Path: blob/main/extensions/copilot/src/platform/nesFetch/node/completionsFetchServiceImpl.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { AsyncIterUtilsExt } from '../../../util/common/asyncIterableUtils';6import { ErrorUtils } from '../../../util/common/errors';7import { Result } from '../../../util/common/result';8import { CancellationToken } from '../../../util/vs/base/common/cancellation';9import { Codicon } from '../../../util/vs/base/common/codicons';10import { IDisposable } from '../../../util/vs/base/common/lifecycle';11import { ThemeIcon } from '../../../util/vs/base/common/themables';12import { IAuthenticationService } from '../../authentication/common/authentication';13import { getRequestId, RequestId } from '../../networking/common/fetch';14import { FetchOptions, IFetcherService, IHeaders, Response } from '../../networking/common/fetcherService';15import { IRequestLogger, LoggedRequestKind } from '../../requestLogger/common/requestLogger';16import { Completion } from '../common/completionsAPI';17import { Completions, ICompletionsFetchService } from '../common/completionsFetchService';18import { ResponseStream } from '../common/responseStream';19import { jsonlStreamToCompletions } from './streamTransformer';2021export type FetchResponse = {22status: number;23statusText: string;24headers: IHeaders;25body: AsyncIterable<string>;26requestId: RequestId;27response: Response;28};2930export interface IFetchRequestParams extends Completions.ModelParams { }3132export class CompletionsFetchService implements ICompletionsFetchService {33readonly _serviceBrand: undefined;3435constructor(36@IAuthenticationService private authService: IAuthenticationService,37@IFetcherService private fetcherService: IFetcherService,38@IRequestLogger private readonly requestLogger: IRequestLogger,39) {40}4142public disconnectAll(): Promise<unknown> {43return this.fetcherService.disconnectAll();44}4546public async fetch(47url: string,48secretKey: string,49params: IFetchRequestParams,50requestId: string,51ct: CancellationToken,52headerOverrides?: Record<string, string>,53): Promise<Result<ResponseStream, Completions.CompletionsFetchFailure>> {54const startTimeMs = Date.now();5556if (ct.isCancellationRequested) {57const result = Result.error(new Completions.RequestCancelled());58this._logCompletionsRequest(url, params, requestId, startTimeMs, result);59return result;60}6162const options = {63requestId,64headers: this.getHeaders(requestId, secretKey, headerOverrides),65body: JSON.stringify({66...params,67stream: true,68})69};7071const fetchResponse = await this._fetchFromUrl(url, options, ct);7273if (fetchResponse.isError()) {74this._logCompletionsRequest(url, params, requestId, startTimeMs, fetchResponse);75return fetchResponse;76}7778if (fetchResponse.val.status === 200) {7980const jsonlStream = AsyncIterUtilsExt.splitLines(fetchResponse.val.body);81const completionsStream = jsonlStreamToCompletions(jsonlStream);8283const response = new ResponseStream(fetchResponse.val.response, completionsStream, fetchResponse.val.requestId, fetchResponse.val.headers);8485const result = Result.ok(response);86this._logCompletionsRequest(url, params, requestId, startTimeMs, result);87return result;8889} else {90const error: Completions.CompletionsFetchFailure = new Completions.UnsuccessfulResponse(91fetchResponse.val.status,92fetchResponse.val.statusText,93fetchResponse.val.headers,94() => collectAsyncIterableToString(fetchResponse.val.body).catch(() => ''),95);9697const result = Result.error(error);98this._logCompletionsRequest(url, params, requestId, startTimeMs, result);99return result;100}101}102103protected async _fetchFromUrl(url: string, options: Completions.Internal.FetchOptions, ct: CancellationToken): Promise<Result<FetchResponse, Completions.CompletionsFetchFailure>> {104105const fetchAbortCtl = this.fetcherService.makeAbortController();106107const onCancellationDisposable = ct.onCancellationRequested(() => {108fetchAbortCtl.abort();109});110111try {112113const request: FetchOptions = {114headers: options.headers,115body: options.body,116signal: fetchAbortCtl.signal,117method: 'POST',118callSite: 'nes-completions',119};120121const response = await this.fetcherService.fetch(url, request);122123if (response.status === 200 && this.authService.copilotToken?.isFreeUser && this.authService.copilotToken?.isChatQuotaExceeded) {124this.authService.resetCopilotToken();125}126127if (response.status !== 200) {128if (response.status === 402) {129// When we receive a 402, we have exceed the free tier quota130// This is stored on the token so let's refresh it131if (!this.authService.copilotToken?.isCompletionsQuotaExceeded) {132this.authService.resetCopilotToken(response.status);133await this.authService.getCopilotToken();134}135}136137return Result.error(new Completions.UnsuccessfulResponse(response.status, response.statusText, response.headers, () => response.text().catch(() => '')));138}139140const body = response.body.pipeThrough(new TextDecoderStream());141142const responseStream = streamWithCleanup(body, onCancellationDisposable);143144return Result.ok({145status: response.status,146statusText: response.statusText,147headers: response.headers,148body: responseStream,149requestId: getRequestId(response.headers),150response,151});152153} catch (reason: unknown) {154155onCancellationDisposable.dispose();156157if (reason instanceof Error && reason.message === 'This operation was aborted') {158return Result.error(new Completions.RequestCancelled());159}160161const error = ErrorUtils.fromUnknown(reason);162return Result.error(new Completions.Unexpected(error));163}164}165166private _logCompletionsRequest(167url: string,168params: IFetchRequestParams,169requestId: string,170startTimeMs: number,171result: Result<ResponseStream, Completions.CompletionsFetchFailure>,172): void {173if (result.isOk()) {174// For successful requests, wait for the stream to complete so we can log the response175const responseStream = result.val;176void responseStream.response.then(aggregated => {177const aggregationStatus = aggregated.isOk() ? 'success' : 'failed';178this._emitCompletionsLogEntry(url, params, requestId, startTimeMs, aggregationStatus, aggregated);179});180} else {181const err = result.err;182if (err instanceof Completions.RequestCancelled) {183this._emitCompletionsLogEntry(url, params, requestId, startTimeMs, 'cancelled', undefined);184} else if (err instanceof Completions.UnsuccessfulResponse) {185this._emitCompletionsLogEntry(url, params, requestId, startTimeMs, 'failed', undefined, `${err.status} ${err.statusText}`);186} else if (err instanceof Completions.Unexpected) {187this._emitCompletionsLogEntry(url, params, requestId, startTimeMs, 'failed', undefined, err.error.message);188}189}190}191192private _emitCompletionsLogEntry(193url: string,194params: IFetchRequestParams,195requestId: string,196startTimeMs: number,197status: 'success' | 'cancelled' | 'failed',198aggregatedResponse: Result<Completion, Error> | undefined,199errorReason?: string,200): void {201const durationMs = Date.now() - startTimeMs;202const lines: string[] = [];203204lines.push(`> 🚨 Note: This log may contain personal information such as the contents of your files. Please review the contents carefully before sharing.`);205lines.push(`# completions`);206lines.push(``);207208// Table of contents209lines.push(`- [Metadata](#metadata)`);210lines.push(`- [Prompt](#prompt)`);211if (params.suffix) {212lines.push(`- [Suffix](#suffix)`);213}214lines.push(`- [Response](#response)`);215lines.push(``);216217// Metadata218lines.push(`## Metadata`);219lines.push(`<pre><code>`);220lines.push(`url : ${url}`);221lines.push(`requestId : ${requestId}`);222lines.push(`model : ${params.model ?? '(default)'}`);223lines.push(`maxTokens : ${params.max_tokens}`);224lines.push(`temperature : ${params.temperature}`);225lines.push(`top_p : ${params.top_p}`);226lines.push(`n : ${params.n}`);227lines.push(`duration : ${durationMs}ms`);228lines.push(`</code></pre>`);229230// Prompt231lines.push(``);232lines.push(`## Prompt`);233lines.push(`~~~`);234lines.push(params.prompt);235lines.push(`~~~`);236237// Suffix238if (params.suffix) {239lines.push(``);240lines.push(`## Suffix`);241lines.push(`~~~`);242lines.push(params.suffix);243lines.push(`~~~`);244}245246// Response247lines.push(``);248lines.push(`## Response`);249if (status === 'cancelled') {250lines.push(`## CANCELED`);251} else if (status === 'failed') {252lines.push(`## FAILED: ${errorReason}`);253} else if (aggregatedResponse) {254if (aggregatedResponse.isOk()) {255const completion = aggregatedResponse.val;256const text = completion.choices[0]?.text ?? '';257const finishReason = completion.choices[0]?.finish_reason ?? 'unknown';258lines.push(`~~~`);259lines.push(text || '<EMPTY RESPONSE>');260lines.push(`~~~`);261lines.push(``);262lines.push(`<pre><code>`);263lines.push(`finishReason : ${finishReason}`);264if (completion.usage) {265lines.push(`promptTokens : ${completion.usage.prompt_tokens}`);266lines.push(`completionTokens : ${completion.usage.completion_tokens}`);267lines.push(`totalTokens : ${completion.usage.total_tokens}`);268}269lines.push(`</code></pre>`);270} else {271lines.push(`## FAILED: stream error - ${aggregatedResponse.err.message}`);272}273}274275const icon: ThemeIcon | undefined = status === 'success' ? undefined : Codicon.error;276277this.requestLogger.addEntry({278type: LoggedRequestKind.MarkdownContentRequest,279debugName: 'Completions Request',280startTimeMs,281icon,282markdownContent: lines.join('\n'),283});284}285286private getHeaders(287requestId: string,288secretKey: string,289headerOverrides: Record<string, string> = {},290): Record<string, string> {291const headers: Record<string, string> = {292'Content-Type': 'application/json',293'x-policy-id': 'nil',294Authorization: 'Bearer ' + secretKey,295'X-Request-Id': requestId,296'X-GitHub-Api-Version': '2025-04-01',297...headerOverrides,298};299300return headers;301}302}303304/**305* Wraps an async iterable stream and disposes the cleanup disposable when the stream completes or errors.306*/307async function* streamWithCleanup(308stream: AsyncIterable<string>,309cleanupDisposable: IDisposable310): AsyncGenerator<string> {311try {312for await (const str of stream) {313yield str;314}315} catch (err: unknown) {316const error = ErrorUtils.fromUnknown(err);317throw error;318} finally {319cleanupDisposable.dispose();320}321}322323/**324* Collects all strings from an async iterable and joins them into a single string.325*/326async function collectAsyncIterableToString(iterable: AsyncIterable<string>): Promise<string> {327const parts: string[] = [];328for await (const part of iterable) {329parts.push(part);330}331return parts.join('');332}333334335