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": {
"cpy-cli": "^4.2.0",
"rimraf": "^5.0.1",
"tabby-agent": "0.1.1"
"tabby-agent": "0.2.0-dev"
}
}

View File

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

View File

@ -1,10 +1,4 @@
import {
CancelablePromise,
LogEventRequest as ApiLogEventRequest,
CompletionResponse as ApiCompletionResponse,
HealthState,
} from "./generated";
import type { components as ApiComponents } from "./types/tabbyApi";
import { AgentConfig, PartialAgentConfig } from "./AgentConfig";
export type AgentInitOptions = Partial<{
@ -13,7 +7,7 @@ export type AgentInitOptions = Partial<{
clientProperties: Record<string, any>;
}>;
export type ServerHealthState = HealthState;
export type ServerHealthState = ApiComponents["schemas"]["HealthState"];
export type CompletionRequest = {
filepath: string;
@ -23,9 +17,11 @@ export type CompletionRequest = {
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 = {
name: "slowCompletionResponseTime";
@ -58,7 +54,7 @@ export interface AgentFunction {
* Initialize agent. Client should call this method before calling any other methods.
* @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:
@ -104,7 +100,7 @@ export interface AgentFunction {
* @returns the auth url for redirecting, and the code for next step `waitingForAuth`
* @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,
@ -112,7 +108,7 @@ export interface AgentFunction {
* @param code from `requestAuthUrl`
* @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
@ -122,14 +118,14 @@ export interface AgentFunction {
* @returns
* @throws Error if agent is not initialized
*/
provideCompletions(request: CompletionRequest): CancelablePromise<CompletionResponse>;
provideCompletions(request: CompletionRequest, options?: AbortSignalOption): Promise<CompletionResponse>;
/**
* @param event
* @returns
* @throws Error if agent is not initialized
*/
postEvent(event: LogEventRequest): CancelablePromise<boolean>;
postEvent(event: LogEventRequest, options?: AbortSignalOption): Promise<boolean>;
}
export type StatusChangedEvent = {

View File

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

View File

@ -1,12 +1,13 @@
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 { isBrowser } from "./env";
import { rootLogger } from "./logger";
import { dataStore, DataStore } from "./dataStore";
export class AnonymousUsageLogger {
private anonymousUsageTrackingApi = new CloudApi();
private anonymousUsageTrackingApi = createClient<CloudApi>({ baseUrl: "https://app.tabbyml.com/api" });
private logger = rootLogger.child({ component: "AnonymousUsage" });
private systemData = {
agent: `${agentName}, ${agentVersion}`,
@ -73,18 +74,20 @@ export class AnonymousUsageLogger {
if (unique) {
this.emittedUniqueEvent.push(event);
}
await this.anonymousUsageTrackingApi.api
.usage({
distinctId: this.anonymousId,
event,
properties: {
...this.systemData,
...this.properties,
...data,
try {
await this.anonymousUsageTrackingApi.POST("/usage", {
body: {
distinctId: this.anonymousId,
event,
properties: {
...this.systemData,
...this.properties,
...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 decodeJwt from "jwt-decode";
import { CloudApi, DeviceTokenResponse, DeviceTokenAcceptResponse } from "./cloud";
import { ApiError, CancelablePromise } from "./generated";
import createClient from "openapi-fetch";
import type { paths as CloudApi } from "./types/cloudApi";
import type { AbortSignalOption } from "./Agent";
import { HttpError, abortSignalFromAnyOf } from "./utils";
import { dataStore, DataStore } from "./dataStore";
import { rootLogger } from "./logger";
@ -40,7 +42,7 @@ export class Auth extends EventEmitter {
readonly endpoint: string;
readonly dataStore: DataStore | 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;
static async create(options: { endpoint: string; dataStore?: DataStore }): Promise<Auth> {
@ -53,7 +55,7 @@ export class Auth extends EventEmitter {
super();
this.endpoint = options.endpoint;
this.dataStore = options.dataStore || dataStore;
this.authApi = new CloudApi();
this.authApi = createClient<CloudApi>({ baseUrl: "https://app.tabbyml.com/api" });
this.scheduleRefreshToken();
}
@ -113,47 +115,52 @@ export class Auth extends EventEmitter {
}
}
requestAuthUrl(): CancelablePromise<{ authUrl: string; code: string }> {
return new CancelablePromise(async (resolve, reject, onCancel) => {
let apiRequest: CancelablePromise<DeviceTokenResponse>;
onCancel(() => {
apiRequest?.cancel();
});
try {
await this.reset();
if (onCancel.isCancelled) return;
this.logger.debug("Start to request device token");
apiRequest = this.authApi.api.deviceToken({ auth_url: this.endpoint });
const deviceToken = await apiRequest;
this.logger.debug({ deviceToken }, "Request device token response");
const authUrl = new URL(Auth.authPageUrl);
authUrl.searchParams.append("code", deviceToken.data.code);
resolve({ authUrl: authUrl.toString(), code: deviceToken.data.code });
} catch (error) {
this.logger.error({ error }, "Error when requesting token");
reject(error);
async requestAuthUrl(options?: AbortSignalOption): Promise<{ authUrl: string; code: string }> {
try {
await this.reset();
if (options?.signal.aborted) {
throw options.signal.reason;
}
});
this.logger.debug("Start to request device token");
const response = await this.authApi.POST("/device-token", {
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");
const authUrl = new URL(Auth.authPageUrl);
authUrl.searchParams.append("code", deviceToken.data.code);
return { authUrl: authUrl.toString(), code: deviceToken.data.code };
} catch (error) {
this.logger.error({ error }, "Error when requesting token");
throw error;
}
}
pollingToken(code: string): CancelablePromise<boolean> {
return new CancelablePromise((resolve, reject, onCancel) => {
let apiRequest: CancelablePromise<DeviceTokenAcceptResponse>;
async pollingToken(code: string, options?: AbortSignalOption): Promise<boolean> {
return new Promise((resolve, reject) => {
const signal = abortSignalFromAnyOf([AbortSignal.timeout(Auth.tokenStrategy.polling.timeout), options?.signal]);
const timer = setInterval(async () => {
try {
apiRequest = this.authApi.api.deviceTokenAccept({ code });
const response = await apiRequest;
this.logger.debug({ response }, "Poll jwt response");
const response = await this.authApi.POST("/device-token/accept", { params: { query: { code } }, signal });
if (response.error) {
throw new HttpError(response.response);
}
const result = response.data;
this.logger.debug({ result }, "Poll jwt response");
this.jwt = {
token: response.data.jwt,
payload: decodeJwt(response.data.jwt),
token: result.data.jwt,
payload: decodeJwt(result.data.jwt),
};
super.emit("updated", this.jwt);
await this.save();
clearInterval(timer);
resolve(true);
} 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");
} else {
// unknown error but still keep polling
@ -161,28 +168,35 @@ export class Auth extends EventEmitter {
}
}
}, Auth.tokenStrategy.polling.interval);
setTimeout(() => {
if (signal.aborted) {
clearInterval(timer);
reject(new Error("Timeout when polling token"));
}, Auth.tokenStrategy.polling.timeout);
onCancel(() => {
apiRequest?.cancel();
clearInterval(timer);
});
reject(signal.reason);
} else {
signal.addEventListener("abort", () => {
clearInterval(timer);
reject(signal.reason);
});
}
});
}
private async refreshToken(jwt: JWT, options = { maxTry: 1, retryDelay: 1000 }, retry = 0): Promise<JWT> {
try {
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");
return {
token: refreshedJwt.data.jwt,
payload: decodeJwt(refreshedJwt.data.jwt),
};
} 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");
} else {
// unknown error, retry a few times

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import { CancelablePromise } from "./generated";
import { AgentFunction, AgentEvent, Agent, agentEventNames } from "./Agent";
import { rootLogger } from "./logger";
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> = [
id: number, // Matched request id
data: ReturnType<AgentFunction[T]>,
];
type AgentEventNotification = {
id: 0;
data: AgentEvent;
};
type AgentEventNotification = [
id: 0, // Always 0
data: AgentEvent,
];
type CancellationResponse = [
id: number, // Matched request id
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.
@ -47,13 +46,13 @@ export class StdIO {
private readonly logger = rootLogger.child({ component: "StdIO" });
private buffer: string = "";
private ongoingRequests: { [id: number]: PromiseLike<any> } = {};
private abortControllers: { [id: string]: AbortController } = {};
private agent: Agent | null = null;
constructor() {}
private handleInput(data: Buffer): void {
private async handleInput(data: Buffer) {
const input = data.toString();
this.buffer += input;
const lines = splitLines(this.buffer);
@ -66,66 +65,68 @@ export class StdIO {
this.buffer = lines.pop()!;
}
for (const line of lines) {
let request: Request | null = null;
let request: StdIORequest | null = null;
try {
request = JSON.parse(line) as Request;
request = JSON.parse(line) as StdIORequest;
} catch (error) {
this.logger.error({ error }, `Failed to parse request: ${line}`);
continue;
}
this.logger.debug({ request }, "Received request");
this.handleRequest(request).then((response) => {
this.sendResponse(response);
this.logger.debug({ response }, "Sent response");
});
const response = await this.handleRequest(request);
this.sendResponse(response);
this.logger.debug({ response }, "Sent response");
}
}
private async handleRequest(request: Request): Promise<Response> {
const response: Response = [0, null];
private async handleRequest(request: StdIORequest): Promise<StdIOResponse> {
let requestId: number = 0;
const response: StdIOResponse = [0, null];
const abortController = new AbortController();
try {
if (!this.agent) {
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") {
response[1] = this.cancelRequest(request as CancellationRequest);
} else {
let func = this.agent[funcName];
const func = this.agent[funcName];
if (!func) {
throw new Error(`Unknown function: ${funcName}`);
}
const result = func.apply(this.agent, request[1].args);
if (typeof result === "object" && typeof result.then === "function") {
this.ongoingRequests[request[0]] = result;
response[1] = await result;
delete this.ongoingRequests[request[0]];
} else {
response[1] = result;
const args = request[1].args;
// If the last argument is an object and has `signal` property, replace it with the abort signal.
if (args.length > 0 && typeof args[args.length - 1] === "object" && args[args.length - 1]["signal"]) {
this.abortControllers[requestId] = abortController;
args[args.length - 1]["signal"] = abortController.signal;
}
response[1] = await func.apply(this.agent, args);
}
} catch (error) {
this.logger.error({ error, request }, `Failed to handle request`);
} finally {
if (this.abortControllers[requestId]) {
delete this.abortControllers[requestId];
}
return response;
}
}
private cancelRequest(request: CancellationRequest): boolean {
const ongoing = this.ongoingRequests[request[1].args[0]];
if (!ongoing) {
return false;
const targetId = request[1].args[0];
const controller = this.abortControllers[targetId];
if (controller) {
controller.abort();
return true;
}
if (ongoing instanceof CancelablePromise) {
ongoing.cancel();
}
delete this.ongoingRequests[request[1].args[0]];
return true;
return false;
}
private sendResponse(response: Response): void {
private sendResponse(response: StdIOResponse): void {
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 { deepmerge } from "deepmerge-ts";
import { getProperty, setProperty, deleteProperty } from "dot-prop";
import { TabbyApi, CancelablePromise } from "./generated";
import { cancelable, splitLines, isBlank } from "./utils";
import {
import createClient from "openapi-fetch";
import { paths as TabbyApi } from "./types/tabbyApi";
import { splitLines, isBlank, abortSignalFromAnyOf, HttpError, isTimeoutError, isCanceledError } from "./utils";
import type {
Agent,
AgentStatus,
AgentIssue,
AgentEvent,
AgentInitOptions,
AbortSignalOption,
ServerHealthState,
CompletionRequest,
CompletionResponse,
@ -43,14 +45,15 @@ export class TabbyAgent extends EventEmitter implements Agent {
private status: AgentStatus = "notInitialized";
private issues: AgentIssue["name"][] = [];
private serverHealthState: ServerHealthState | null = null;
private api: TabbyApi;
private api: ReturnType<typeof createClient<TabbyApi>>;
private auth: Auth;
private dataStore: DataStore | null = null;
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
private tryingConnectTimer: ReturnType<typeof setInterval> | null = null;
private completionResponseStats: ResponseStats = new ResponseStats(completionResponseTimeStatsStrategy);
private constructor() {
super();
@ -97,16 +100,19 @@ export class TabbyAgent extends EventEmitter implements Agent {
this.auth.on("updated", this.setupApi.bind(this));
}
} else {
// If `Authorization` request header is provided, use it directly.
this.auth = null;
}
await this.setupApi();
}
private async setupApi() {
this.api = new TabbyApi({
BASE: this.config.server.endpoint.replace(/\/+$/, ""), // remove trailing slash
TOKEN: this.auth?.token,
HEADERS: this.config.server.requestHeaders,
this.api = createClient<TabbyApi>({
baseUrl: this.config.server.endpoint.replace(/\/+$/, ""), // remove trailing slash
headers: {
Authorization: this.auth?.token ? `Bearer ${this.auth.token}` : undefined,
...this.config.server.requestHeaders,
},
});
await this.healthCheck();
}
@ -160,111 +166,65 @@ export class TabbyAgent extends EventEmitter implements Agent {
super.emit("authRequired", event);
}
private callApi<Request, Response>(
api: (request: Request) => CancelablePromise<Response>,
request: Request,
options: { timeout?: number } = { timeout: this.config.server.requestTimeout },
): CancelablePromise<Response> {
return new CancelablePromise((resolve, reject, onCancel) => {
const requestId = uuid();
this.logger.debug({ requestId, api: api.name, request }, "API request");
let timeout: ReturnType<typeof setTimeout> | null = null;
let timeoutCancelled = false;
const apiRequest = api.call(this.api.v1, request);
const requestStartedAt = performance.now();
apiRequest
.then((response: Response) => {
this.logger.debug({ requestId, api: api.name, response }, "API response");
if (this.status !== "issuesExist") {
this.changeStatus("ready");
}
if (api.name === "completion") {
this.completionResponseStats.push({
name: api.name,
status: 200,
responseTime: performance.now() - requestStartedAt,
});
}
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 (
error.name === "ApiError" &&
[401, 403, 405].indexOf(error.status) !== -1 &&
new URL(this.config.server.endpoint).hostname.endsWith("app.tabbyml.com") &&
this.config.server.requestHeaders["Authorization"] === undefined
) {
this.logger.debug({ requestId, api: api.name, error }, "API unauthorized");
this.changeStatus("unauthorized");
} else if (error.name === "ApiError") {
this.logger.error({ requestId, api: api.name, error }, "API error");
this.changeStatus("disconnected");
} else {
this.logger.error({ requestId, api: api.name, error }, "API request failed with unknown error");
this.changeStatus("disconnected");
}
// don't record cancelled request in stats
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),
);
private async post<T extends Parameters<typeof this.api.POST>[0]>(
path: T,
requestOptions: Parameters<typeof this.api.POST<T>>[1],
abortOptions?: { signal?: AbortSignal; timeout?: number },
): Promise<Awaited<ReturnType<typeof this.api.POST<T>>>["data"]> {
const requestId = uuid();
this.logger.debug({ requestId, path, requestOptions, abortOptions }, "API request");
try {
const timeout = Math.min(0x7fffffff, abortOptions?.timeout || this.config.server.requestTimeout);
const signal = abortSignalFromAnyOf([AbortSignal.timeout(timeout), abortOptions?.signal]);
const response = await this.api.POST(path, { ...requestOptions, signal });
if (response.error) {
throw new HttpError(response.response);
}
onCancel(() => {
if (timeout) {
clearTimeout(timeout);
}
apiRequest.cancel();
});
});
this.logger.debug({ requestId, path, response: response.data }, "API response");
if (this.status !== "issuesExist") {
this.changeStatus("ready");
}
return response.data;
} catch (error) {
if (isTimeoutError(error)) {
this.logger.debug({ requestId, path, error }, "API request timeout");
} else if (isCanceledError(error)) {
this.logger.debug({ requestId, path, error }, "API request canceled");
} else if (
error instanceof HttpError &&
[401, 403, 405].indexOf(error.status) !== -1 &&
new URL(this.config.server.endpoint).hostname.endsWith("app.tabbyml.com") &&
this.config.server.requestHeaders["Authorization"] === undefined
) {
this.logger.debug({ requestId, path, error }, "API unauthorized");
this.changeStatus("unauthorized");
} else if (error instanceof HttpError) {
this.logger.error({ requestId, path, error }, "API error");
this.changeStatus("disconnected");
} else {
this.logger.error({ requestId, path, error }, "API request failed with unknown error");
this.changeStatus("disconnected");
}
throw error;
}
}
private healthCheck(): Promise<any> {
return this.callApi(this.api.v1.health, {})
.then((healthState) => {
if (
typeof healthState === "object" &&
healthState["model"] !== undefined &&
healthState["device"] !== undefined
) {
this.serverHealthState = healthState;
if (this.status === "ready") {
this.anonymousUsageLogger.uniqueEvent("AgentConnected", healthState);
}
private async healthCheck(options?: AbortSignalOption): Promise<any> {
try {
const healthState = await this.post("/v1/health", {}, options);
if (
typeof healthState === "object" &&
healthState["model"] !== undefined &&
healthState["device"] !== undefined
) {
this.serverHealthState = healthState;
if (this.status === "ready") {
this.anonymousUsageLogger.uniqueEvent("AgentConnected", healthState);
}
})
.catch(() => {});
}
} catch (_) {
// ignore
}
}
private createSegments(request: CompletionRequest): { prefix: string; suffix: string } {
@ -352,109 +312,124 @@ export class TabbyAgent extends EventEmitter implements Agent {
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") {
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);
await this.healthCheck(options);
if (this.status !== "unauthorized") {
return null;
} else {
return await this.auth.requestAuthUrl(options);
}
}
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);
if (isBlank(segments.prefix)) {
// Empty prompt
this.logger.debug("Segment prefix is blank, returning empty completion response");
completionResponse = {
id: "agent-" + uuid(),
choices: [],
};
} 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);
}
return null;
})
// From api
.then(async (response: CompletionResponse | null) => {
if (response) return response;
const segments = this.createSegments(request);
if (isBlank(segments.prefix)) {
this.logger.debug("Segment prefix is blank, returning empty completion response");
return {
id: "agent-" + uuid(),
choices: [],
};
}
const debounce = this.CompletionDebounce.debounce(
// Request server
await this.completionDebounce.debounce(
{
request,
this.config.completion.debounce,
this.completionResponseStats.stats()["averageResponseTime"],
);
cancelableList.push(debounce);
await debounce;
const apiRequest = this.callApi(
this.api.v1.completion,
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,
{
language: request.language,
segments,
user: this.auth?.user,
body: {
language: request.language,
segments,
user: this.auth?.user,
},
},
{
signal,
timeout: request.manually ? this.config.completion.timeout.manually : this.config.completion.timeout.auto,
},
);
cancelableList.push(apiRequest);
let res = await apiRequest;
res = await preCacheProcess(request, res);
this.completionCache.set(request, res);
return res;
})
// Postprocess
.then(async (response: CompletionResponse | null) => {
return postprocess(request, response);
}),
() => {
cancelableList.forEach((cancelable) => cancelable.cancel());
},
);
this.completionResponseStats.push({
name: apiPath,
status: 200,
responseTime: performance.now() - requestStartedAt,
});
} catch (error) {
// record timed out request in stats, do not record canceled request
if (isTimeoutError(error)) {
this.completionResponseStats.push({
name: apiPath,
status: error.status,
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") {
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 () {
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");
if (
(await fs.pathExists(dataFile_0_3_0)) &&
!(await fs.pathExists(migratedFlag))
) {
if ((await fs.pathExists(dataFile_0_3_0)) && !(await fs.pathExists(migratedFlag))) {
const data = await fs.readJson(dataFile_0_3_0);
await fs.outputJson(dataFile, data);
await fs.outputFile(migratedFlag, "");

View File

@ -4,18 +4,21 @@ export {
AgentStatus,
AgentFunction,
AgentEvent,
AgentEventEmitter,
AgentIssue,
StatusChangedEvent,
ConfigUpdatedEvent,
AuthRequiredEvent,
NewIssueEvent,
AgentIssue,
SlowCompletionResponseTimeIssue,
HighCompletionTimeoutRateIssue,
AgentInitOptions,
ServerHealthState,
CompletionRequest,
CompletionResponse,
LogEventRequest,
AbortSignalOption,
agentEventNames,
} from "./Agent";
export { AgentConfig, PartialAgentConfig } from "./AgentConfig";
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);
}
import { CancelablePromise } from "./generated";
export function cancelable<T>(promise: Promise<T>, cancel: () => void): CancelablePromise<T> {
return new CancelablePromise((resolve, reject, onCancel) => {
promise
.then((resp: T) => {
resolve(resp);
})
.catch((err: Error) => {
reject(err);
});
onCancel(() => {
cancel();
// Polyfill for AbortSignal.any(signals) which added in Node.js v20.
export function abortSignalFromAnyOf(signals: AbortSignal[]) {
const controller = new AbortController();
for (const signal of signals) {
if (signal?.aborted) {
controller.abort(signal.reason);
return signal;
}
signal?.addEventListener("abort", () => controller.abort(signal.reason), {
signal: controller.signal,
});
});
}
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"],
"sourceMap": true,
"esModuleInterop": true,
"resolveJsonModule": true
"resolveJsonModule": true,
"noUncheckedIndexedAccess": true
},
"include": ["./src"]
}

View File

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

View File

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

View File

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

View File

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

996
yarn.lock

File diff suppressed because it is too large Load Diff