Skip to content
This repository has been archived by the owner on Aug 27, 2024. It is now read-only.

Commit

Permalink
#18 Implement debounce with proper async support
Browse files Browse the repository at this point in the history
  • Loading branch information
Venthe committed Apr 4, 2023
1 parent 5048442 commit 2053a83
Show file tree
Hide file tree
Showing 5 changed files with 173 additions and 70 deletions.
79 changes: 32 additions & 47 deletions src/FauxpilotCompletionProvider.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,35 @@
import { Configuration, CreateCompletionRequestPrompt, CreateCompletionResponse, OpenAIApi } from 'openai';
import { CancellationToken, InlineCompletionContext, InlineCompletionItem, InlineCompletionItemProvider, InlineCompletionList, Position, ProviderResult, Range, TextDocument, workspace } from 'vscode';
import { AxiosResponse } from 'axios';
import { nextId } from './Uuid';
import { debounce } from './utilities';

export class FauxpilotCompletionProvider implements InlineCompletionItemProvider {
cachedPrompts: Map<string, number> = new Map<string, number>();

// TODO: Make dynamic
// AFAIK VSCode creates provider once. As such, token will never be updated
private configuration: Configuration = new Configuration({
apiKey: workspace.getConfiguration('fauxpilot').get("token")
});
// TODO: Make dynamic
// AFAIK VSCode creates provider once. As such, server address will never be updated
private openai: OpenAIApi = new OpenAIApi(this.configuration, `${workspace.getConfiguration('fauxpilot').get("server")}/${workspace.getConfiguration('fauxpilot').get("engine")}`);
private request_status: string = "done";
private readonly debouncedApiCall: any = debounce(
// TODO: Extract to method.
// I absolutely forgot how to handle 'this' context in JS. Simple extraction makes this
// undefined. How to bind it?
(prompt: string, position: Position) => {
return new Promise(resolve => {
console.debug("Requesting completion after debounce period");
this.callOpenAi(prompt).then((response) => {
resolve(this.toInlineCompletions(response.data, position));
}).catch((error) => {
console.error(error);
resolve(([] as InlineCompletionItem[]));
});
});
}, { timeout: workspace.getConfiguration('fauxpilot').get("suggestionDelay") as number, defaultReturn: [] });

constructor(private testCompletion?: any) {
}

//@ts-ignore
// because ASYNC and PROMISE
Expand All @@ -21,43 +40,14 @@ export class FauxpilotCompletionProvider implements InlineCompletionItemProvider
}

const prompt = this.getPrompt(document, position);
console.debug("Requesting completion for prompt", prompt);

if (this.isNil(prompt)) {
console.debug("Prompt is empty, skipping");
return Promise.resolve(([] as InlineCompletionItem[]));
}

const currentTimestamp = Date.now();
const currentId = nextId();
this.cachedPrompts.set(currentId, currentTimestamp);

// check there is no newer request util this.request_status is done
while (this.request_status === "pending") {
await this.sleep(200);
console.debug("current id = ", currentId, " request status = ", this.request_status);
if (this.newestTimestamp() > currentTimestamp) {
console.debug("newest timestamp=", this.newestTimestamp(), "current timestamp=", currentTimestamp);
console.debug("Newer request is pending, skipping");
this.cachedPrompts.delete(currentId);
return Promise.resolve(([] as InlineCompletionItem[]));
}
}

console.debug("current id = ", currentId, "set request status to pending");
this.request_status = "pending";
return this.callOpenAi(prompt as String).then((response) => {
console.debug("current id = ", currentId, "set request status to done");
this.request_status = "done";
this.cachedPrompts.delete(currentId);
return this.toInlineCompletions(response.data, position);
}).catch((error) => {
console.debug("current id = ", currentId, "set request status to done");
this.request_status = "done";
this.cachedPrompts.delete(currentId);
console.error(error);
return ([] as InlineCompletionItem[]);
});
console.debug("Requesting completion for prompt", prompt);
return this.debouncedApiCall(prompt, position);
}

private getPrompt(document: TextDocument, position: Position): String | undefined {
Expand All @@ -72,27 +62,22 @@ export class FauxpilotCompletionProvider implements InlineCompletionItemProvider
return value === undefined || value === null || value.length === 0;
}

private newestTimestamp() {
return Array.from(this.cachedPrompts.values()).reduce((a, b) => Math.max(a, b));
}

private sleep(milliseconds: number) {
return new Promise(r => setTimeout(r, milliseconds));
};

private callOpenAi(prompt: String): Promise<AxiosResponse<CreateCompletionResponse, any>> {
console.debug("Calling OpenAi", prompt);

// FIXME: I do not understand my own comment below. To verify
//check if inline completion is enabled
const stop_words = workspace.getConfiguration('fauxpilot').get("inlineCompletion") ? ["\n"] : [];
console.debug("Calling OpenAi with stop words = ", stop_words);
return this.openai.createCompletion({
const stopWords = workspace.getConfiguration('fauxpilot').get("inlineCompletion") ? ["\n"] : [];
console.debug("Calling OpenAi with stop words = ", stopWords);
// FIXME: how to mock in mocha?
// Current implementation works by injecting alternative provider via constructor
return (this.testCompletion ?? this.openai).createCompletion({
model: workspace.getConfiguration('fauxpilot').get("model") ?? "<<UNSET>>",
prompt: prompt as CreateCompletionRequestPrompt,
/* eslint-disable-next-line @typescript-eslint/naming-convention */
max_tokens: workspace.getConfiguration('fauxpilot').get("maxTokens"),
temperature: workspace.getConfiguration('fauxpilot').get("temperature"),
stop: stop_words
stop: stopWords
});
}

Expand Down
8 changes: 0 additions & 8 deletions src/Uuid.ts

This file was deleted.

108 changes: 108 additions & 0 deletions src/test/suite/fauxpilot-completion-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import assert = require("assert");
import { FauxpilotCompletionProvider } from "../../FauxpilotCompletionProvider";
import { CancellationToken, InlineCompletionContext, InlineCompletionItem, InlineCompletionItemProvider, InlineCompletionList, Position, ProviderResult, Range, TextDocument, workspace } from 'vscode';
import { AxiosResponse } from 'axios';
import { CreateCompletionResponse } from "openai";

suite('provideInlineCompletionItems', () => {
test('Normal completion', async () => {
const provider = new FauxpilotCompletionProvider(testCompletion([{ text: "Example response" }]));
const result = await provider.provideInlineCompletionItems(
documentStub("Example prompt"),
positionStub(),
null as any,
null as any
);
assert.equal((result as any)[0].insertText, "Example response");
});
test('Debounced completion', async () => {
// Rewrite as before/after each
let output: any[] = [];
const originalLog = console.log;
const originalDebug = console.debug;
console.log = (message?: any, ...optional: any[]) => {
output.push([message, ...optional]);
originalLog(message, ...optional);
};
console.debug = (message?: any, ...optional: any[]) => {
output.push([message, ...optional]);
originalDebug(message, ...optional);
};

const provider = new FauxpilotCompletionProvider(testCompletion([{ text: "Example response" }]));
(provider.provideInlineCompletionItems(
documentStub("Example prompt 1"),
positionStub(),
null as any,
null as any
) as Promise<any>).then(console.debug);
const result = await provider.provideInlineCompletionItems(
documentStub("Example prompt 2"),
positionStub(),
null as any,
null as any
);

console.debug = originalDebug;
console.log = originalLog;

assert.equal((result as any)[0].insertText, "Example response");
assert.deepEqual(output, [
[
"Requesting completion for prompt",
"Example prompt 1"
],
[
"Requesting completion for prompt",
"Example prompt 2"
],
[
"Resolved previous debounce with defaults"
],
[
[]
],
[
"Resolved debounce"
],
[
"Requesting completion after debounce period"
],
[
"Calling OpenAi",
"Example prompt 2"
],
[
"Calling OpenAi with stop words = ",
["\n"]
]
]);
});
});

function positionStub(): Position {
return {
line: 0,
character: 0
} as any;
}

function documentStub(out?: any): TextDocument {
return {
getText: () => out
} as any;
}

function testCompletion(choices: { text: string }[]) {
return {
createCompletion: async (params: any): Promise<AxiosResponse<CreateCompletionResponse, any>> => {
console.warn("DEBUG COMPLETION", params);
const result: CreateCompletionResponse = {
choices
};
return {
data: result
} as AxiosResponse;
}
};
}
15 changes: 0 additions & 15 deletions src/test/suite/uuid.test.ts

This file was deleted.

33 changes: 33 additions & 0 deletions src/utilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export function debounce<T>(fn: (ths: any, ...args: any[]) => Promise<T>,
{ timeout = 300, defaultReturn }: { timeout: number, defaultReturn?: T }) {
let timer: NodeJS.Timeout;
let previousPromise: any;
return async (...args: any[]) => {
// Resolve any previous pending promises, so that we will never leave
// them dangling
// TODO: Extract debug logging wrapper
previousPromise?.((() => {
console.debug("Resolved previous debounce with defaults");
return defaultReturn;
})());
clearTimeout(timer);
return new Promise(resolve => {
// Add previous promise, so that we can resolve it with empty upon the
// next (debounced) call
previousPromise = resolve;
timer = setTimeout(() => {
// TODO: Extract debug logging wrapper
resolve((() => {
console.debug("Resolved debounce");
// Because we are actually calling the API, we must resolved
// all previous debounced calls with empty, so we ensure that
// there is no dangling resolved promise that would be called
// during the next debounced call
previousPromise = undefined;
// @ts-ignore
return fn.apply(this, args);
})());
}, timeout);
});
};
}

0 comments on commit 2053a83

Please sign in to comment.