2023-05-24 16:21:38 +00:00
|
|
|
import { EventEmitter } from "events";
|
2023-06-02 03:58:34 +00:00
|
|
|
import { v4 as uuid } from "uuid";
|
2023-06-06 13:29:04 +00:00
|
|
|
import deepEqual from "deep-equal";
|
|
|
|
|
import deepMerge from "deepmerge";
|
2023-06-15 15:53:21 +00:00
|
|
|
import { TabbyApi, CancelablePromise } from "./generated";
|
|
|
|
|
import { cancelable, splitLines, isBlank } from "./utils";
|
|
|
|
|
import {
|
|
|
|
|
Agent,
|
|
|
|
|
AgentStatus,
|
|
|
|
|
AgentEvent,
|
|
|
|
|
AgentInitOptions,
|
|
|
|
|
CompletionRequest,
|
|
|
|
|
CompletionResponse,
|
|
|
|
|
LogEventRequest,
|
|
|
|
|
} from "./Agent";
|
|
|
|
|
import { Auth } from "./Auth";
|
2023-07-03 03:19:09 +00:00
|
|
|
import { AgentConfig, defaultAgentConfig, userAgentConfig } from "./AgentConfig";
|
2023-06-06 13:29:04 +00:00
|
|
|
import { CompletionCache } from "./CompletionCache";
|
2023-06-15 15:53:21 +00:00
|
|
|
import { DataStore } from "./dataStore";
|
2023-06-08 17:19:10 +00:00
|
|
|
import { postprocess } from "./postprocess";
|
2023-06-06 14:25:31 +00:00
|
|
|
import { rootLogger, allLoggers } from "./logger";
|
2023-06-16 08:58:50 +00:00
|
|
|
import { AnonymousUsageLogger } from "./AnonymousUsageLogger";
|
2023-05-24 01:50:57 +00:00
|
|
|
|
2023-06-15 15:53:21 +00:00
|
|
|
/**
|
|
|
|
|
* Different from AgentInitOptions or AgentConfig, this may contain non-serializable objects,
|
|
|
|
|
* so it is not suitable for cli, but only used when imported as module by other js project.
|
|
|
|
|
*/
|
|
|
|
|
export type TabbyAgentOptions = {
|
|
|
|
|
dataStore: DataStore;
|
|
|
|
|
};
|
|
|
|
|
|
2023-05-24 01:50:57 +00:00
|
|
|
export class TabbyAgent extends EventEmitter implements Agent {
|
2023-06-06 14:25:31 +00:00
|
|
|
private readonly logger = rootLogger.child({ component: "TabbyAgent" });
|
2023-06-16 08:58:50 +00:00
|
|
|
private anonymousUsageLogger: AnonymousUsageLogger;
|
2023-06-06 13:29:04 +00:00
|
|
|
private config: AgentConfig = defaultAgentConfig;
|
2023-07-03 03:19:09 +00:00
|
|
|
private userConfig: Partial<AgentConfig> = {}; // config from `~/.tabby/agent/config.toml`
|
|
|
|
|
private clientConfig: Partial<AgentConfig> = {}; // config from `initialize` and `updateConfig` method
|
2023-06-15 15:53:21 +00:00
|
|
|
private status: AgentStatus = "notInitialized";
|
2023-05-24 01:50:57 +00:00
|
|
|
private api: TabbyApi;
|
2023-06-15 15:53:21 +00:00
|
|
|
private auth: Auth;
|
|
|
|
|
private dataStore: DataStore | null = null;
|
2023-06-06 13:29:04 +00:00
|
|
|
private completionCache: CompletionCache = new CompletionCache();
|
2023-06-15 15:53:21 +00:00
|
|
|
static readonly tryConnectInterval = 1000 * 30; // 30s
|
|
|
|
|
private tryingConnectTimer: ReturnType<typeof setInterval> | null = null;
|
2023-05-24 01:50:57 +00:00
|
|
|
|
2023-06-15 15:53:21 +00:00
|
|
|
private constructor() {
|
2023-05-24 01:50:57 +00:00
|
|
|
super();
|
2023-06-15 15:53:21 +00:00
|
|
|
|
|
|
|
|
this.tryingConnectTimer = setInterval(async () => {
|
|
|
|
|
if (this.status === "disconnected") {
|
|
|
|
|
this.logger.debug("Trying to connect...");
|
|
|
|
|
await this.healthCheck();
|
|
|
|
|
}
|
|
|
|
|
}, TabbyAgent.tryConnectInterval);
|
2023-06-06 13:29:04 +00:00
|
|
|
}
|
|
|
|
|
|
2023-06-15 15:53:21 +00:00
|
|
|
static async create(options?: Partial<TabbyAgentOptions>): Promise<TabbyAgent> {
|
|
|
|
|
const agent = new TabbyAgent();
|
|
|
|
|
agent.dataStore = options?.dataStore;
|
2023-06-16 08:58:50 +00:00
|
|
|
agent.anonymousUsageLogger = await AnonymousUsageLogger.create({ dataStore: options?.dataStore });
|
2023-06-15 15:53:21 +00:00
|
|
|
return agent;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async applyConfig() {
|
2023-07-03 03:19:09 +00:00
|
|
|
this.config = deepMerge.all<AgentConfig>([defaultAgentConfig, this.userConfig, this.clientConfig]);
|
2023-06-06 14:25:31 +00:00
|
|
|
allLoggers.forEach((logger) => (logger.level = this.config.logs.level));
|
2023-06-16 08:58:50 +00:00
|
|
|
this.anonymousUsageLogger.disabled = this.config.anonymousUsageTracking.disable;
|
2023-06-15 15:53:21 +00:00
|
|
|
if (this.config.server.endpoint !== this.auth?.endpoint) {
|
|
|
|
|
this.auth = await Auth.create({ endpoint: this.config.server.endpoint, dataStore: this.dataStore });
|
2023-06-24 13:33:33 +00:00
|
|
|
this.auth.on("updated", this.setupApi.bind(this));
|
2023-06-15 15:53:21 +00:00
|
|
|
}
|
2023-06-24 13:33:33 +00:00
|
|
|
await this.setupApi();
|
2023-05-24 01:50:57 +00:00
|
|
|
}
|
|
|
|
|
|
2023-06-24 13:33:33 +00:00
|
|
|
private async setupApi() {
|
|
|
|
|
this.api = new TabbyApi({
|
|
|
|
|
BASE: this.config.server.endpoint.replace(/\/+$/, ""), // remove trailing slash
|
|
|
|
|
TOKEN: this.auth?.token,
|
|
|
|
|
});
|
2023-06-15 15:53:21 +00:00
|
|
|
await this.healthCheck();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private changeStatus(status: AgentStatus) {
|
2023-05-24 01:50:57 +00:00
|
|
|
if (this.status != status) {
|
|
|
|
|
this.status = status;
|
|
|
|
|
const event: AgentEvent = { event: "statusChanged", status };
|
2023-06-06 14:25:31 +00:00
|
|
|
this.logger.debug({ event }, "Status changed");
|
2023-05-24 16:21:38 +00:00
|
|
|
super.emit("statusChanged", event);
|
2023-05-24 01:50:57 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-06 14:25:31 +00:00
|
|
|
private callApi<Request, Response>(
|
|
|
|
|
api: (request: Request) => CancelablePromise<Response>,
|
|
|
|
|
request: Request
|
|
|
|
|
): CancelablePromise<Response> {
|
|
|
|
|
this.logger.debug({ api: api.name, request }, "API request");
|
2023-06-07 16:11:31 +00:00
|
|
|
const promise = api.call(this.api.v1, request);
|
2023-05-29 02:09:44 +00:00
|
|
|
return cancelable(
|
2023-05-24 01:50:57 +00:00
|
|
|
promise
|
2023-06-06 14:25:31 +00:00
|
|
|
.then((response: Response) => {
|
|
|
|
|
this.logger.debug({ api: api.name, response }, "API response");
|
2023-05-24 01:50:57 +00:00
|
|
|
this.changeStatus("ready");
|
2023-06-06 14:25:31 +00:00
|
|
|
return response;
|
2023-05-24 01:50:57 +00:00
|
|
|
})
|
2023-06-15 15:53:21 +00:00
|
|
|
.catch((error) => {
|
|
|
|
|
if (!!error.isCancelled) {
|
|
|
|
|
this.logger.debug({ api: api.name, error }, "API request canceled");
|
|
|
|
|
} else if (error.name === "ApiError" && [401, 403, 405].indexOf(error.status) !== -1) {
|
|
|
|
|
this.logger.debug({ api: api.name, error }, "API unauthorized");
|
|
|
|
|
this.changeStatus("unauthorized");
|
|
|
|
|
} else if (error.name === "ApiError") {
|
|
|
|
|
this.logger.error({ api: api.name, error }, "API error");
|
|
|
|
|
this.changeStatus("disconnected");
|
|
|
|
|
} else {
|
|
|
|
|
this.logger.error({ api: api.name, error }, "API request failed with unknown error");
|
|
|
|
|
this.changeStatus("disconnected");
|
|
|
|
|
}
|
2023-06-06 14:25:31 +00:00
|
|
|
throw error;
|
2023-05-29 02:09:44 +00:00
|
|
|
}),
|
|
|
|
|
() => {
|
2023-05-24 01:50:57 +00:00
|
|
|
promise.cancel();
|
2023-05-29 02:09:44 +00:00
|
|
|
}
|
|
|
|
|
);
|
2023-05-24 01:50:57 +00:00
|
|
|
}
|
|
|
|
|
|
2023-06-24 13:33:33 +00:00
|
|
|
private healthCheck(): Promise<any> {
|
2023-06-15 15:53:21 +00:00
|
|
|
return this.callApi(this.api.v1.health, {}).catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-07 16:11:31 +00:00
|
|
|
private createSegments(request: CompletionRequest): { prefix: string; suffix: string } {
|
2023-06-22 03:01:57 +00:00
|
|
|
// max lines in prefix and suffix configurable
|
|
|
|
|
const maxPrefixLines = request.maxPrefixLines;
|
|
|
|
|
const maxSuffixLines = request.maxSuffixLines;
|
2023-06-02 03:58:34 +00:00
|
|
|
const prefix = request.text.slice(0, request.position);
|
2023-06-07 16:11:31 +00:00
|
|
|
const prefixLines = splitLines(prefix);
|
|
|
|
|
const suffix = request.text.slice(request.position);
|
|
|
|
|
const suffixLines = splitLines(suffix);
|
|
|
|
|
return {
|
2023-06-22 03:01:57 +00:00
|
|
|
prefix: prefixLines.slice(Math.max(prefixLines.length - maxPrefixLines, 0)).join(""),
|
|
|
|
|
suffix: suffixLines.slice(0, maxSuffixLines).join(""),
|
2023-06-07 16:11:31 +00:00
|
|
|
};
|
2023-06-02 03:58:34 +00:00
|
|
|
}
|
|
|
|
|
|
2023-06-15 15:53:21 +00:00
|
|
|
public async initialize(options: Partial<AgentInitOptions>): Promise<boolean> {
|
|
|
|
|
if (options.client) {
|
2023-06-06 14:25:31 +00:00
|
|
|
// Client info is only used in logging for now
|
|
|
|
|
// `pino.Logger.setBindings` is not present in the browser
|
2023-06-24 13:33:33 +00:00
|
|
|
allLoggers.forEach((logger) => logger.setBindings?.({ client: options.client }));
|
|
|
|
|
}
|
2023-07-03 03:19:09 +00:00
|
|
|
if (userAgentConfig) {
|
|
|
|
|
await userAgentConfig.load();
|
|
|
|
|
this.userConfig = userAgentConfig.config;
|
|
|
|
|
userAgentConfig.on("updated", async (config) => {
|
|
|
|
|
this.userConfig = config;
|
|
|
|
|
await this.applyConfig();
|
|
|
|
|
});
|
|
|
|
|
userAgentConfig.watch();
|
|
|
|
|
}
|
2023-06-24 13:33:33 +00:00
|
|
|
if (options.config) {
|
2023-07-03 03:19:09 +00:00
|
|
|
this.clientConfig = deepMerge(this.clientConfig, options.config);
|
2023-06-06 14:25:31 +00:00
|
|
|
}
|
2023-06-24 13:33:33 +00:00
|
|
|
await this.applyConfig();
|
2023-06-24 21:43:13 +00:00
|
|
|
if (this.status === "unauthorized") {
|
|
|
|
|
const event: AgentEvent = { event: "authRequired", server: this.config.server };
|
|
|
|
|
super.emit("authRequired", event);
|
|
|
|
|
}
|
2023-06-16 08:58:50 +00:00
|
|
|
await this.anonymousUsageLogger.event("AgentInitialized", {
|
|
|
|
|
client: options.client,
|
|
|
|
|
});
|
2023-06-15 15:53:21 +00:00
|
|
|
this.logger.debug({ options }, "Initialized");
|
|
|
|
|
return this.status !== "notInitialized";
|
2023-06-06 13:29:04 +00:00
|
|
|
}
|
|
|
|
|
|
2023-06-15 15:53:21 +00:00
|
|
|
public async updateConfig(config: Partial<AgentConfig>): Promise<boolean> {
|
2023-07-03 03:19:09 +00:00
|
|
|
const mergedConfig = deepMerge(this.clientConfig, config);
|
|
|
|
|
if (!deepEqual(this.clientConfig, mergedConfig)) {
|
2023-06-24 21:43:13 +00:00
|
|
|
const serverUpdated = !deepEqual(this.config.server, mergedConfig.server);
|
2023-07-03 03:19:09 +00:00
|
|
|
this.clientConfig = mergedConfig;
|
2023-06-15 15:53:21 +00:00
|
|
|
await this.applyConfig();
|
2023-06-06 13:29:04 +00:00
|
|
|
const event: AgentEvent = { event: "configUpdated", config: this.config };
|
2023-06-06 14:25:31 +00:00
|
|
|
this.logger.debug({ event }, "Config updated");
|
2023-06-06 13:29:04 +00:00
|
|
|
super.emit("configUpdated", event);
|
2023-06-24 21:43:13 +00:00
|
|
|
if (serverUpdated && this.status === "unauthorized") {
|
|
|
|
|
const event: AgentEvent = { event: "authRequired", server: this.config.server };
|
|
|
|
|
super.emit("authRequired", event);
|
|
|
|
|
}
|
2023-06-06 13:29:04 +00:00
|
|
|
}
|
2023-06-23 06:43:55 +00:00
|
|
|
return true;
|
2023-05-24 01:50:57 +00:00
|
|
|
}
|
|
|
|
|
|
2023-06-06 13:29:04 +00:00
|
|
|
public getConfig(): AgentConfig {
|
|
|
|
|
return this.config;
|
2023-05-24 01:50:57 +00:00
|
|
|
}
|
|
|
|
|
|
2023-06-15 15:53:21 +00:00
|
|
|
public getStatus(): AgentStatus {
|
2023-05-24 16:21:38 +00:00
|
|
|
return this.status;
|
|
|
|
|
}
|
|
|
|
|
|
2023-06-24 21:43:13 +00:00
|
|
|
public requestAuthUrl(): CancelablePromise<{ authUrl: string; code: string } | null> {
|
|
|
|
|
if (this.status === "notInitialized") {
|
|
|
|
|
return cancelable(Promise.reject("Agent is not initialized"), () => {});
|
|
|
|
|
}
|
|
|
|
|
return new CancelablePromise(async (resolve, reject, onCancel) => {
|
|
|
|
|
let request: CancelablePromise<{ authUrl: string; code: string }>;
|
|
|
|
|
onCancel(() => {
|
|
|
|
|
request?.cancel();
|
|
|
|
|
});
|
|
|
|
|
await this.healthCheck();
|
|
|
|
|
if (onCancel.isCancelled) return;
|
|
|
|
|
if (this.status === "unauthorized") {
|
|
|
|
|
request = this.auth.requestAuthUrl();
|
|
|
|
|
resolve(request);
|
|
|
|
|
} else {
|
|
|
|
|
}
|
|
|
|
|
resolve(null);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public waitForAuthToken(code: string): CancelablePromise<any> {
|
2023-06-23 06:43:55 +00:00
|
|
|
if (this.status === "notInitialized") {
|
2023-06-24 21:43:13 +00:00
|
|
|
return cancelable(Promise.reject("Agent is not initialized"), () => {});
|
2023-06-23 06:43:55 +00:00
|
|
|
}
|
2023-06-24 21:43:13 +00:00
|
|
|
const polling = this.auth.pollingToken(code);
|
2023-06-15 15:53:21 +00:00
|
|
|
return cancelable(
|
2023-06-24 21:43:13 +00:00
|
|
|
polling.then(() => {
|
|
|
|
|
return this.setupApi();
|
2023-06-15 15:53:21 +00:00
|
|
|
}),
|
|
|
|
|
() => {
|
2023-06-24 21:43:13 +00:00
|
|
|
polling.cancel();
|
2023-06-15 15:53:21 +00:00
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2023-05-24 01:50:57 +00:00
|
|
|
public getCompletions(request: CompletionRequest): CancelablePromise<CompletionResponse> {
|
2023-06-15 15:53:21 +00:00
|
|
|
if (this.status === "notInitialized") {
|
2023-06-24 21:43:13 +00:00
|
|
|
return cancelable(Promise.reject("Agent is not initialized"), () => {});
|
2023-06-15 15:53:21 +00:00
|
|
|
}
|
2023-05-29 02:09:44 +00:00
|
|
|
if (this.completionCache.has(request)) {
|
2023-06-06 14:25:31 +00:00
|
|
|
this.logger.debug({ request }, "Completion cache hit");
|
2023-05-29 02:09:44 +00:00
|
|
|
return new CancelablePromise((resolve) => {
|
|
|
|
|
resolve(this.completionCache.get(request));
|
|
|
|
|
});
|
|
|
|
|
}
|
2023-06-07 16:11:31 +00:00
|
|
|
const segments = this.createSegments(request);
|
|
|
|
|
if (isBlank(segments.prefix)) {
|
|
|
|
|
this.logger.debug("Segment prefix is blank, returning empty completion response");
|
2023-06-02 03:58:34 +00:00
|
|
|
return new CancelablePromise((resolve) => {
|
|
|
|
|
resolve({
|
|
|
|
|
id: "agent-" + uuid(),
|
2023-06-06 13:29:04 +00:00
|
|
|
choices: [],
|
2023-06-02 03:58:34 +00:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
2023-06-07 16:11:31 +00:00
|
|
|
const promise = this.callApi(this.api.v1.completion, {
|
2023-06-06 14:25:31 +00:00
|
|
|
language: request.language,
|
2023-06-07 16:11:31 +00:00
|
|
|
segments,
|
2023-06-16 17:50:47 +00:00
|
|
|
user: this.auth?.user,
|
2023-06-06 14:25:31 +00:00
|
|
|
});
|
2023-05-29 02:09:44 +00:00
|
|
|
return cancelable(
|
2023-06-08 17:19:10 +00:00
|
|
|
promise
|
|
|
|
|
.then((response) => {
|
|
|
|
|
this.completionCache.set(request, response);
|
|
|
|
|
return response;
|
2023-06-22 06:22:35 +00:00
|
|
|
})
|
|
|
|
|
.then((response) => {
|
|
|
|
|
return postprocess(request, response);
|
2023-06-08 17:19:10 +00:00
|
|
|
}),
|
2023-05-29 02:09:44 +00:00
|
|
|
() => {
|
|
|
|
|
promise.cancel();
|
|
|
|
|
}
|
|
|
|
|
);
|
2023-05-24 01:50:57 +00:00
|
|
|
}
|
|
|
|
|
|
2023-06-07 16:11:31 +00:00
|
|
|
public postEvent(request: LogEventRequest): CancelablePromise<boolean> {
|
2023-06-15 15:53:21 +00:00
|
|
|
if (this.status === "notInitialized") {
|
2023-06-24 21:43:13 +00:00
|
|
|
return cancelable(Promise.reject("Agent is not initialized"), () => {});
|
2023-06-15 15:53:21 +00:00
|
|
|
}
|
2023-06-07 16:11:31 +00:00
|
|
|
return this.callApi(this.api.v1.event, request);
|
2023-05-24 01:50:57 +00:00
|
|
|
}
|
|
|
|
|
}
|