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
parent
5897a776bb
commit
efe2dcbb0f
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export { CloudApi } from "./CloudApi";
|
|
||||||
export { ApiService } from "./services/ApiService";
|
|
||||||
export { DeviceTokenResponse } from "./models/DeviceTokenResponse";
|
|
||||||
export { DeviceTokenAcceptResponse } from "./models/DeviceTokenAcceptResponse";
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
export type DeviceTokenAcceptResponse = {
|
|
||||||
data: {
|
|
||||||
jwt: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
export type DeviceTokenRefreshResponse = {
|
|
||||||
data: {
|
|
||||||
jwt: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export type DeviceTokenRequest = {
|
|
||||||
auth_url: string;
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
export type DeviceTokenResponse = {
|
|
||||||
data: {
|
|
||||||
code: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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, "");
|
||||||
|
|
|
||||||
|
|
@ -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";
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,8 @@
|
||||||
"clients/vscode",
|
"clients/vscode",
|
||||||
"clients/vim",
|
"clients/vim",
|
||||||
"clients/intellij"
|
"clients/intellij"
|
||||||
]
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue