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": {
|
||||
"cpy-cli": "^4.2.0",
|
||||
"rimraf": "^5.0.1",
|
||||
"tabby-agent": "0.1.1"
|
||||
"tabby-agent": "0.2.0-dev"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
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, "");
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
"lib": ["ES2020", "dom"],
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true
|
||||
"resolveJsonModule": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"include": ["./src"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -5,5 +5,8 @@
|
|||
"clients/vscode",
|
||||
"clients/vim",
|
||||
"clients/intellij"
|
||||
]
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue