refactor(agent): agent http request and cancellation flow. (#446)

* refactor(agent): refactor http request and cancellation flow.

* fix: minor fixes.

* fix: minor fix cheking timeout error in stats.
release-0.2
Zhiming Ma 2023-09-15 11:05:46 +08:00 committed by GitHub
parent 5897a776bb
commit efe2dcbb0f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 1139 additions and 1023 deletions

View File

@ -10,6 +10,6 @@
"devDependencies": { "devDependencies": {
"cpy-cli": "^4.2.0", "cpy-cli": "^4.2.0",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"tabby-agent": "0.1.1" "tabby-agent": "0.2.0-dev"
} }
} }

View File

@ -1,16 +1,14 @@
{ {
"name": "tabby-agent", "name": "tabby-agent",
"version": "0.1.1", "version": "0.2.0-dev",
"description": "Generic client agent for Tabby AI coding assistant IDE extensions.", "description": "Generic client agent for Tabby AI coding assistant IDE extensions.",
"repository": "https://github.com/TabbyML/tabby", "repository": "https://github.com/TabbyML/tabby",
"main": "./dist/index.js", "main": "./dist/index.js",
"browser": "./dist/index.mjs", "browser": "./dist/index.mjs",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"scripts": { "scripts": {
"openapi-codegen": "rimraf ./src/generated && openapi --input ./openapi/tabby.json --output ./src/generated --client axios --name TabbyApi --indent 2", "openapi-codegen": "openapi-typescript ./openapi/tabby.json -o ./src/types/tabbyApi.d.ts",
"predev": "yarn openapi-codegen",
"dev": "tsup --watch --no-minify --no-treeshake", "dev": "tsup --watch --no-minify --no-treeshake",
"prebuild": "yarn openapi-codegen",
"build": "tsup", "build": "tsup",
"test:watch": "env TEST_LOG_DEBUG=1 mocha --watch", "test:watch": "env TEST_LOG_DEBUG=1 mocha --watch",
"test": "mocha", "test": "mocha",
@ -20,12 +18,12 @@
"@types/chai": "^4.3.5", "@types/chai": "^4.3.5",
"@types/fs-extra": "^11.0.1", "@types/fs-extra": "^11.0.1",
"@types/mocha": "^10.0.1", "@types/mocha": "^10.0.1",
"@types/node": "^16.18.32", "@types/node": "^18.12.0",
"chai": "^4.3.7", "chai": "^4.3.7",
"dedent": "^0.7.0", "dedent": "^0.7.0",
"esbuild-plugin-polyfill-node": "^0.3.0", "esbuild-plugin-polyfill-node": "^0.3.0",
"mocha": "^10.2.0", "mocha": "^10.2.0",
"openapi-typescript-codegen": "^0.24.0", "openapi-typescript": "^6.6.1",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
@ -33,18 +31,17 @@
"typescript": "^5.0.3" "typescript": "^5.0.3"
}, },
"dependencies": { "dependencies": {
"axios": "^1.4.0",
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"deep-equal": "^2.2.1", "deep-equal": "^2.2.1",
"deepmerge-ts": "^5.1.0", "deepmerge-ts": "^5.1.0",
"dot-prop": "^8.0.2", "dot-prop": "^8.0.2",
"fast-levenshtein": "^3.0.0", "fast-levenshtein": "^3.0.0",
"form-data": "^4.0.0",
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",
"jwt-decode": "^3.1.2", "jwt-decode": "^3.1.2",
"lru-cache": "^9.1.1", "lru-cache": "^9.1.1",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"object-sizeof": "^2.6.1", "object-sizeof": "^2.6.1",
"openapi-fetch": "^0.7.6",
"pino": "^8.14.1", "pino": "^8.14.1",
"rotating-file-stream": "^3.1.0", "rotating-file-stream": "^3.1.0",
"toml": "^3.0.0", "toml": "^3.0.0",

View File

@ -1,10 +1,4 @@
import { import type { components as ApiComponents } from "./types/tabbyApi";
CancelablePromise,
LogEventRequest as ApiLogEventRequest,
CompletionResponse as ApiCompletionResponse,
HealthState,
} from "./generated";
import { AgentConfig, PartialAgentConfig } from "./AgentConfig"; import { AgentConfig, PartialAgentConfig } from "./AgentConfig";
export type AgentInitOptions = Partial<{ export type AgentInitOptions = Partial<{
@ -13,7 +7,7 @@ export type AgentInitOptions = Partial<{
clientProperties: Record<string, any>; clientProperties: Record<string, any>;
}>; }>;
export type ServerHealthState = HealthState; export type ServerHealthState = ApiComponents["schemas"]["HealthState"];
export type CompletionRequest = { export type CompletionRequest = {
filepath: string; filepath: string;
@ -23,9 +17,11 @@ export type CompletionRequest = {
manually?: boolean; manually?: boolean;
}; };
export type CompletionResponse = ApiCompletionResponse; export type CompletionResponse = ApiComponents["schemas"]["CompletionResponse"];
export type LogEventRequest = ApiLogEventRequest; export type LogEventRequest = ApiComponents["schemas"]["LogEventRequest"];
export type AbortSignalOption = { signal: AbortSignal };
export type SlowCompletionResponseTimeIssue = { export type SlowCompletionResponseTimeIssue = {
name: "slowCompletionResponseTime"; name: "slowCompletionResponseTime";
@ -58,7 +54,7 @@ export interface AgentFunction {
* Initialize agent. Client should call this method before calling any other methods. * Initialize agent. Client should call this method before calling any other methods.
* @param options * @param options
*/ */
initialize(options: AgentInitOptions): Promise<boolean>; initialize(options?: AgentInitOptions): Promise<boolean>;
/** /**
* The agent configuration has the following levels, will be deep merged in the order: * The agent configuration has the following levels, will be deep merged in the order:
@ -104,7 +100,7 @@ export interface AgentFunction {
* @returns the auth url for redirecting, and the code for next step `waitingForAuth` * @returns the auth url for redirecting, and the code for next step `waitingForAuth`
* @throws Error if agent is not initialized * @throws Error if agent is not initialized
*/ */
requestAuthUrl(): CancelablePromise<{ authUrl: string; code: string } | null>; requestAuthUrl(options?: AbortSignalOption): Promise<{ authUrl: string; code: string } | null>;
/** /**
* Wait for auth token to be ready after redirecting user to auth url, * Wait for auth token to be ready after redirecting user to auth url,
@ -112,7 +108,7 @@ export interface AgentFunction {
* @param code from `requestAuthUrl` * @param code from `requestAuthUrl`
* @throws Error if agent is not initialized * @throws Error if agent is not initialized
*/ */
waitForAuthToken(code: string): CancelablePromise<any>; waitForAuthToken(code: string, options?: AbortSignalOption): Promise<void>;
/** /**
* Provide completions for the given request. This method is debounced, calling it before the previous * Provide completions for the given request. This method is debounced, calling it before the previous
@ -122,14 +118,14 @@ export interface AgentFunction {
* @returns * @returns
* @throws Error if agent is not initialized * @throws Error if agent is not initialized
*/ */
provideCompletions(request: CompletionRequest): CancelablePromise<CompletionResponse>; provideCompletions(request: CompletionRequest, options?: AbortSignalOption): Promise<CompletionResponse>;
/** /**
* @param event * @param event
* @returns * @returns
* @throws Error if agent is not initialized * @throws Error if agent is not initialized
*/ */
postEvent(event: LogEventRequest): CancelablePromise<boolean>; postEvent(event: LogEventRequest, options?: AbortSignalOption): Promise<boolean>;
} }
export type StatusChangedEvent = { export type StatusChangedEvent = {

View File

@ -3,7 +3,7 @@ import { isBrowser } from "./env";
export type AgentConfig = { export type AgentConfig = {
server: { server: {
endpoint: string; endpoint: string;
requestHeaders: Record<string, string>; requestHeaders: Record<string, string | number | boolean | null | undefined>;
requestTimeout: number; requestTimeout: number;
}; };
completion: { completion: {

View File

@ -1,12 +1,13 @@
import { name as agentName, version as agentVersion } from "../package.json"; import { name as agentName, version as agentVersion } from "../package.json";
import { CloudApi } from "./cloud"; import createClient from "openapi-fetch";
import type { paths as CloudApi } from "./types/cloudApi";
import { v4 as uuid } from "uuid"; import { v4 as uuid } from "uuid";
import { isBrowser } from "./env"; import { isBrowser } from "./env";
import { rootLogger } from "./logger"; import { rootLogger } from "./logger";
import { dataStore, DataStore } from "./dataStore"; import { dataStore, DataStore } from "./dataStore";
export class AnonymousUsageLogger { export class AnonymousUsageLogger {
private anonymousUsageTrackingApi = new CloudApi(); private anonymousUsageTrackingApi = createClient<CloudApi>({ baseUrl: "https://app.tabbyml.com/api" });
private logger = rootLogger.child({ component: "AnonymousUsage" }); private logger = rootLogger.child({ component: "AnonymousUsage" });
private systemData = { private systemData = {
agent: `${agentName}, ${agentVersion}`, agent: `${agentName}, ${agentVersion}`,
@ -73,8 +74,9 @@ export class AnonymousUsageLogger {
if (unique) { if (unique) {
this.emittedUniqueEvent.push(event); this.emittedUniqueEvent.push(event);
} }
await this.anonymousUsageTrackingApi.api try {
.usage({ await this.anonymousUsageTrackingApi.POST("/usage", {
body: {
distinctId: this.anonymousId, distinctId: this.anonymousId,
event, event,
properties: { properties: {
@ -82,9 +84,10 @@ export class AnonymousUsageLogger {
...this.properties, ...this.properties,
...data, ...data,
}, },
}) },
.catch((error) => {
this.logger.error({ error }, "Error when sending anonymous usage data");
}); });
} catch (error) {
this.logger.error({ error }, "Error when sending anonymous usage data");
}
} }
} }

View File

@ -1,7 +1,9 @@
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import decodeJwt from "jwt-decode"; import decodeJwt from "jwt-decode";
import { CloudApi, DeviceTokenResponse, DeviceTokenAcceptResponse } from "./cloud"; import createClient from "openapi-fetch";
import { ApiError, CancelablePromise } from "./generated"; import type { paths as CloudApi } from "./types/cloudApi";
import type { AbortSignalOption } from "./Agent";
import { HttpError, abortSignalFromAnyOf } from "./utils";
import { dataStore, DataStore } from "./dataStore"; import { dataStore, DataStore } from "./dataStore";
import { rootLogger } from "./logger"; import { rootLogger } from "./logger";
@ -40,7 +42,7 @@ export class Auth extends EventEmitter {
readonly endpoint: string; readonly endpoint: string;
readonly dataStore: DataStore | null = null; readonly dataStore: DataStore | null = null;
private refreshTokenTimer: ReturnType<typeof setInterval> | null = null; private refreshTokenTimer: ReturnType<typeof setInterval> | null = null;
private authApi: CloudApi | null = null; private authApi: ReturnType<typeof createClient<CloudApi>>;
private jwt: JWT | null = null; private jwt: JWT | null = null;
static async create(options: { endpoint: string; dataStore?: DataStore }): Promise<Auth> { static async create(options: { endpoint: string; dataStore?: DataStore }): Promise<Auth> {
@ -53,7 +55,7 @@ export class Auth extends EventEmitter {
super(); super();
this.endpoint = options.endpoint; this.endpoint = options.endpoint;
this.dataStore = options.dataStore || dataStore; this.dataStore = options.dataStore || dataStore;
this.authApi = new CloudApi(); this.authApi = createClient<CloudApi>({ baseUrl: "https://app.tabbyml.com/api" });
this.scheduleRefreshToken(); this.scheduleRefreshToken();
} }
@ -113,47 +115,52 @@ export class Auth extends EventEmitter {
} }
} }
requestAuthUrl(): CancelablePromise<{ authUrl: string; code: string }> { async requestAuthUrl(options?: AbortSignalOption): Promise<{ authUrl: string; code: string }> {
return new CancelablePromise(async (resolve, reject, onCancel) => {
let apiRequest: CancelablePromise<DeviceTokenResponse>;
onCancel(() => {
apiRequest?.cancel();
});
try { try {
await this.reset(); await this.reset();
if (onCancel.isCancelled) return; if (options?.signal.aborted) {
throw options.signal.reason;
}
this.logger.debug("Start to request device token"); this.logger.debug("Start to request device token");
apiRequest = this.authApi.api.deviceToken({ auth_url: this.endpoint }); const response = await this.authApi.POST("/device-token", {
const deviceToken = await apiRequest; body: { auth_url: this.endpoint },
signal: options?.signal,
});
if (response.error) {
throw new HttpError(response.response);
}
const deviceToken = response.data;
this.logger.debug({ deviceToken }, "Request device token response"); this.logger.debug({ deviceToken }, "Request device token response");
const authUrl = new URL(Auth.authPageUrl); const authUrl = new URL(Auth.authPageUrl);
authUrl.searchParams.append("code", deviceToken.data.code); authUrl.searchParams.append("code", deviceToken.data.code);
resolve({ authUrl: authUrl.toString(), code: deviceToken.data.code }); return { authUrl: authUrl.toString(), code: deviceToken.data.code };
} catch (error) { } catch (error) {
this.logger.error({ error }, "Error when requesting token"); this.logger.error({ error }, "Error when requesting token");
reject(error); throw error;
} }
});
} }
pollingToken(code: string): CancelablePromise<boolean> { async pollingToken(code: string, options?: AbortSignalOption): Promise<boolean> {
return new CancelablePromise((resolve, reject, onCancel) => { return new Promise((resolve, reject) => {
let apiRequest: CancelablePromise<DeviceTokenAcceptResponse>; const signal = abortSignalFromAnyOf([AbortSignal.timeout(Auth.tokenStrategy.polling.timeout), options?.signal]);
const timer = setInterval(async () => { const timer = setInterval(async () => {
try { try {
apiRequest = this.authApi.api.deviceTokenAccept({ code }); const response = await this.authApi.POST("/device-token/accept", { params: { query: { code } }, signal });
const response = await apiRequest; if (response.error) {
this.logger.debug({ response }, "Poll jwt response"); throw new HttpError(response.response);
}
const result = response.data;
this.logger.debug({ result }, "Poll jwt response");
this.jwt = { this.jwt = {
token: response.data.jwt, token: result.data.jwt,
payload: decodeJwt(response.data.jwt), payload: decodeJwt(result.data.jwt),
}; };
super.emit("updated", this.jwt); super.emit("updated", this.jwt);
await this.save(); await this.save();
clearInterval(timer); clearInterval(timer);
resolve(true); resolve(true);
} catch (error) { } catch (error) {
if (error instanceof ApiError && [400, 401, 403, 405].indexOf(error.status) !== -1) { if (error instanceof HttpError && [400, 401, 403, 405].indexOf(error.status) !== -1) {
this.logger.debug({ error }, "Expected error when polling jwt"); this.logger.debug({ error }, "Expected error when polling jwt");
} else { } else {
// unknown error but still keep polling // unknown error but still keep polling
@ -161,28 +168,35 @@ export class Auth extends EventEmitter {
} }
} }
}, Auth.tokenStrategy.polling.interval); }, Auth.tokenStrategy.polling.interval);
setTimeout(() => { if (signal.aborted) {
clearInterval(timer); clearInterval(timer);
reject(new Error("Timeout when polling token")); reject(signal.reason);
}, Auth.tokenStrategy.polling.timeout); } else {
onCancel(() => { signal.addEventListener("abort", () => {
apiRequest?.cancel();
clearInterval(timer); clearInterval(timer);
reject(signal.reason);
}); });
}
}); });
} }
private async refreshToken(jwt: JWT, options = { maxTry: 1, retryDelay: 1000 }, retry = 0): Promise<JWT> { private async refreshToken(jwt: JWT, options = { maxTry: 1, retryDelay: 1000 }, retry = 0): Promise<JWT> {
try { try {
this.logger.debug({ retry }, "Start to refresh token"); this.logger.debug({ retry }, "Start to refresh token");
const refreshedJwt = await this.authApi.api.deviceTokenRefresh(jwt.token); const response = await this.authApi.POST("/device-token/refresh", {
headers: { Authorization: `Bearer ${jwt.token}` },
});
if (response.error) {
throw new HttpError(response.response);
}
const refreshedJwt = response.data;
this.logger.debug({ refreshedJwt }, "Refresh token response"); this.logger.debug({ refreshedJwt }, "Refresh token response");
return { return {
token: refreshedJwt.data.jwt, token: refreshedJwt.data.jwt,
payload: decodeJwt(refreshedJwt.data.jwt), payload: decodeJwt(refreshedJwt.data.jwt),
}; };
} catch (error) { } catch (error) {
if (error instanceof ApiError && [400, 401, 403, 405].indexOf(error.status) !== -1) { if (error instanceof HttpError && [400, 401, 403, 405].indexOf(error.status) !== -1) {
this.logger.debug({ error }, "Error when refreshing jwt"); this.logger.debug({ error }, "Error when refreshing jwt");
} else { } else {
// unknown error, retry a few times // unknown error, retry a few times

View File

@ -1,6 +1,5 @@
import { CancelablePromise } from "./generated"; import type { CompletionRequest, AbortSignalOption } from "./Agent";
import { CompletionRequest } from "./Agent"; import type { AgentConfig } from "./AgentConfig";
import { AgentConfig } from "./AgentConfig";
import { rootLogger } from "./logger"; import { rootLogger } from "./logger";
import { splitLines } from "./utils"; import { splitLines } from "./utils";
@ -10,7 +9,6 @@ function clamp(min: number, max: number, value: number): number {
export class CompletionDebounce { export class CompletionDebounce {
private readonly logger = rootLogger.child({ component: "CompletionDebounce" }); private readonly logger = rootLogger.child({ component: "CompletionDebounce" });
private ongoing: CancelablePromise<any> | null = null;
private lastCalledTimeStamp = 0; private lastCalledTimeStamp = 0;
private baseInterval = 200; // ms private baseInterval = 200; // ms
@ -38,16 +36,20 @@ export class CompletionDebounce {
}, },
}; };
debounce( async debounce(
request: CompletionRequest, context: {
config: AgentConfig["completion"]["debounce"], request: CompletionRequest;
responseTime: number, config: AgentConfig["completion"]["debounce"];
): CancelablePromise<any> { responseTime: number;
},
options?: AbortSignalOption,
): Promise<void> {
const { request, config, responseTime } = context;
if (request.manually) { if (request.manually) {
return this.renewPromise(0); return this.sleep(0, options);
} }
if (config.mode === "fixed") { if (config.mode === "fixed") {
return this.renewPromise(config.interval); return this.sleep(config.interval, options);
} }
const now = Date.now(); const now = Date.now();
this.updateBaseInterval(now - this.lastCalledTimeStamp); this.updateBaseInterval(now - this.lastCalledTimeStamp);
@ -57,25 +59,24 @@ export class CompletionDebounce {
this.options.adaptiveRate.max - (this.options.adaptiveRate.max - this.options.adaptiveRate.min) * contextScore; this.options.adaptiveRate.max - (this.options.adaptiveRate.max - this.options.adaptiveRate.min) * contextScore;
const expectedLatency = adaptiveRate * this.baseInterval; const expectedLatency = adaptiveRate * this.baseInterval;
const delay = clamp(this.options.requestDelay.min, this.options.requestDelay.max, expectedLatency - responseTime); const delay = clamp(this.options.requestDelay.min, this.options.requestDelay.max, expectedLatency - responseTime);
return this.renewPromise(delay); return this.sleep(delay, options);
} }
private renewPromise(delay: number): CancelablePromise<any> { private async sleep(delay: number, options?: AbortSignalOption): Promise<void> {
if (this.ongoing) { return new Promise((resolve, reject) => {
this.ongoing.cancel(); const timer = setTimeout(resolve, Math.min(delay, 0x7fffffff));
} if (options?.signal) {
this.ongoing = new CancelablePromise<any>((resolve, reject, onCancel) => { if (options.signal.aborted) {
const timer = setTimeout(
() => {
resolve(true);
},
Math.min(delay, 0x7fffffff),
);
onCancel(() => {
clearTimeout(timer); clearTimeout(timer);
reject(options.signal.reason);
} else {
options.signal.addEventListener("abort", () => {
clearTimeout(timer);
reject(options.signal.reason);
}); });
}
}
}); });
return this.ongoing;
} }
private updateBaseInterval(interval: number) { private updateBaseInterval(interval: number) {

View File

@ -1,5 +1,6 @@
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { rootLogger } from "./logger"; import { rootLogger } from "./logger";
import { isTimeoutError } from "./utils";
export type ResponseStatsEntry = { export type ResponseStatsEntry = {
name: string; name: string;
@ -22,7 +23,7 @@ export const completionResponseTimeStatsStrategy = {
stats: { stats: {
total: (entries: ResponseStatsEntry[]) => entries.length, total: (entries: ResponseStatsEntry[]) => entries.length,
responses: (entries: ResponseStatsEntry[]) => entries.filter((entry) => entry.status === 200).length, responses: (entries: ResponseStatsEntry[]) => entries.filter((entry) => entry.status === 200).length,
timeouts: (entries: ResponseStatsEntry[]) => entries.filter((entry) => entry.error?.isTimeoutError).length, timeouts: (entries: ResponseStatsEntry[]) => entries.filter((entry) => isTimeoutError(entry.error)).length,
averageResponseTime: (entries: ResponseStatsEntry[]) => averageResponseTime: (entries: ResponseStatsEntry[]) =>
entries.filter((entry) => entry.status === 200).reduce((acc, entry) => acc + entry.responseTime, 0) / entries.filter((entry) => entry.status === 200).reduce((acc, entry) => acc + entry.responseTime, 0) /
entries.length, entries.length,

View File

@ -1,4 +1,3 @@
import { CancelablePromise } from "./generated";
import { AgentFunction, AgentEvent, Agent, agentEventNames } from "./Agent"; import { AgentFunction, AgentEvent, Agent, agentEventNames } from "./Agent";
import { rootLogger } from "./logger"; import { rootLogger } from "./logger";
import { splitLines } from "./utils"; import { splitLines } from "./utils";
@ -19,24 +18,24 @@ type CancellationRequest = [
}, },
]; ];
type Request = AgentFunctionRequest<any> | CancellationRequest; type StdIORequest = AgentFunctionRequest<any> | CancellationRequest;
type AgentFunctionResponse<T extends keyof AgentFunction> = [ type AgentFunctionResponse<T extends keyof AgentFunction> = [
id: number, // Matched request id id: number, // Matched request id
data: ReturnType<AgentFunction[T]>, data: ReturnType<AgentFunction[T]>,
]; ];
type AgentEventNotification = { type AgentEventNotification = [
id: 0; id: 0, // Always 0
data: AgentEvent; data: AgentEvent,
}; ];
type CancellationResponse = [ type CancellationResponse = [
id: number, // Matched request id id: number, // Matched request id
data: boolean, data: boolean,
]; ];
type Response = AgentFunctionResponse<any> | AgentEventNotification | CancellationResponse; type StdIOResponse = AgentFunctionResponse<any> | AgentEventNotification | CancellationResponse;
/** /**
* Every request and response should be single line JSON string and end with a newline. * Every request and response should be single line JSON string and end with a newline.
@ -47,13 +46,13 @@ export class StdIO {
private readonly logger = rootLogger.child({ component: "StdIO" }); private readonly logger = rootLogger.child({ component: "StdIO" });
private buffer: string = ""; private buffer: string = "";
private ongoingRequests: { [id: number]: PromiseLike<any> } = {}; private abortControllers: { [id: string]: AbortController } = {};
private agent: Agent | null = null; private agent: Agent | null = null;
constructor() {} constructor() {}
private handleInput(data: Buffer): void { private async handleInput(data: Buffer) {
const input = data.toString(); const input = data.toString();
this.buffer += input; this.buffer += input;
const lines = splitLines(this.buffer); const lines = splitLines(this.buffer);
@ -66,66 +65,68 @@ export class StdIO {
this.buffer = lines.pop()!; this.buffer = lines.pop()!;
} }
for (const line of lines) { for (const line of lines) {
let request: Request | null = null; let request: StdIORequest | null = null;
try { try {
request = JSON.parse(line) as Request; request = JSON.parse(line) as StdIORequest;
} catch (error) { } catch (error) {
this.logger.error({ error }, `Failed to parse request: ${line}`); this.logger.error({ error }, `Failed to parse request: ${line}`);
continue; continue;
} }
this.logger.debug({ request }, "Received request"); this.logger.debug({ request }, "Received request");
this.handleRequest(request).then((response) => { const response = await this.handleRequest(request);
this.sendResponse(response); this.sendResponse(response);
this.logger.debug({ response }, "Sent response"); this.logger.debug({ response }, "Sent response");
});
} }
} }
private async handleRequest(request: Request): Promise<Response> { private async handleRequest(request: StdIORequest): Promise<StdIOResponse> {
const response: Response = [0, null]; let requestId: number = 0;
const response: StdIOResponse = [0, null];
const abortController = new AbortController();
try { try {
if (!this.agent) { if (!this.agent) {
throw new Error(`Agent not bound.\n`); throw new Error(`Agent not bound.\n`);
} }
response[0] = request[0]; requestId = request[0];
response[0] = requestId;
let funcName = request[1].func; const funcName = request[1].func;
if (funcName === "cancelRequest") { if (funcName === "cancelRequest") {
response[1] = this.cancelRequest(request as CancellationRequest); response[1] = this.cancelRequest(request as CancellationRequest);
} else { } else {
let func = this.agent[funcName]; const func = this.agent[funcName];
if (!func) { if (!func) {
throw new Error(`Unknown function: ${funcName}`); throw new Error(`Unknown function: ${funcName}`);
} }
const result = func.apply(this.agent, request[1].args); const args = request[1].args;
if (typeof result === "object" && typeof result.then === "function") { // If the last argument is an object and has `signal` property, replace it with the abort signal.
this.ongoingRequests[request[0]] = result; if (args.length > 0 && typeof args[args.length - 1] === "object" && args[args.length - 1]["signal"]) {
response[1] = await result; this.abortControllers[requestId] = abortController;
delete this.ongoingRequests[request[0]]; args[args.length - 1]["signal"] = abortController.signal;
} else {
response[1] = result;
} }
response[1] = await func.apply(this.agent, args);
} }
} catch (error) { } catch (error) {
this.logger.error({ error, request }, `Failed to handle request`); this.logger.error({ error, request }, `Failed to handle request`);
} finally { } finally {
if (this.abortControllers[requestId]) {
delete this.abortControllers[requestId];
}
return response; return response;
} }
} }
private cancelRequest(request: CancellationRequest): boolean { private cancelRequest(request: CancellationRequest): boolean {
const ongoing = this.ongoingRequests[request[1].args[0]]; const targetId = request[1].args[0];
if (!ongoing) { const controller = this.abortControllers[targetId];
return false; if (controller) {
} controller.abort();
if (ongoing instanceof CancelablePromise) {
ongoing.cancel();
}
delete this.ongoingRequests[request[1].args[0]];
return true; return true;
} }
return false;
}
private sendResponse(response: Response): void { private sendResponse(response: StdIOResponse): void {
this.outStream.write(JSON.stringify(response) + "\n"); this.outStream.write(JSON.stringify(response) + "\n");
} }

View File

@ -3,14 +3,16 @@ import { v4 as uuid } from "uuid";
import deepEqual from "deep-equal"; import deepEqual from "deep-equal";
import { deepmerge } from "deepmerge-ts"; import { deepmerge } from "deepmerge-ts";
import { getProperty, setProperty, deleteProperty } from "dot-prop"; import { getProperty, setProperty, deleteProperty } from "dot-prop";
import { TabbyApi, CancelablePromise } from "./generated"; import createClient from "openapi-fetch";
import { cancelable, splitLines, isBlank } from "./utils"; import { paths as TabbyApi } from "./types/tabbyApi";
import { import { splitLines, isBlank, abortSignalFromAnyOf, HttpError, isTimeoutError, isCanceledError } from "./utils";
import type {
Agent, Agent,
AgentStatus, AgentStatus,
AgentIssue, AgentIssue,
AgentEvent, AgentEvent,
AgentInitOptions, AgentInitOptions,
AbortSignalOption,
ServerHealthState, ServerHealthState,
CompletionRequest, CompletionRequest,
CompletionResponse, CompletionResponse,
@ -43,14 +45,15 @@ export class TabbyAgent extends EventEmitter implements Agent {
private status: AgentStatus = "notInitialized"; private status: AgentStatus = "notInitialized";
private issues: AgentIssue["name"][] = []; private issues: AgentIssue["name"][] = [];
private serverHealthState: ServerHealthState | null = null; private serverHealthState: ServerHealthState | null = null;
private api: TabbyApi; private api: ReturnType<typeof createClient<TabbyApi>>;
private auth: Auth; private auth: Auth;
private dataStore: DataStore | null = null; private dataStore: DataStore | null = null;
private completionCache: CompletionCache = new CompletionCache(); private completionCache: CompletionCache = new CompletionCache();
private CompletionDebounce: CompletionDebounce = new CompletionDebounce(); private completionDebounce: CompletionDebounce = new CompletionDebounce();
private nonParallelProvideCompletionAbortController: AbortController | null = null;
private completionResponseStats: ResponseStats = new ResponseStats(completionResponseTimeStatsStrategy);
static readonly tryConnectInterval = 1000 * 30; // 30s static readonly tryConnectInterval = 1000 * 30; // 30s
private tryingConnectTimer: ReturnType<typeof setInterval> | null = null; private tryingConnectTimer: ReturnType<typeof setInterval> | null = null;
private completionResponseStats: ResponseStats = new ResponseStats(completionResponseTimeStatsStrategy);
private constructor() { private constructor() {
super(); super();
@ -97,16 +100,19 @@ export class TabbyAgent extends EventEmitter implements Agent {
this.auth.on("updated", this.setupApi.bind(this)); this.auth.on("updated", this.setupApi.bind(this));
} }
} else { } else {
// If `Authorization` request header is provided, use it directly.
this.auth = null; this.auth = null;
} }
await this.setupApi(); await this.setupApi();
} }
private async setupApi() { private async setupApi() {
this.api = new TabbyApi({ this.api = createClient<TabbyApi>({
BASE: this.config.server.endpoint.replace(/\/+$/, ""), // remove trailing slash baseUrl: this.config.server.endpoint.replace(/\/+$/, ""), // remove trailing slash
TOKEN: this.auth?.token, headers: {
HEADERS: this.config.server.requestHeaders, Authorization: this.auth?.token ? `Bearer ${this.auth.token}` : undefined,
...this.config.server.requestHeaders,
},
}); });
await this.healthCheck(); await this.healthCheck();
} }
@ -160,99 +166,52 @@ export class TabbyAgent extends EventEmitter implements Agent {
super.emit("authRequired", event); super.emit("authRequired", event);
} }
private callApi<Request, Response>( private async post<T extends Parameters<typeof this.api.POST>[0]>(
api: (request: Request) => CancelablePromise<Response>, path: T,
request: Request, requestOptions: Parameters<typeof this.api.POST<T>>[1],
options: { timeout?: number } = { timeout: this.config.server.requestTimeout }, abortOptions?: { signal?: AbortSignal; timeout?: number },
): CancelablePromise<Response> { ): Promise<Awaited<ReturnType<typeof this.api.POST<T>>>["data"]> {
return new CancelablePromise((resolve, reject, onCancel) => {
const requestId = uuid(); const requestId = uuid();
this.logger.debug({ requestId, api: api.name, request }, "API request"); this.logger.debug({ requestId, path, requestOptions, abortOptions }, "API request");
let timeout: ReturnType<typeof setTimeout> | null = null; try {
let timeoutCancelled = false; const timeout = Math.min(0x7fffffff, abortOptions?.timeout || this.config.server.requestTimeout);
const apiRequest = api.call(this.api.v1, request); const signal = abortSignalFromAnyOf([AbortSignal.timeout(timeout), abortOptions?.signal]);
const requestStartedAt = performance.now(); const response = await this.api.POST(path, { ...requestOptions, signal });
apiRequest if (response.error) {
.then((response: Response) => { throw new HttpError(response.response);
this.logger.debug({ requestId, api: api.name, response }, "API response"); }
this.logger.debug({ requestId, path, response: response.data }, "API response");
if (this.status !== "issuesExist") { if (this.status !== "issuesExist") {
this.changeStatus("ready"); this.changeStatus("ready");
} }
if (api.name === "completion") { return response.data;
this.completionResponseStats.push({ } catch (error) {
name: api.name, if (isTimeoutError(error)) {
status: 200, this.logger.debug({ requestId, path, error }, "API request timeout");
responseTime: performance.now() - requestStartedAt, } else if (isCanceledError(error)) {
}); this.logger.debug({ requestId, path, error }, "API request canceled");
}
if (timeout) {
clearTimeout(timeout);
}
resolve(response);
})
.catch((error) => {
if (
(!!error.isCancelled && timeoutCancelled) ||
(!error.isCancelled && error.code === "ECONNABORTED") ||
(error.name === "ApiError" && [408, 499].indexOf(error.status) !== -1)
) {
error.isTimeoutError = true;
this.logger.debug({ requestId, api: api.name, error }, "API request timeout");
} else if (!!error.isCancelled) {
this.logger.debug({ requestId, api: api.name, error }, "API request cancelled");
} else if ( } else if (
error.name === "ApiError" && error instanceof HttpError &&
[401, 403, 405].indexOf(error.status) !== -1 && [401, 403, 405].indexOf(error.status) !== -1 &&
new URL(this.config.server.endpoint).hostname.endsWith("app.tabbyml.com") && new URL(this.config.server.endpoint).hostname.endsWith("app.tabbyml.com") &&
this.config.server.requestHeaders["Authorization"] === undefined this.config.server.requestHeaders["Authorization"] === undefined
) { ) {
this.logger.debug({ requestId, api: api.name, error }, "API unauthorized"); this.logger.debug({ requestId, path, error }, "API unauthorized");
this.changeStatus("unauthorized"); this.changeStatus("unauthorized");
} else if (error.name === "ApiError") { } else if (error instanceof HttpError) {
this.logger.error({ requestId, api: api.name, error }, "API error"); this.logger.error({ requestId, path, error }, "API error");
this.changeStatus("disconnected"); this.changeStatus("disconnected");
} else { } else {
this.logger.error({ requestId, api: api.name, error }, "API request failed with unknown error"); this.logger.error({ requestId, path, error }, "API request failed with unknown error");
this.changeStatus("disconnected"); this.changeStatus("disconnected");
} }
// don't record cancelled request in stats throw error;
if (api.name === "completion" && (error.isTimeoutError || !error.isCancelled)) {
this.completionResponseStats.push({
name: api.name,
status: error.status,
responseTime: performance.now() - requestStartedAt,
error,
});
} }
if (timeout) {
clearTimeout(timeout);
}
reject(error);
});
// It seems that openapi-typescript-codegen does not provide timeout options passing to axios,
// Just use setTimeout to cancel the request manually.
if (options.timeout && options.timeout > 0) {
timeout = setTimeout(
() => {
this.logger.debug({ api: api.name, timeout: options.timeout }, "Cancel API request due to timeout");
timeoutCancelled = true;
apiRequest.cancel();
},
Math.min(options.timeout, 0x7fffffff),
);
}
onCancel(() => {
if (timeout) {
clearTimeout(timeout);
}
apiRequest.cancel();
});
});
} }
private healthCheck(): Promise<any> { private async healthCheck(options?: AbortSignalOption): Promise<any> {
return this.callApi(this.api.v1.health, {}) try {
.then((healthState) => { const healthState = await this.post("/v1/health", {}, options);
if ( if (
typeof healthState === "object" && typeof healthState === "object" &&
healthState["model"] !== undefined && healthState["model"] !== undefined &&
@ -263,8 +222,9 @@ export class TabbyAgent extends EventEmitter implements Agent {
this.anonymousUsageLogger.uniqueEvent("AgentConnected", healthState); this.anonymousUsageLogger.uniqueEvent("AgentConnected", healthState);
} }
} }
}) } catch (_) {
.catch(() => {}); // ignore
}
} }
private createSegments(request: CompletionRequest): { prefix: string; suffix: string } { private createSegments(request: CompletionRequest): { prefix: string; suffix: string } {
@ -352,109 +312,124 @@ export class TabbyAgent extends EventEmitter implements Agent {
return this.serverHealthState; return this.serverHealthState;
} }
public requestAuthUrl(): CancelablePromise<{ authUrl: string; code: string } | null> { public async requestAuthUrl(options?: AbortSignalOption): Promise<{ authUrl: string; code: string } | null> {
if (this.status === "notInitialized") { if (this.status === "notInitialized") {
return cancelable(Promise.reject("Agent is not initialized"), () => {}); throw new Error("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> {
if (this.status === "notInitialized") {
return cancelable(Promise.reject("Agent is not initialized"), () => {});
}
const polling = this.auth.pollingToken(code);
return cancelable(
polling.then(() => {
return this.setupApi();
}),
() => {
polling.cancel();
},
);
}
public provideCompletions(request: CompletionRequest): CancelablePromise<CompletionResponse> {
if (this.status === "notInitialized") {
return cancelable(Promise.reject("Agent is not initialized"), () => {});
}
const cancelableList: CancelablePromise<any>[] = [];
return cancelable(
Promise.resolve(null)
// From cache
.then(async (response: CompletionResponse | null) => {
if (response) return response;
if (this.completionCache.has(request)) {
this.logger.debug({ request }, "Completion cache hit");
const debounce = this.CompletionDebounce.debounce(request, this.config.completion.debounce, 0);
cancelableList.push(debounce);
await debounce;
return this.completionCache.get(request);
} }
await this.healthCheck(options);
if (this.status !== "unauthorized") {
return null; return null;
}) } else {
// From api return await this.auth.requestAuthUrl(options);
.then(async (response: CompletionResponse | null) => { }
if (response) return response; }
public async waitForAuthToken(code: string, options?: AbortSignalOption): Promise<void> {
if (this.status === "notInitialized") {
throw new Error("Agent is not initialized");
}
await this.auth.pollingToken(code, options);
await this.setupApi();
}
public async provideCompletions(
request: CompletionRequest,
options?: AbortSignalOption,
): Promise<CompletionResponse> {
if (this.status === "notInitialized") {
throw new Error("Agent is not initialized");
}
if (this.nonParallelProvideCompletionAbortController) {
this.nonParallelProvideCompletionAbortController.abort();
}
this.nonParallelProvideCompletionAbortController = new AbortController();
const signal = abortSignalFromAnyOf([this.nonParallelProvideCompletionAbortController.signal, options?.signal]);
let completionResponse: CompletionResponse | null = null;
if (this.completionCache.has(request)) {
// Hit cache
this.logger.debug({ request }, "Completion cache hit");
await this.completionDebounce.debounce(
{
request,
config: this.config.completion.debounce,
responseTime: 0,
},
{ signal },
);
completionResponse = this.completionCache.get(request);
} else {
// No cache
const segments = this.createSegments(request); const segments = this.createSegments(request);
if (isBlank(segments.prefix)) { if (isBlank(segments.prefix)) {
// Empty prompt
this.logger.debug("Segment prefix is blank, returning empty completion response"); this.logger.debug("Segment prefix is blank, returning empty completion response");
return { completionResponse = {
id: "agent-" + uuid(), id: "agent-" + uuid(),
choices: [], choices: [],
}; };
} } else {
const debounce = this.CompletionDebounce.debounce( // Request server
request, await this.completionDebounce.debounce(
this.config.completion.debounce,
this.completionResponseStats.stats()["averageResponseTime"],
);
cancelableList.push(debounce);
await debounce;
const apiRequest = this.callApi(
this.api.v1.completion,
{ {
request,
config: this.config.completion.debounce,
responseTime: this.completionResponseStats.stats()["averageResponseTime"],
},
options,
);
const requestStartedAt = performance.now();
const apiPath = "/v1/completions";
try {
completionResponse = await this.post(
apiPath,
{
body: {
language: request.language, language: request.language,
segments, segments,
user: this.auth?.user, user: this.auth?.user,
}, },
},
{ {
signal,
timeout: request.manually ? this.config.completion.timeout.manually : this.config.completion.timeout.auto, timeout: request.manually ? this.config.completion.timeout.manually : this.config.completion.timeout.auto,
}, },
); );
cancelableList.push(apiRequest); this.completionResponseStats.push({
let res = await apiRequest; name: apiPath,
res = await preCacheProcess(request, res); status: 200,
this.completionCache.set(request, res); responseTime: performance.now() - requestStartedAt,
return res; });
}) } catch (error) {
// Postprocess // record timed out request in stats, do not record canceled request
.then(async (response: CompletionResponse | null) => { if (isTimeoutError(error)) {
return postprocess(request, response); this.completionResponseStats.push({
}), name: apiPath,
() => { status: error.status,
cancelableList.forEach((cancelable) => cancelable.cancel()); responseTime: performance.now() - requestStartedAt,
}, error,
); });
}
}
completionResponse = await preCacheProcess(request, completionResponse);
if (options?.signal?.aborted) {
throw options.signal.reason;
}
this.completionCache.set(request, completionResponse);
}
}
completionResponse = await postprocess(request, completionResponse);
if (options?.signal?.aborted) {
throw options.signal.reason;
}
return completionResponse;
} }
public postEvent(request: LogEventRequest): CancelablePromise<boolean> { public async postEvent(request: LogEventRequest, options?: AbortSignalOption): Promise<boolean> {
if (this.status === "notInitialized") { if (this.status === "notInitialized") {
return cancelable(Promise.reject("Agent is not initialized"), () => {}); throw new Error("Agent is not initialized");
} }
return this.callApi(this.api.v1.event, request); await this.post("/v1/events", { body: request, parseAs: "text" }, options);
return true;
} }
} }

View File

@ -1,27 +0,0 @@
import type { BaseHttpRequest, OpenAPIConfig } from "../generated";
import { AxiosHttpRequest } from "../generated/core/AxiosHttpRequest";
import { ApiService } from "./services/ApiService";
type HttpRequestConstructor = new (config: OpenAPIConfig) => BaseHttpRequest;
export class CloudApi {
public readonly api: ApiService;
public readonly request: BaseHttpRequest;
constructor(config?: Partial<OpenAPIConfig>, HttpRequest: HttpRequestConstructor = AxiosHttpRequest) {
this.request = new HttpRequest({
BASE: config?.BASE ?? "https://app.tabbyml.com/api",
VERSION: config?.VERSION ?? "0.0.0",
WITH_CREDENTIALS: config?.WITH_CREDENTIALS ?? false,
CREDENTIALS: config?.CREDENTIALS ?? "include",
TOKEN: config?.TOKEN,
USERNAME: config?.USERNAME,
PASSWORD: config?.PASSWORD,
HEADERS: config?.HEADERS,
ENCODE_PATH: config?.ENCODE_PATH,
});
this.api = new ApiService(this.request);
}
}

View File

@ -1,4 +0,0 @@
export { CloudApi } from "./CloudApi";
export { ApiService } from "./services/ApiService";
export { DeviceTokenResponse } from "./models/DeviceTokenResponse";
export { DeviceTokenAcceptResponse } from "./models/DeviceTokenAcceptResponse";

View File

@ -1,5 +0,0 @@
export type DeviceTokenAcceptResponse = {
data: {
jwt: string;
};
};

View File

@ -1,5 +0,0 @@
export type DeviceTokenRefreshResponse = {
data: {
jwt: string;
};
};

View File

@ -1,3 +0,0 @@
export type DeviceTokenRequest = {
auth_url: string;
};

View File

@ -1,5 +0,0 @@
export type DeviceTokenResponse = {
data: {
code: string;
};
};

View File

@ -1,60 +0,0 @@
import type { CancelablePromise } from "../../generated/core/CancelablePromise";
import type { BaseHttpRequest } from "../../generated/core/BaseHttpRequest";
import type { DeviceTokenRequest } from "../models/DeviceTokenRequest";
import type { DeviceTokenResponse } from "../models/DeviceTokenResponse";
import type { DeviceTokenAcceptResponse } from "../models/DeviceTokenAcceptResponse";
import type { DeviceTokenRefreshResponse } from "../models/DeviceTokenRefreshResponse";
export class ApiService {
constructor(public readonly httpRequest: BaseHttpRequest) {}
/**
* @returns DeviceTokenResponse Success
* @throws ApiError
*/
public deviceToken(body: DeviceTokenRequest): CancelablePromise<DeviceTokenResponse> {
return this.httpRequest.request({
method: "POST",
url: "/device-token",
body,
});
}
/**
* @param code
* @returns DeviceTokenAcceptResponse Success
* @throws ApiError
*/
public deviceTokenAccept(query: { code: string }): CancelablePromise<DeviceTokenAcceptResponse> {
return this.httpRequest.request({
method: "POST",
url: "/device-token/accept",
query,
});
}
/**
* @param token
* @returns DeviceTokenRefreshResponse Success
* @throws ApiError
*/
public deviceTokenRefresh(token: string): CancelablePromise<DeviceTokenRefreshResponse> {
return this.httpRequest.request({
method: "POST",
url: "/device-token/refresh",
headers: { Authorization: `Bearer ${token}` },
});
}
/**
* @param body object for anonymous usage tracking
*/
public usage(body: any): CancelablePromise<any> {
return this.httpRequest.request({
method: "POST",
url: "/usage",
body,
});
}
}

View File

@ -28,10 +28,7 @@ export const dataStore: DataStore = isBrowser
migrateFrom_0_3_0: async function () { migrateFrom_0_3_0: async function () {
const dataFile_0_3_0 = require("path").join(require("os").homedir(), ".tabby", "agent", "data.json"); const dataFile_0_3_0 = require("path").join(require("os").homedir(), ".tabby", "agent", "data.json");
const migratedFlag = require("path").join(require("os").homedir(), ".tabby", "agent", ".data_json_migrated"); const migratedFlag = require("path").join(require("os").homedir(), ".tabby", "agent", ".data_json_migrated");
if ( if ((await fs.pathExists(dataFile_0_3_0)) && !(await fs.pathExists(migratedFlag))) {
(await fs.pathExists(dataFile_0_3_0)) &&
!(await fs.pathExists(migratedFlag))
) {
const data = await fs.readJson(dataFile_0_3_0); const data = await fs.readJson(dataFile_0_3_0);
await fs.outputJson(dataFile, data); await fs.outputJson(dataFile, data);
await fs.outputFile(migratedFlag, ""); await fs.outputFile(migratedFlag, "");

View File

@ -4,18 +4,21 @@ export {
AgentStatus, AgentStatus,
AgentFunction, AgentFunction,
AgentEvent, AgentEvent,
AgentEventEmitter,
AgentIssue,
StatusChangedEvent, StatusChangedEvent,
ConfigUpdatedEvent, ConfigUpdatedEvent,
AuthRequiredEvent, AuthRequiredEvent,
NewIssueEvent, NewIssueEvent,
AgentIssue,
SlowCompletionResponseTimeIssue, SlowCompletionResponseTimeIssue,
HighCompletionTimeoutRateIssue, HighCompletionTimeoutRateIssue,
AgentInitOptions,
ServerHealthState,
CompletionRequest, CompletionRequest,
CompletionResponse, CompletionResponse,
LogEventRequest, LogEventRequest,
AbortSignalOption,
agentEventNames, agentEventNames,
} from "./Agent"; } from "./Agent";
export { AgentConfig, PartialAgentConfig } from "./AgentConfig"; export { AgentConfig, PartialAgentConfig } from "./AgentConfig";
export { DataStore } from "./dataStore"; export { DataStore } from "./dataStore";
export { CancelablePromise } from "./generated";

View File

@ -0,0 +1,101 @@
export interface paths {
"/device-token": {
post: operations["deviceToken"];
};
"/device-token/accept": {
post: operations["deviceTokenAccept"];
};
"/device-token/refresh": {
post: operations["deviceTokenRefresh"];
};
"/usage": {
post: operations["usage"];
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
DeviceTokenRequest: {
auth_url: string;
};
DeviceTokenResponse: {
data: {
code: string;
};
};
DeviceTokenAcceptResponse: {
data: {
jwt: string;
};
};
DeviceTokenRefreshResponse: {
data: {
jwt: string;
};
};
UsageRequest: {};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export type external = Record<string, never>;
export interface operations {
deviceToken: {
requestBody: {
content: {
"application/json": components["schemas"]["DeviceTokenRequest"];
};
};
responses: {
200: {
content: {
"application/json": components["schemas"]["DeviceTokenResponse"];
};
};
};
};
deviceTokenAccept: {
parameters: {
query: {
code: string;
};
};
responses: {
200: {
content: {
"application/json": components["schemas"]["DeviceTokenAcceptResponse"];
};
};
};
};
deviceTokenRefresh: {
responses: {
200: {
content: {
"application/json": components["schemas"]["DeviceTokenRefreshResponse"];
};
};
};
};
usage: {
requestBody: {
content: {
"application/json": components["schemas"]["UsageRequest"];
};
};
responses: {
200: {
content: never;
};
};
};
}

View File

@ -0,0 +1,143 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/v1/completions": {
post: operations["completion"];
};
"/v1/events": {
post: operations["event"];
};
"/v1/health": {
post: operations["health"];
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
Choice: {
/** Format: int32 */
index: number;
text: string;
};
/**
* @example {
* "language": "python",
* "segments": {
* "prefix": "def fib(n):\n ",
* "suffix": "\n return fib(n - 1) + fib(n - 2)"
* }
* }
*/
CompletionRequest: {
/** @example def fib(n): */
prompt?: string | null;
/**
* @description Language identifier, full list is maintained at
* https://code.visualstudio.com/docs/languages/identifiers
* @example python
*/
language?: string | null;
segments?: components["schemas"]["Segments"] | null;
user?: string | null;
};
CompletionResponse: {
id: string;
choices: components["schemas"]["Choice"][];
};
HealthState: {
model: string;
device: string;
compute_type: string;
arch: string;
cpu_info: string;
cpu_count: number;
cuda_devices: string[];
version: components["schemas"]["Version"];
};
LogEventRequest: {
/**
* @description Event type, should be `view` or `select`.
* @example view
*/
type: string;
completion_id: string;
/** Format: int32 */
choice_index: number;
};
Segments: {
/** @description Content that appears before the cursor in the editor window. */
prefix: string;
/** @description Content that appears after the cursor in the editor window. */
suffix?: string | null;
};
Version: {
build_date: string;
build_timestamp: string;
git_sha: string;
git_describe: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export type external = Record<string, never>;
export interface operations {
completion: {
requestBody: {
content: {
"application/json": components["schemas"]["CompletionRequest"];
};
};
responses: {
/** @description Success */
200: {
content: {
"application/json": components["schemas"]["CompletionResponse"];
};
};
/** @description Bad Request */
400: {
content: never;
};
};
};
event: {
requestBody: {
content: {
"application/json": components["schemas"]["LogEventRequest"];
};
};
responses: {
/** @description Success */
200: {
content: never;
};
/** @description Bad Request */
400: {
content: never;
};
};
};
health: {
responses: {
/** @description Success */
200: {
content: {
"application/json": components["schemas"]["HealthState"];
};
};
};
};
}

View File

@ -18,18 +18,43 @@ export function calcDistance(a: string, b: string) {
return levenshtein.get(a, b); return levenshtein.get(a, b);
} }
import { CancelablePromise } from "./generated"; // Polyfill for AbortSignal.any(signals) which added in Node.js v20.
export function cancelable<T>(promise: Promise<T>, cancel: () => void): CancelablePromise<T> { export function abortSignalFromAnyOf(signals: AbortSignal[]) {
return new CancelablePromise((resolve, reject, onCancel) => { const controller = new AbortController();
promise for (const signal of signals) {
.then((resp: T) => { if (signal?.aborted) {
resolve(resp); controller.abort(signal.reason);
}) return signal;
.catch((err: Error) => { }
reject(err); signal?.addEventListener("abort", () => controller.abort(signal.reason), {
}); signal: controller.signal,
onCancel(() => {
cancel();
});
}); });
}
return controller.signal;
}
// Http Error
export class HttpError extends Error {
status: number;
statusText: string;
response: Response;
constructor(response: Response) {
super(`${response.status} ${response.statusText}`);
this.name = "HttpError";
this.status = response.status;
this.statusText = response.statusText;
this.response = response;
}
}
export function isTimeoutError(error: any) {
return (
(error instanceof Error && error.name === "TimeoutError") ||
(error instanceof HttpError && [408, 499].indexOf(error.status) !== -1)
);
}
export function isCanceledError(error: any) {
return error instanceof Error && error.name === "AbortError";
} }

View File

@ -5,7 +5,8 @@
"lib": ["ES2020", "dom"], "lib": ["ES2020", "dom"],
"sourceMap": true, "sourceMap": true,
"esModuleInterop": true, "esModuleInterop": true,
"resolveJsonModule": true "resolveJsonModule": true,
"noUncheckedIndexedAccess": true
}, },
"include": ["./src"] "include": ["./src"]
} }

View File

@ -10,6 +10,6 @@
"devDependencies": { "devDependencies": {
"cpy-cli": "^4.2.0", "cpy-cli": "^4.2.0",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"tabby-agent": "0.1.1" "tabby-agent": "0.2.0-dev"
} }
} }

View File

@ -7,7 +7,7 @@
"repository": "https://github.com/TabbyML/tabby", "repository": "https://github.com/TabbyML/tabby",
"bugs": "https://github.com/TabbyML/tabby/issues", "bugs": "https://github.com/TabbyML/tabby/issues",
"license": "Apache-2.0", "license": "Apache-2.0",
"version": "0.4.1", "version": "0.5.0-dev",
"keywords": [ "keywords": [
"ai", "ai",
"autocomplete", "autocomplete",
@ -20,7 +20,7 @@
], ],
"icon": "assets/logo.png", "icon": "assets/logo.png",
"engines": { "engines": {
"vscode": "^1.70.0" "vscode": "^1.82.0"
}, },
"categories": [ "categories": [
"Programming Languages", "Programming Languages",
@ -197,6 +197,6 @@
}, },
"dependencies": { "dependencies": {
"@xstate/fsm": "^2.0.1", "@xstate/fsm": "^2.0.1",
"tabby-agent": "0.1.1" "tabby-agent": "0.2.0-dev"
} }
} }

View File

@ -3,21 +3,17 @@ import {
InlineCompletionContext, InlineCompletionContext,
InlineCompletionItem, InlineCompletionItem,
InlineCompletionItemProvider, InlineCompletionItemProvider,
InlineCompletionList,
InlineCompletionTriggerKind, InlineCompletionTriggerKind,
Position, Position,
ProviderResult,
Range, Range,
TextDocument, TextDocument,
workspace, workspace,
} from "vscode"; } from "vscode";
import { CompletionResponse, CancelablePromise } from "tabby-agent"; import { CompletionResponse } from "tabby-agent";
import { agent } from "./agent"; import { agent } from "./agent";
import { notifications } from "./notifications"; import { notifications } from "./notifications";
export class TabbyCompletionProvider implements InlineCompletionItemProvider { export class TabbyCompletionProvider implements InlineCompletionItemProvider {
private pendingCompletion: CancelablePromise<CompletionResponse> | null = null;
// User Settings // User Settings
private enabled: boolean = true; private enabled: boolean = true;
@ -30,9 +26,12 @@ export class TabbyCompletionProvider implements InlineCompletionItemProvider {
}); });
} }
//@ts-ignore because ASYNC and PROMISE public async provideInlineCompletionItems(
//prettier-ignore document: TextDocument,
public async provideInlineCompletionItems(document: TextDocument, position: Position, context: InlineCompletionContext, token: CancellationToken): ProviderResult<InlineCompletionItem[] | InlineCompletionList> { position: Position,
context: InlineCompletionContext,
token: CancellationToken,
): Promise<InlineCompletionItem[]> {
const emptyResponse = Promise.resolve([] as InlineCompletionItem[]); const emptyResponse = Promise.resolve([] as InlineCompletionItem[]);
if (!this.enabled) { if (!this.enabled) {
console.debug("Extension not enabled, skipping."); console.debug("Extension not enabled, skipping.");
@ -45,12 +44,13 @@ export class TabbyCompletionProvider implements InlineCompletionItemProvider {
return emptyResponse; return emptyResponse;
} }
const replaceRange = this.calculateReplaceRange(document, position); if (token?.isCancellationRequested) {
console.debug("Cancellation was requested.");
if (this.pendingCompletion) { return emptyResponse;
this.pendingCompletion.cancel();
} }
const replaceRange = this.calculateReplaceRange(document, position);
const request = { const request = {
filepath: document.uri.fsPath, filepath: document.uri.fsPath,
language: document.languageId, // https://code.visualstudio.com/docs/languages/identifiers language: document.languageId, // https://code.visualstudio.com/docs/languages/identifiers
@ -58,12 +58,18 @@ export class TabbyCompletionProvider implements InlineCompletionItemProvider {
position: document.offsetAt(position), position: document.offsetAt(position),
manually: context.triggerKind === InlineCompletionTriggerKind.Invoke, manually: context.triggerKind === InlineCompletionTriggerKind.Invoke,
}; };
this.pendingCompletion = agent().provideCompletions(request);
const completion = await this.pendingCompletion.catch((e: Error) => { const abortController = new AbortController();
token?.onCancellationRequested(() => {
console.debug("Cancellation requested.");
abortController.abort();
});
const completion = await agent()
.provideCompletions(request, { signal: abortController.signal })
.catch((_) => {
return null; return null;
}); });
this.pendingCompletion = null;
const completions = this.toInlineCompletions(completion, replaceRange); const completions = this.toInlineCompletions(completion, replaceRange);
return Promise.resolve(completions); return Promise.resolve(completions);

View File

@ -9,7 +9,6 @@ import {
commands, commands,
} from "vscode"; } from "vscode";
import { strict as assert } from "assert"; import { strict as assert } from "assert";
import { CancelablePromise } from "tabby-agent";
import { agent } from "./agent"; import { agent } from "./agent";
import { notifications } from "./notifications"; import { notifications } from "./notifications";
@ -121,22 +120,19 @@ const openAuthPage: Command = {
cancellable: true, cancellable: true,
}, },
async (progress, token) => { async (progress, token) => {
let requestAuthUrl: CancelablePromise<{ authUrl: string; code: string } | null>; const abortController = new AbortController();
let waitForAuthToken: CancelablePromise<any>;
token.onCancellationRequested(() => { token.onCancellationRequested(() => {
requestAuthUrl?.cancel(); abortController.abort();
waitForAuthToken?.cancel();
}); });
const signal = abortController.signal;
try { try {
callbacks?.onAuthStart?.(); callbacks?.onAuthStart?.();
progress.report({ message: "Generating authorization url..." }); progress.report({ message: "Generating authorization url..." });
requestAuthUrl = agent().requestAuthUrl(); let authUrl = await agent().requestAuthUrl({ signal });
let authUrl = await requestAuthUrl;
if (authUrl) { if (authUrl) {
env.openExternal(Uri.parse(authUrl.authUrl)); env.openExternal(Uri.parse(authUrl.authUrl));
progress.report({ message: "Waiting for authorization from browser..." }); progress.report({ message: "Waiting for authorization from browser..." });
waitForAuthToken = agent().waitForAuthToken(authUrl.code); await agent().waitForAuthToken(authUrl.code, { signal });
await waitForAuthToken;
assert(agent().getStatus() === "ready"); assert(agent().getStatus() === "ready");
notifications.showInformationAuthSuccess(); notifications.showInformationAuthSuccess();
} else if (agent().getStatus() === "ready") { } else if (agent().getStatus() === "ready") {
@ -145,7 +141,7 @@ const openAuthPage: Command = {
notifications.showInformationWhenAuthFailed(); notifications.showInformationWhenAuthFailed();
} }
} catch (error: any) { } catch (error: any) {
if (error.isCancelled) { if (error.name === "AbortError") {
return; return;
} }
console.debug("Error auth", { error }); console.debug("Error auth", { error });

View File

@ -5,5 +5,8 @@
"clients/vscode", "clients/vscode",
"clients/vim", "clients/vim",
"clients/intellij" "clients/intellij"
] ],
"engines": {
"node": ">=18"
}
} }

996
yarn.lock

File diff suppressed because it is too large Load Diff