From 1bf26956f052816523a4d878a3920796dd345a4d Mon Sep 17 00:00:00 2001 From: unbadfish <3066893506@qq.com> Date: Fri, 14 Aug 2026 16:33:14 +0800 Subject: [PATCH 1/8] [feat] Support BYOK endpoints for Inline suggestions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit (A) Bridge chatLanguageModels.json custom models into the completions pipeline - Add byok/common/byokCompletionModels.ts: module-level registry and parser that turns customendpoint/customoai groups into ByokCompletionModel entries (completionsUrl used verbatim, apiKey resolved from secret storage, id disambiguation: id → group/id → vendor/group/id) - Add extension/src/byokCompletionModelsContribution.ts: triggers core model resolution via lm.selectChatModels({vendor}) and keeps the completions model manager in sync - Hook abstractLanguageModelChatProvider.provideLanguageModelChatInformation to report the decoded group configuration (vendor, group, configuration) - Forward `group` in extHostLanguageModels.$provideLanguageModelChatInfo so multiple groups per vendor coexist (typed via an intersection to satisfy tsgo) (B) Route completions to the custom endpoint instead of the Copilot proxy - openai/fetch.ts: when a custom model is selected, POST to completionsUrl with FIM fields (prompt/suffix/max_tokens/temperature/top_p/n/stop/stream/model); strip Copilot-only fields (extra/nwo/code_annotations) and headers - handleError: custom endpoints skip Copilot-specific semantics (466, firewall detection, token reset) and get targeted 401/402/404/429 messages - nesFetch transport: isCustomEndpoint omits x-policy-id/X-GitHub-Api-Version, sends no Authorization when no apiKey is configured, skips Copilot 402 quota handling (C) Force n=1 while keeping multi-candidate cycling - Most OpenAI-compatible FIM endpoints (e.g. DeepSeek) reject n>1; force n=1 after the postOptions merge so it cannot be overridden - Cycling (Alt+]) still yields multiple candidates: each cycle with ≤1 cached candidate issues a fresh request and merges the new sample into the cache; the cycling sampling temperature (0.2) is preserved so greedy servers still produce different samples (D) Model selection, picker and configuration schema - openai/model.ts: validate github.copilot.selectedCompletionModel against BYOK models as well (accepts id, group/id and vendor/group/id forms, e.g. customendpoint/DS-oss/deepseek-v4-flash) - Model picker lists custom models together with their group name - package.json: add completionsUrl to customendpoint/customoai models plus a group-level fallback - completionsCoreContribution: register the inline completion provider without a Copilot token when BYOK models are configured (offline/signed-out) (E) Tests - byok/common/test/byokCompletionModels.spec.ts: parsing, group-level fallback, id disambiguation, removal and id-form matching (vitest, 9 cases) - openai/test/fetch.test.ts: BYOK request construction, header/key handling and error branches (mocked fetch, no network, vendor-neutral URLs) - openai/test/model.test.ts: model selection validation and picker surfacing co-author by deepseek-v4-flash&pro, harness by copilot. --- extensions/copilot/package.json | 18 ++ .../byok/common/byokCompletionModels.ts | 178 +++++++++++++++++ .../common/test/byokCompletionModels.spec.ts | 184 ++++++++++++++++++ .../abstractLanguageModelChatProvider.ts | 11 +- .../byok/vscode-node/byokContribution.ts | 4 + .../vscode-node/completionsServiceBridges.ts | 10 + .../src/byokCompletionModelsContribution.ts | 64 ++++++ .../vscode-node/extension/src/modelPicker.ts | 8 +- .../src/ghostText/completionsFromNetwork.ts | 13 +- .../lib/src/ghostText/ghostText.ts | 1 + .../lib/src/ghostText/requestContext.ts | 3 + .../vscode-node/lib/src/openai/config.ts | 4 + .../vscode-node/lib/src/openai/fetch.ts | 136 +++++++++++-- .../vscode-node/lib/src/openai/model.ts | 48 ++++- .../lib/src/openai/test/fetch.test.ts | 147 +++++++++++++- .../lib/src/openai/test/model.test.ts | 79 ++++++++ .../completionsCoreContribution.ts | 24 ++- .../common/completionsFetchService.ts | 7 +- .../node/completionsFetchServiceImpl.ts | 25 ++- .../api/common/extHostLanguageModels.ts | 11 +- 20 files changed, 930 insertions(+), 45 deletions(-) create mode 100644 extensions/copilot/src/extension/byok/common/byokCompletionModels.ts create mode 100644 extensions/copilot/src/extension/byok/common/test/byokCompletionModels.spec.ts create mode 100644 extensions/copilot/src/extension/completions-core/vscode-node/extension/src/byokCompletionModelsContribution.ts create mode 100644 extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/test/model.test.ts diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index ea6cc75b4f3f9a..8f972c8ae123b9 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1909,6 +1909,12 @@ "type": "string", "markdownDescription": "URL endpoint for the custom OpenAI-compatible model.\n\n**Important:** Base URLs default to Chat Completions API. Explicit API paths including `/responses` or `/chat/completions` are respected." }, + "completionsUrl": { + "type": "string", + "pattern": "^https?://.+", + "patternErrorMessage": "URL must start with http:// or https://", + "markdownDescription": "Full URL used **verbatim** for inline code completions (FIM) requests, e.g. `https://api.deepseek.com/beta/completions` or `https://api.siliconflow.cn/v1/completions`. When omitted, this model is only available for chat, not for inline code completions." + }, "toolCalling": { "type": "boolean", "description": "Whether the model supports tool calling" @@ -2040,6 +2046,12 @@ "title": "API Type", "markdownDescription": "Default request/response format for models in this group. Individual models can override this with their own `apiType` property; when both are unset the type is inferred from the URL path." }, + "completionsUrl": { + "type": "string", + "pattern": "^https?://.+", + "patternErrorMessage": "URL must start with http:// or https://", + "markdownDescription": "Default full URL used **verbatim** for inline code completions (FIM) requests for models in this group, e.g. `https://api.deepseek.com/beta/completions` or `https://api.siliconflow.cn/v1/completions`. Individual models can override this with their own `completionsUrl` property. When neither is set, the group's models are only available for chat." + }, "models": { "type": "array", "defaultSnippets": [ @@ -2076,6 +2088,12 @@ "patternErrorMessage": "URL must start with http:// or https://", "markdownDescription": "URL endpoint for the model.\n\n**Important:** Base URLs default to Chat Completions API. Explicit API paths are respected: `/chat/completions`, `/responses`, and `/v1/messages` (Anthropic-compatible). Use the `apiType` property to override the request/response format independently of the URL." }, + "completionsUrl": { + "type": "string", + "pattern": "^https?://.+", + "patternErrorMessage": "URL must start with http:// or https://", + "markdownDescription": "Full URL used **verbatim** for inline code completions (FIM) requests, e.g. `https://api.deepseek.com/beta/completions` or `https://api.siliconflow.cn/v1/completions`. When omitted, this model is only available for chat, not for inline code completions." + }, "apiType": { "type": "string", "enum": [ diff --git a/extensions/copilot/src/extension/byok/common/byokCompletionModels.ts b/extensions/copilot/src/extension/byok/common/byokCompletionModels.ts new file mode 100644 index 00000000000000..97b1504dc7132d --- /dev/null +++ b/extensions/copilot/src/extension/byok/common/byokCompletionModels.ts @@ -0,0 +1,178 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IStringDictionary } from '../../../util/vs/base/common/collections'; +import { Emitter, Event } from '../../../util/vs/base/common/event'; + +/** + * Vendors whose `chatLanguageModels.json` groups can back inline code completions + * via OpenAI-compatible FIM endpoints. + */ +export const BYOK_COMPLETION_VENDORS = ['customendpoint', 'customoai']; + +/** + * A custom OpenAI-compatible model usable for inline code completions (FIM). + * The FIM request is POSTed verbatim to {@link ByokCompletionModel.completionsUrl}. + */ +export interface ByokCompletionModel { + /** Identifier usable in `github.copilot.selectedCompletionModel`. */ + readonly id: string; + /** Display name for the model picker. */ + readonly label: string; + /** Vendor id ('customendpoint' | 'customoai'). */ + readonly vendor: string; + /** The chatLanguageModels.json group name the model comes from. */ + readonly groupName: string; + /** Full URL the FIM request is POSTed to. Used verbatim — no path derivation. */ + readonly completionsUrl: string; + /** API key resolved from secret storage by the language models service. */ + readonly apiKey?: string; + /** Model identifier sent in the request body `model` field. */ + readonly model: string; + /** Custom headers from the model configuration. */ + readonly requestHeaders?: Record; +} + +interface RegisteredGroupConfig { + readonly vendor: string; + readonly groupName: string; + readonly configuration: IStringDictionary | undefined; +} + +const registeredGroupConfigs = new Map(); +let completionModels: ByokCompletionModel[] = []; + +const _onDidChange = new Emitter(); +export const onDidChangeByokCompletionModels: Event = _onDidChange.event; + +/** + * Called by the BYOK language model chat providers (see + * `AbstractLanguageModelChatProvider.provideLanguageModelChatInformation`) once the + * language models service has resolved a `chatLanguageModels.json` group. At that + * point secrets such as `${input:...}` api keys are already decoded, so this is the + * only place where the completion pipeline can obtain them. + * + * Passing `undefined` configuration removes the group (e.g. group deleted in the file). + */ +export function updateByokCompletionModelConfig(vendor: string, groupName: string | undefined, configuration: IStringDictionary | undefined): void { + const key = `${vendor}/${groupName ?? ''}`; + if (!configuration) { + registeredGroupConfigs.delete(key); + } else { + registeredGroupConfigs.set(key, { vendor, groupName: groupName ?? '', configuration }); + } + recomputeCompletionModels(); +} + +/** Clears all registered group configs, e.g. when BYOK is disabled by enterprise policy. */ +export function clearByokCompletionModelConfigs(): void { + registeredGroupConfigs.clear(); + recomputeCompletionModels(); +} + +export function getByokCompletionModels(): readonly ByokCompletionModel[] { + return completionModels; +} + +export function getByokCompletionModelById(id: string): ByokCompletionModel | undefined { + // Exact match on the generated identifier first, then fall back to the + // `group/id` and `vendor/group/id` forms used by the chat model picker + // (`toModelIdentifier`), so user-entered values like + // `customendpoint/DS-oss/deepseek-v4-flash` also resolve. + return completionModels.find(model => + model.id === id + || `${model.groupName}/${model.model}` === id + || `${model.vendor}/${model.groupName}/${model.model}` === id + ); +} + +function recomputeCompletionModels(): void { + const models: ByokCompletionModel[] = []; + const usedIds = new Set(); + + for (const { vendor, groupName, configuration } of registeredGroupConfigs.values()) { + if (!BYOK_COMPLETION_VENDORS.includes(vendor)) { + continue; + } + parseGroupConfiguration(models, usedIds, vendor, groupName, configuration); + } + + if (JSON.stringify(models) !== JSON.stringify(completionModels)) { + completionModels = models; + _onDidChange.fire(); + } +} + +function parseGroupConfiguration(models: ByokCompletionModel[], usedIds: Set, vendor: string, groupName: string, configuration: IStringDictionary | undefined): void { + if (!configuration || typeof configuration !== 'object') { + return; + } + const groupCompletionsUrl = asOptionalString(configuration.completionsUrl); + const apiKey = asOptionalString(configuration.apiKey); + const configuredModels = configuration.models; + if (!Array.isArray(configuredModels)) { + return; + } + for (const entry of configuredModels) { + if (!entry || typeof entry !== 'object') { + continue; + } + const model = entry as IStringDictionary; + const modelId = asOptionalString(model.id); + const url = asOptionalString(model.url); + if (!modelId || !url) { + // The model must have an id and a chat URL to be usable at all. + continue; + } + const completionsUrl = asOptionalString(model.completionsUrl) ?? groupCompletionsUrl; + if (!completionsUrl || !/^https?:\/\//.test(completionsUrl)) { + // No (valid) completions URL configured: the model is chat-only. + continue; + } + const id = computeUniqueId(modelId, groupName, vendor, usedIds); + usedIds.add(id); + models.push({ + id, + label: asOptionalString(model.name) ?? modelId, + vendor, + groupName, + completionsUrl, + ...(apiKey ? { apiKey } : {}), + model: modelId, + ...(toStringRecord(model.requestHeaders) ? { requestHeaders: toStringRecord(model.requestHeaders) } : {}), + }); + } +} + +/** + * The raw model id is the primary identifier; when multiple groups define models + * with the same id, they are disambiguated by group name and vendor. + */ +function computeUniqueId(modelId: string, groupName: string, vendor: string, usedIds: Set): string { + const candidates = [modelId, `${groupName}/${modelId}`, `${vendor}/${groupName}/${modelId}`]; + for (const candidate of candidates) { + if (!usedIds.has(candidate)) { + return candidate; + } + } + return `${vendor}/${groupName}/${modelId}/${usedIds.size}`; +} + +function asOptionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +function toStringRecord(value: unknown): Record | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + const record: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === 'string') { + record[key] = entry; + } + } + return Object.keys(record).length > 0 ? record : undefined; +} diff --git a/extensions/copilot/src/extension/byok/common/test/byokCompletionModels.spec.ts b/extensions/copilot/src/extension/byok/common/test/byokCompletionModels.spec.ts new file mode 100644 index 00000000000000..b7258b23eb425a --- /dev/null +++ b/extensions/copilot/src/extension/byok/common/test/byokCompletionModels.spec.ts @@ -0,0 +1,184 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, describe, expect, it } from 'vitest'; +import { clearByokCompletionModelConfigs, getByokCompletionModelById, getByokCompletionModels, updateByokCompletionModelConfig } from '../byokCompletionModels'; + +describe('byokCompletionModels', () => { + afterEach(() => { + clearByokCompletionModelConfigs(); + }); + + it('parses customendpoint groups with an explicit model completionsUrl', () => { + updateByokCompletionModelConfig('customendpoint', 'Custom', { + apiKey: 'sk-1', + models: [ + { + id: 'custom-model', + name: 'Custom Model', + url: 'https://custom.example.com/v1/chat/completions', + completionsUrl: 'https://custom.example.com/v1/completions', + }, + ], + }); + + expect(getByokCompletionModels()).toEqual([ + { + id: 'custom-model', + label: 'Custom Model', + vendor: 'customendpoint', + groupName: 'Custom', + completionsUrl: 'https://custom.example.com/v1/completions', + apiKey: 'sk-1', + model: 'custom-model', + }, + ]); + }); + + it('falls back to the group-level completionsUrl (used verbatim)', () => { + updateByokCompletionModelConfig('customendpoint', 'Custom', { + completionsUrl: 'https://custom.example.com/v1/completions', + models: [ + { + id: 'nested/model-id', + name: 'Nested Model', + url: 'https://custom.example.com/v1/chat/completions', + }, + ], + }); + + const model = getByokCompletionModels()[0]; + expect(model.completionsUrl).toBe('https://custom.example.com/v1/completions'); + expect(model.id).toBe('nested/model-id'); + }); + + it('skips models without any completionsUrl (chat-only)', () => { + updateByokCompletionModelConfig('customendpoint', 'ChatOnly', { + models: [ + { + id: 'chat-model', + name: 'Chat Model', + url: 'https://api.example.com/v1/chat/completions', + }, + ], + }); + + expect(getByokCompletionModels()).toEqual([]); + }); + + it('skips models with a non-http(s) completionsUrl', () => { + updateByokCompletionModelConfig('customendpoint', 'Bad', { + completionsUrl: 'file:///tmp/completions', + models: [ + { + id: 'bad-model', + name: 'Bad Model', + url: 'https://api.example.com/v1/chat/completions', + }, + ], + }); + + expect(getByokCompletionModels()).toEqual([]); + }); + + it('disambiguates duplicate model ids across groups', () => { + updateByokCompletionModelConfig('customendpoint', 'A', { + completionsUrl: 'https://a.example.com/v1/completions', + models: [ + { + id: 'm', + name: 'M A', + url: 'https://a.example.com/v1/chat/completions', + }, + ], + }); + updateByokCompletionModelConfig('customendpoint', 'B', { + completionsUrl: 'https://b.example.com/v1/completions', + models: [ + { + id: 'm', + name: 'M B', + url: 'https://b.example.com/v1/chat/completions', + }, + ], + }); + + const models = getByokCompletionModels(); + expect(models).toHaveLength(2); + expect(models[0].id).toBe('m'); + expect(models[1].id).toBe('B/m'); + expect(getByokCompletionModelById('m')?.label).toBe('M A'); + expect(getByokCompletionModelById('B/m')?.label).toBe('M B'); + }); + + it('ignores non-BYOK vendors such as openai', () => { + updateByokCompletionModelConfig('openai', 'OpenAI', { + completionsUrl: 'https://api.openai.com/v1/completions', + models: [ + { + id: 'gpt-4o', + name: 'GPT-4o', + url: 'https://api.openai.com/v1/chat/completions', + }, + ], + }); + + expect(getByokCompletionModels()).toEqual([]); + }); + + it('matches the id, group/id and vendor/group/id forms of a selected model', () => { + updateByokCompletionModelConfig('customendpoint', 'Group-1', { + completionsUrl: 'https://custom.example.com/v1/completions', + models: [ + { + id: 'model-flash', + name: 'Model Flash', + url: 'https://custom.example.com/v1/chat/completions', + }, + ], + }); + + // `github.copilot.selectedCompletionModel` may hold any of these forms: the + // chat model picker writes the vendor/group/id form (`toModelIdentifier`), + // while the completion model picker writes the bare id. + expect(getByokCompletionModelById('model-flash')?.label).toBe('Model Flash'); + expect(getByokCompletionModelById('Group-1/model-flash')?.label).toBe('Model Flash'); + expect(getByokCompletionModelById('customendpoint/Group-1/model-flash')?.label).toBe('Model Flash'); + expect(getByokCompletionModelById('unknown-model')).toBeUndefined(); + }); + + it('removes a group when its configuration is cleared', () => { + updateByokCompletionModelConfig('customendpoint', 'Custom', { + completionsUrl: 'https://custom.example.com/v1/completions', + models: [ + { + id: 'custom-model', + name: 'Custom Model', + url: 'https://custom.example.com/v1/chat/completions', + }, + ], + }); + expect(getByokCompletionModels()).toHaveLength(1); + + updateByokCompletionModelConfig('customendpoint', 'Custom', undefined); + expect(getByokCompletionModels()).toEqual([]); + }); + + it('clears all models', () => { + updateByokCompletionModelConfig('customendpoint', 'A', { + completionsUrl: 'https://a.example.com/v1/completions', + models: [ + { + id: 'm', + name: 'M', + url: 'https://a.example.com/v1/chat/completions', + }, + ], + }); + + clearByokCompletionModelConfigs(); + expect(getByokCompletionModels()).toEqual([]); + }); +}); diff --git a/extensions/copilot/src/extension/byok/vscode-node/abstractLanguageModelChatProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/abstractLanguageModelChatProvider.ts index 65a36803164c57..70d5fc09088a09 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/abstractLanguageModelChatProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/abstractLanguageModelChatProvider.ts @@ -7,6 +7,7 @@ import { CancellationToken, commands, LanguageModelChatInformation, LanguageMode import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { IChatModelInformation, ModelSupportedEndpoint } from '../../../platform/endpoint/common/endpointProvider'; import { ILogService } from '../../../platform/log/common/logService'; +import { updateByokCompletionModelConfig } from '../common/byokCompletionModels'; import { IFetcherService } from '../../../platform/networking/common/fetcherService'; import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; import { IStringDictionary } from '../../../util/vs/base/common/collections'; @@ -58,7 +59,15 @@ export abstract class AbstractLanguageModelChatProvider { + async provideLanguageModelChatInformation(options: PrepareLanguageModelChatModelOptions, token: CancellationToken): Promise { + const { silent, configuration } = options; + // Feed the resolved group configuration (api keys are already decoded from + // secret storage by the language models service) to the inline completions + // BYOK pipeline, enabling OpenAI-compatible FIM endpoints for code completions. + // Note: `group` is not part of the public API type yet but is forwarded at runtime + // (see extHostLanguageModels.ts), allowing multiple groups per vendor to coexist. + const extendedOptions = options as PrepareLanguageModelChatModelOptions & { group?: string; configuration?: IStringDictionary }; + updateByokCompletionModelConfig(this._id, extendedOptions.group, extendedOptions.configuration); let apiKey: string | undefined = (configuration as C)?.apiKey; if (!apiKey) { apiKey = await this.configureDefaultGroupWithApiKeyOnly(); diff --git a/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts b/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts index a0d088952246da..f17543fa1fe716 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/byokContribution.ts @@ -9,6 +9,7 @@ import { ILogService } from '../../../platform/log/common/logService'; import { IFetcherService } from '../../../platform/networking/common/fetcherService'; import { Disposable, DisposableStore } from '../../../util/vs/base/common/lifecycle'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { clearByokCompletionModelConfigs } from '../../byok/common/byokCompletionModels'; import { BYOKKnownModels, isClientBYOKAllowed } from '../../byok/common/byokProvider'; import { IExtensionContribution } from '../../common/contributions'; import { AbstractLanguageModelChatProvider } from './abstractLanguageModelChatProvider'; @@ -92,6 +93,9 @@ export class BYOKContrib extends Disposable implements IExtensionContribution { } else if (!allowed && this._providersRegistered) { this._providerRegistrations.clear(); this._providersRegistered = false; + // Also drop any BYOK completion models so completions never route to a + // custom endpoint once BYOK is disabled by enterprise policy. + clearByokCompletionModelConfigs(); this._logService.info('BYOK: unregistered providers due to enterprise policy.'); } } diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/completionsServiceBridges.ts b/extensions/copilot/src/extension/completions-core/vscode-node/completionsServiceBridges.ts index 1418a543ddd85d..2b133d7c1e242e 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/completionsServiceBridges.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/completionsServiceBridges.ts @@ -23,6 +23,7 @@ import { ModelPickerManager } from './extension/src/modelPicker'; import { CopilotStatusBar } from './extension/src/statusBar'; import { CopilotStatusBarPickMenu } from './extension/src/statusBarPicker'; import { ExtensionTextDocumentManager } from './extension/src/textDocumentManager'; +import { ByokCompletionModelsContribution } from './extension/src/byokCompletionModelsContribution'; import { exception } from './extension/src/vscodeInlineCompletionItemProvider'; import { CopilotTokenManagerImpl, ICompletionsCopilotTokenManager } from './lib/src/auth/copilotTokenManager'; import { ICompletionsCitationManager } from './lib/src/citationManager'; @@ -125,6 +126,15 @@ export function createContext(serviceAccessor: ServicesAccessor, store: Disposab return serviceAccessor.get(IInstantiationService).createChild(serviceCollection, store); } +/** + * Bridges `chatLanguageModels.json` custom (BYOK) models into the completions model + * manager. Invoked early during extension activation, independently of the inline + * completion provider registration, so signed-out/offline BYOK setups work. + */ +export function setupByokCompletionModels(accessor: ServicesAccessor): IDisposable { + return accessor.get(IInstantiationService).createInstance(ByokCompletionModelsContribution); +} + /** @public */ export function setup(serviceAccessor: ServicesAccessor, disposables: DisposableStore) { // This must be registered before activation! diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/byokCompletionModelsContribution.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/byokCompletionModelsContribution.ts new file mode 100644 index 00000000000000..f7511e01aa69bb --- /dev/null +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/byokCompletionModelsContribution.ts @@ -0,0 +1,64 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { lm } from 'vscode'; +import { Disposable } from '../../../../../util/vs/base/common/lifecycle'; +import { BYOK_COMPLETION_VENDORS, getByokCompletionModels, onDidChangeByokCompletionModels } from '../../../../byok/common/byokCompletionModels'; +import { ICompletionsLogTargetService, LogLevel } from '../../lib/src/logger'; +import { ICompletionsModelManagerService } from '../../lib/src/openai/model'; + +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Bridges `chatLanguageModels.json` (via the language models service) into the inline + * completions pipeline. It triggers resolution of the BYOK vendor groups so the chat + * providers report their decoded configuration (api keys already resolved from secret + * storage), and keeps the completions model manager in sync when models change. + */ +export class ByokCompletionModelsContribution extends Disposable { + constructor( + @ICompletionsModelManagerService private readonly _modelManager: ICompletionsModelManagerService, + @ICompletionsLogTargetService private readonly _logService: ICompletionsLogTargetService, + ) { + super(); + void this._syncByokModels(); + // Re-sync whenever the language models service reports changes (e.g. the user + // edits chatLanguageModels.json — the core re-resolves groups automatically). + this._register(lm.onDidChangeChatModels(() => void this._syncByokModels())); + this._register(onDidChangeByokCompletionModels(() => this._modelManager.refreshByokModels())); + } + + /** + * `lm.selectChatModels` has the side effect of making the language models service + * resolve the vendor's groups, which invokes the BYOK chat providers' + * `provideLanguageModelChatInformation` with the decoded group configuration. The + * first attempts may race with the BYOK provider registration during extension + * activation, so retry until the models show up or the attempts are exhausted. + */ + private async _syncByokModels(): Promise { + for (let attempt = 0; attempt < 3; attempt++) { + for (const vendor of BYOK_COMPLETION_VENDORS) { + try { + await lm.selectChatModels({ vendor }); + } catch (error) { + // The vendor may not be registered (e.g. BYOK disabled by enterprise + // policy); resolution failures are not fatal for completions. + this._logService.logIt(LogLevel.INFO, `BYOK completions: selectChatModels(${vendor}) failed: ${String(error)}`); + } + } + const models = getByokCompletionModels(); + this._logService.logIt(LogLevel.INFO, `BYOK completions: attempt ${attempt + 1}, resolved ${models.length} model(s): ${models.map(m => m.id).join(', ') || 'none'}`); + if (models.length > 0) { + break; + } + if (attempt < 2) { + await delay(1000 * (attempt + 1)); + } + } + this._modelManager.refreshByokModels(); + } +} diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/modelPicker.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/modelPicker.ts index 2b46b78d4e41d7..9ed30cc5c4106b 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/modelPicker.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/modelPicker.ts @@ -45,7 +45,10 @@ export class ModelPickerManager { private readonly MODELS_INFO_URL = 'https://aka.ms/CopilotCompletionsModelPickerLearnMore'; get models(): ModelItem[] { - return this._modelManager.getGenericCompletionModels(); + return [ + ...this._modelManager.getGenericCompletionModels(), + ...this._modelManager.getCustomCompletionModels(), + ]; } hasMultipleModels(): boolean { @@ -122,10 +125,11 @@ export class ModelPickerManager { private modelsForModelPicker(): [string | null, ModelPickerItem[]] { const currentModelSelection = this._instantiationService.invokeFunction(getUserSelectedModelConfiguration); const items: ModelPickerItem[] = this.models.map(model => { + const groupLabel = model.custom ? `${model.customGroup} • ` : ''; return { modelId: model.modelId, label: `${model.label}${model.preview ? ' (Preview)' : ''}`, - description: `(${model.modelId})`, + description: `${groupLabel}(${model.modelId})`, alwaysShow: model.modelId === this.getDefaultModelId(), type: 'model' as const, }; diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/completionsFromNetwork.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/completionsFromNetwork.ts index 9f0babace1f88f..a69e571fd3b581 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/completionsFromNetwork.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/completionsFromNetwork.ts @@ -266,8 +266,16 @@ export class CompletionsFromNetwork { telemetryBuilder.setModelName(requestContext.engineModelId); // Request one choice for automatic requests, three for invoked (cycling) requests. - const n = requestContext.isCycling ? 3 : 1; - const temperature = getTemperatureForSamples(this.runtimeMode, n); + // Custom BYOK (OpenAI-compatible) endpoints typically only support n = 1 (e.g. + // DeepSeek FIM), so cycling falls back to re-requesting a single sample: each + // Alt+] with <= 1 cached candidate fires a new request (see ghostText.ts) and the + // fresh sample is merged with the cached candidates (deduplicated by text). + // Because candidates now come from separate requests instead of one n=3 batch, + // keep the cycling sampling temperature (0.2): with temperature 0 (the n=1 + // default) a greedy server would return the same completion every time and + // cycling would degrade to a single candidate. + const n = requestContext.isCycling && !requestContext.customModel ? 3 : 1; + const temperature = getTemperatureForSamples(this.runtimeMode, requestContext.isCycling ? 3 : 1); const extra: CompletionRequestExtra = { language: requestContext.languageId, next_indent: requestContext.indentation.next ?? 0, @@ -320,6 +328,7 @@ export class CompletionsFromNetwork { postOptions, headers: requestContext.headers, extra, + customModel: requestContext.customModel, }; const res = await this.fetcherService.fetchAndStreamCompletions(completionParams, baseTelemetryData, finishedCb, cancellationToken); if (res.type === 'failed') { diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/ghostText.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/ghostText.ts index 173037a468e8a7..b01f030b63f10a 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/ghostText.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/ghostText.ts @@ -378,6 +378,7 @@ export class GhostTextComputer { stop: ghostTextStrategy.stop, maxTokens: ghostTextStrategy.maxTokens, afterAccept: hasAcceptedCurrentCompletion, + customModel: engineInfo.customModel, }; // Add headers to identify async completions and speculative requests requestContext.headers = { diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/requestContext.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/requestContext.ts index eb58da175dcdc7..11a42fa474cfc9 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/requestContext.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/ghostText/requestContext.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { ByokCompletionModel } from '../../../../../byok/common/byokCompletionModels'; import { BlockMode } from '../config'; import { CompletionHeaders } from '../openai/fetch'; import { ContextIndentation } from '../prompt/parseBlock'; @@ -39,5 +40,7 @@ export interface RequestContext { maxTokens?: number; /** Whether the current request is following an accepted completion. */ afterAccept: boolean; + /** Custom BYOK (OpenAI-compatible) completion model, when the user selected one. */ + customModel?: ByokCompletionModel; } diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/config.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/config.ts index ab6e8cb7b6059e..5ffcc26ccabb0e 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/config.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/config.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ServicesAccessor } from '../../../../../../util/vs/platform/instantiation/common/instantiation'; +import { ByokCompletionModel } from '../../../../../byok/common/byokCompletionModels'; import { TokenizerName } from '../../../prompt/src/tokenization'; import { TelemetryWithExp } from '../telemetry'; import { CompletionHeaders } from './fetch'; @@ -16,6 +17,8 @@ export type EngineRequestInfo = { modelId: string; engineChoiceSource: ModelChoiceSourceTelemetryValue; tokenizer: TokenizerName; + /** Set when the selected model is a custom BYOK (OpenAI-compatible) completion model. */ + customModel?: ByokCompletionModel; }; export function getEngineRequestInfo( @@ -31,5 +34,6 @@ export function getEngineRequestInfo( modelId: modelRequestInfo.modelId, engineChoiceSource: modelRequestInfo.modelChoiceSource, tokenizer, + customModel: modelRequestInfo.customModel, }; } diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/fetch.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/fetch.ts index 1205343a774dbf..86a0acdb804204 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/fetch.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/fetch.ts @@ -7,6 +7,7 @@ import { IAuthenticationService } from '../../../../../../platform/authenticatio import { CopilotAnnotations, StreamCopilotAnnotations } from '../../../../../../platform/completions-core/common/openai/copilotAnnotations'; import { IEnvService } from '../../../../../../platform/env/common/envService'; import { Completion } from '../../../../../../platform/nesFetch/common/completionsAPI'; +import { ByokCompletionModel } from '../../../../../byok/common/byokCompletionModels'; import { Completions, ICompletionsFetchService } from '../../../../../../platform/nesFetch/common/completionsFetchService'; import { ResponseStream } from '../../../../../../platform/nesFetch/common/responseStream'; import { RequestId, getRequestId } from '../../../../../../platform/networking/common/fetch'; @@ -86,6 +87,8 @@ type CompletionFetchRequestFields = { logprobs?: number; /** Likelihood of specified tokens appearing in the completion. */ logit_bias?: { [key: string]: number }; + /** The model to use for the completion. Required for custom (BYOK) endpoints. */ + model?: string; /** Copilot-only: NWO of repository, if any */ nwo?: string; @@ -99,8 +102,8 @@ type CompletionFetchRequestFields = { /** OAI API completion request, along with additional fields specific to Copilot. */ export type CompletionRequest = BaseFetchRequest & CompletionFetchRequestFields & { - /** Copilot-only: extra arguments for completion processing. */ - extra: Partial; + /** Copilot-only: extra arguments for completion processing. Omitted for custom (BYOK) endpoints. */ + extra?: Partial; }; /** @@ -236,6 +239,11 @@ export interface CompletionParams extends InternalFetchParams { requestLogProbs?: boolean; postOptions?: PostOptions; extra: Partial; + /** + * When set, the request is sent to a custom (BYOK) OpenAI-compatible FIM endpoint + * instead of the Copilot proxy: no Copilot token is used and the URL is used verbatim. + */ + customModel?: ByokCompletionModel; } /** @@ -308,7 +316,7 @@ export function sanitizeRequestOptionTelemetry( let valueToLog = value as unknown; - if (key === 'extra' && extraKeys) { + if (key === 'extra' && extraKeys && valueToLog !== undefined) { const extra = { ...(valueToLog as CompletionRequestExtra) }; for (const extraKey of extraKeys) { delete extra[extraKey]; @@ -353,7 +361,13 @@ export class LiveOpenAIFetcher extends OpenAIFetcher { return { type: 'canceled', reason: this.#disabledReason }; } const endpoint = 'completions'; - const copilotToken = this.copilotTokenManager.token ?? await this.copilotTokenManager.getToken(); + const customModel = params.customModel; + + // Custom (BYOK) endpoints are contacted directly with the user's own API key — + // no Copilot token is fetched, which makes completions fully offline-capable. + const copilotToken = customModel + ? undefined + : (this.copilotTokenManager.token ?? await this.copilotTokenManager.getToken()); const request: CompletionRequest = { prompt: params.prompt.prefix, @@ -373,17 +387,39 @@ export class LiveOpenAIFetcher extends OpenAIFetcher { request.logprobs = 2; // Request that logprobs of 2 tokens (i.e. including the best alternative) be returned } - const githubNWO = tryGetGitHubNWO(params.repoInfo); - if (githubNWO !== undefined) { - request.nwo = githubNWO; + if (customModel) { + // BYOK: send the standard OpenAI FIM fields only. Copilot-specific fields + // (`extra`, `nwo`, `code_annotations`) are omitted; `stop` is kept verbatim + // so single-/multi-line modes work as they do against the Copilot proxy. + // `n` is forced to 1 below (after postOptions are merged): most + // OpenAI-compatible FIM endpoints (e.g. DeepSeek, SiliconFlow) reject n > 1. + // Multiple candidates still work: cycling (Alt+]) fires a new request + // whenever the local cache holds <= 1 candidate (see ghostText.ts) and merges + // the fresh sample with the cached ones — the cycling sampling temperature + // (0.2) is kept in completionsFromNetwork so consecutive requests produce + // different completions. + request.model = customModel.model; + delete request.extra; + } else { + const githubNWO = tryGetGitHubNWO(params.repoInfo); + if (githubNWO !== undefined) { + request.nwo = githubNWO; + } } if (params.postOptions) { Object.assign(request, params.postOptions); } - if (params.prompt.context && params.prompt.context.length > 0) { - request.extra.context = params.prompt.context; + if (customModel) { + // postOptions may carry `n` (e.g. cycling requests pass n=3) and + // `code_annotations: false` (Copilot-only); force `n` back to 1 AFTER the + // merge and drop the Copilot-only fields for BYOK endpoints. + request.n = 1; + delete request.extra; + delete request.code_annotations; + } else if (params.prompt.context && params.prompt.context.length > 0) { + request.extra!.context = params.prompt.context; } // Give a final opportunity to cancel the request before we send the request @@ -401,7 +437,9 @@ export class LiveOpenAIFetcher extends OpenAIFetcher { const telemetryExp = baseTelemetryData; const uiKind = params.uiKind; const headers = params.headers; - const uri = this.instantiationService.invokeFunction(getProxyEngineUrl, copilotToken, engineModelId, endpoint); + const uri = customModel + ? customModel.completionsUrl // Used verbatim — the user is responsible for the URL + : this.instantiationService.invokeFunction(getProxyEngineUrl, copilotToken!, engineModelId, endpoint); const telemetryData = telemetryExp.extendedBy( { @@ -426,7 +464,11 @@ export class LiveOpenAIFetcher extends OpenAIFetcher { let fullHeaders: Record; - { + if (customModel) { + // Minimal headers for custom endpoints: Content-Type, X-Request-Id and + // Authorization (Bearer ) are added by the fetch service. + fullHeaders = {}; + } else { fullHeaders = { ...headers, ...this.instantiationService.invokeFunction(editorVersionHeaders), @@ -445,13 +487,15 @@ export class LiveOpenAIFetcher extends OpenAIFetcher { const requestSw = new StopWatch(); const cancelToken = cancel ?? CancellationToken.None; + const secretKey = customModel ? (customModel.apiKey ?? '') : copilotToken!.token; const res = await this.fetchService.fetch( uri, - copilotToken.token, + secretKey, request, ourRequestId, cancelToken, fullHeaders, + customModel !== undefined, ).then(response => { if (response.isError() && response.err instanceof Completions.Unexpected && isInterruptedNetworkError(response.err.error)) { // disconnect and retry the request once if the connection was reset @@ -459,11 +503,12 @@ export class LiveOpenAIFetcher extends OpenAIFetcher { return this.fetchService.disconnectAll().then(() => { return this.fetchService.fetch( uri, - copilotToken.token, - request, - ourRequestId, - cancelToken, - fullHeaders, + secretKey, + request, + ourRequestId, + cancelToken, + fullHeaders, + customModel !== undefined, ); }); } else { @@ -507,7 +552,7 @@ export class LiveOpenAIFetcher extends OpenAIFetcher { status: err.status, text: err.text, headers: err.headers, - }, copilotToken); + }, copilotToken, customModel); } else if (err instanceof Completions.Unexpected) { const error = err.error; @@ -794,9 +839,62 @@ export class LiveOpenAIFetcher extends OpenAIFetcher { statusReporter: ICompletionsStatusReporter, telemetryData: TelemetryData, response: { status: number; text(): Promise; headers: IHeaders }, - copilotToken: CopilotToken + copilotToken: CopilotToken | undefined, + customModel?: ByokCompletionModel ): Promise { const text = await response.text(); + + if (customModel) { + // Custom (BYOK) endpoints have no Copilot-specific error semantics (quota, + // token refresh, proxy/firewall detection). Handle the common cases directly — + // including 402, which for OpenAI-compatible providers signals billing/quota + // issues rather than the Copilot free-tier quota handled below. + if (response.status === 401 || response.status === 403) { + const message = `Custom completions endpoint rejected the API key (${response.status}). Check the apiKey configured for "${customModel.groupName}" in chatLanguageModels.json.`; + statusReporter.setError(message); + telemetryData.properties.error = message; + telemetryData.properties.status = String(response.status); + return { type: 'failed', reason: message }; + } + if (response.status === 402) { + // 402 Payment Required: OpenAI-compatible providers (e.g. DeepSeek, + // SiliconFlow) return this when the provider account has billing or quota + // problems (typically insufficient balance). This is NOT the Copilot + // free-tier quota, so do not apply the quota-exhausted state or command. + const message = `Custom completions endpoint returned 402 (Payment Required). Check the billing/quota status of the provider account for "${customModel.groupName}". Response: ${text}`; + statusReporter.setWarning(message); + logger.warn(this.logTargetService, `Custom completions (${customModel.id}) request failed: ${message}`); + telemetryData.properties.error = message; + telemetryData.properties.status = String(response.status); + return { type: 'failed', reason: message }; + } + if (response.status === 404) { + const message = `Custom completions endpoint returned 404 for <${customModel.completionsUrl}>. Check the completionsUrl configured for "${customModel.groupName}" in chatLanguageModels.json.`; + statusReporter.setWarning(message); + telemetryData.properties.error = message; + telemetryData.properties.status = String(response.status); + return { type: 'failed', reason: message }; + } + if (response.status === 429) { + const rateLimitSeconds = 10; + setTimeout(() => { + this.#disabledReason = undefined; + }, rateLimitSeconds * 1000); + this.#disabledReason = 'rate limited'; + const message = 'Custom completions endpoint rate limited. Denying completions for 10 seconds.'; + statusReporter.setWarning(message); + logger.warn(this.logTargetService, message); + return { type: 'failed', reason: this.#disabledReason }; + } + const message = `Custom completions endpoint for "${customModel.id}" (${customModel.groupName}) returned ${response.status}: ${text}`; + statusReporter.setWarning(message); + logger.warn(this.logTargetService, `Custom completions (${customModel.id}) request failed: ${message}`); + telemetryData.properties.error = message; + telemetryData.properties.status = String(response.status); + this.instantiationService.invokeFunction(telemetry, 'request.shownWarning', telemetryData); + return { type: 'failed', reason: `unhandled status from server: ${response.status} ${text}` }; + } + if (response.status === 402) { this.#disabledReason = 'monthly free code completions exhausted'; const message = 'Completions limit reached'; diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/model.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/model.ts index 406d5d5d0c0c35..2950f69ef32afb 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/model.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/model.ts @@ -6,6 +6,7 @@ import { IAuthenticationService } from '../../../../../../platform/authentication/common/authentication'; import { ICompletionModelInformation, IEndpointProvider } from '../../../../../../platform/endpoint/common/endpointProvider'; import { createServiceIdentifier } from '../../../../../../util/common/services'; +import { ByokCompletionModel, getByokCompletionModels, getByokCompletionModelById, onDidChangeByokCompletionModels } from '../../../../../byok/common/byokCompletionModels'; import { Disposable } from '../../../../../../util/vs/base/common/lifecycle'; import { IInstantiationService } from '../../../../../../util/vs/platform/instantiation/common/instantiation'; import { getUserSelectedModelConfiguration } from '../../../extension/src/modelPickerUserSelection'; @@ -23,9 +24,12 @@ export interface ICompletionsModelManagerService { readonly _serviceBrand: undefined; readonly onDidChangeModels: Event; getGenericCompletionModels(): ModelItem[]; + getCustomCompletionModels(): ModelItem[]; getDefaultModelId(): string; getTokenizerForModel(modelId: string): TokenizerName; getCurrentModelRequestInfo(featureSettings?: TelemetryWithExp): ModelRequestInfo; + /** Refreshes the custom BYOK (OpenAI-compatible) completion models from the shared store. */ + refreshByokModels(): void; } const FallbackModelId = 'gpt-41-copilot'; @@ -33,6 +37,8 @@ export class AvailableModelsManager extends Disposable implements ICompletionsMo declare _serviceBrand: undefined; fetchedModelData: ICompletionModelInformation[] = []; customModels: string[] = []; + /** Custom BYOK (OpenAI-compatible) completion models from chatLanguageModels.json. */ + byokModels: ByokCompletionModel[] = []; editorPreviewFeaturesDisabled: boolean = false; private readonly _onDidChangeModels = this._register(new Emitter()); readonly onDidChangeModels = this._onDidChangeModels.event; @@ -50,6 +56,9 @@ export class AvailableModelsManager extends Disposable implements ICompletionsMo if (shouldFetch) { this._register(onCopilotToken(authenticationService, () => this.refreshAvailableModels())); } + // BYOK completion models come from the language models service (chatLanguageModels.json) + // and can be used fully offline, without a Copilot token. + this._register(onDidChangeByokCompletionModels(() => this.refreshByokModels())); } // This will get its initial call after the initial token got fetched @@ -97,6 +106,31 @@ export class AvailableModelsManager extends Disposable implements ICompletionsMo return AvailableModelsManager.mapCompletionModels(filteredResult); } + /** Refreshes the custom BYOK completion models from the shared store. */ + refreshByokModels(): void { + const models = getByokCompletionModels(); + if (JSON.stringify(models) !== JSON.stringify(this.byokModels)) { + this.byokModels = [...models]; + this._onDidChangeModels.fire(); + } + } + + /** + * Returns the custom BYOK (OpenAI-compatible) completion models configured via + * chatLanguageModels.json. These work fully offline and are identified by the + * model id (or `${group}/${id}` when ambiguous) in `github.copilot.selectedCompletionModel`. + */ + getCustomCompletionModels(): ModelItem[] { + return this.byokModels.map(model => ({ + modelId: model.id, + label: model.label, + preview: false, + tokenizer: TokenizerName.o200k, + custom: true, + customGroup: model.groupName, + })); + } + getTokenizerForModel(modelId: string): TokenizerName { const modelItems = this.getGenericCompletionModels(); const modelItem = modelItems.find(item => item.modelId === modelId); @@ -135,6 +169,12 @@ export class AvailableModelsManager extends Disposable implements ICompletionsMo const defaultModelId = this.getDefaultModelId(); let userSelectedCompletionModel = this._instantiationService.invokeFunction(getUserSelectedModelConfiguration); if (userSelectedCompletionModel) { + // A custom BYOK (OpenAI-compatible) completion model is always valid, even + // when the CAPI model list is empty (e.g. signed out / fully offline). + const customModel = getByokCompletionModelById(userSelectedCompletionModel); + if (customModel) { + return new ModelRequestInfo(userSelectedCompletionModel, 'modelpicker', customModel); + } const genericModels = this.getGenericCompletionModels().map(model => model.modelId); if (!genericModels.includes(userSelectedCompletionModel)) { if (genericModels.length > 0) { @@ -187,6 +227,10 @@ export interface ModelItem { label: string; preview: boolean; tokenizer: string; + /** Whether this is a custom BYOK (OpenAI-compatible) completion model. */ + custom?: boolean; + /** The chatLanguageModels.json group name for custom BYOK models. */ + customGroup?: string; } export type ModelChoiceSourceTelemetryValue = @@ -200,7 +244,9 @@ export type ModelChoiceSourceTelemetryValue = class ModelRequestInfo { constructor( readonly modelId: string, - readonly modelChoiceSource: ModelChoiceSourceTelemetryValue + readonly modelChoiceSource: ModelChoiceSourceTelemetryValue, + /** Set when the selected model is a custom BYOK (OpenAI-compatible) completion model. */ + readonly customModel?: ByokCompletionModel ) { } get headers(): CompletionHeaders { diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/test/fetch.test.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/test/fetch.test.ts index 165549d205ce2b..f2f9a1f9fe34e4 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/test/fetch.test.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/test/fetch.test.ts @@ -159,6 +159,121 @@ suite('"Fetch" unit tests', function () { assert.ok(resetSpy.calledOnce, 'resetToken should have been called once'); }); + test('sends BYOK completions to the custom endpoint with standard FIM fields only', async function () { + const recordingFetchService = new MockCompletionsFetchService(); + const serviceCollectionClone = serviceCollection.clone(); + serviceCollectionClone.define(ICompletionsFetchService, recordingFetchService); + const accessor = serviceCollectionClone.createTestingAccessor(); + + const openAIFetcher = accessor.get(IInstantiationService).createInstance(LiveOpenAIFetcher); + const params: CompletionParams = { + prompt: { + context: ['# Language: Python'], + prefix: 'prefix', + suffix: '\n return a + b', + isFimEnabled: true, + }, + languageId: 'python', + repoInfo: undefined, + engineModelId: 'custom-model', + count: 3, + uiKind: CopilotUiKind.GhostText, + postOptions: { n: 3, stop: ['\n\n\n'], code_annotations: false }, + ourRequestId: generateUuid(), + extra: { language: 'python' }, + customModel: { + id: 'custom-model', + label: 'Custom Model', + vendor: 'customendpoint', + groupName: 'Custom', + completionsUrl: 'https://custom.example.com/v1/completions', + apiKey: 'sk-test', + model: 'custom-model', + }, + }; + + await openAIFetcher.fetchAndStreamCompletions(params, TelemetryWithExp.createEmptyConfigForTesting(), () => undefined); + + // The URL is used verbatim and the user's API key is passed as the secret key. + assert.strictEqual(recordingFetchService.lastUrl, 'https://custom.example.com/v1/completions'); + assert.strictEqual(recordingFetchService.lastSecretKey, 'sk-test'); + assert.strictEqual(recordingFetchService.lastIsCustomEndpoint, true); + // No Copilot-specific headers. + assert.deepStrictEqual(recordingFetchService.lastHeaders, {}); + + const lastParams = recordingFetchService.lastParams; + assert.ok(lastParams); + assert.strictEqual(lastParams.prompt, 'prefix'); + assert.strictEqual(lastParams.suffix, '\n return a + b'); + assert.strictEqual(lastParams.model, 'custom-model'); + // `n` is forced to 1 for custom endpoints (most OpenAI-compatible FIM servers + // reject n > 1); `stop` (single-/multi-line mode) is kept verbatim. + assert.strictEqual(lastParams.n, 1); + assert.deepStrictEqual(lastParams.stop, ['\n\n\n']); + // Copilot-specific fields are stripped. (`nwo` is not part of ModelParams but + // would be serialized if present, so check it at runtime.) + assert.strictEqual(lastParams.extra, undefined); + assert.strictEqual((lastParams as unknown as { nwo?: unknown }).nwo, undefined); + assert.strictEqual(lastParams.code_annotations, undefined); + }); + + test('BYOK completions work without a Copilot token or an API key (offline)', async function () { + const recordingFetchService = new MockCompletionsFetchService(); + const serviceCollectionClone = serviceCollection.clone(); + serviceCollectionClone.define(ICompletionsFetchService, recordingFetchService); + const accessor = serviceCollectionClone.createTestingAccessor(); + + const openAIFetcher = accessor.get(IInstantiationService).createInstance(LiveOpenAIFetcher); + const params: CompletionParams = { + prompt: { prefix: 'prefix', suffix: '', isFimEnabled: false }, + languageId: '', + repoInfo: undefined, + engineModelId: 'local-model', + count: 1, + uiKind: CopilotUiKind.GhostText, + ourRequestId: generateUuid(), + extra: {}, + customModel: { + id: 'local-model', + label: 'Local Model', + vendor: 'customendpoint', + groupName: 'Local', + completionsUrl: 'http://localhost:11434/v1/completions', + model: 'local-model', + }, + }; + + await openAIFetcher.fetchAndStreamCompletions(params, TelemetryWithExp.createEmptyConfigForTesting(), () => undefined); + + assert.strictEqual(recordingFetchService.lastIsCustomEndpoint, true); + assert.strictEqual(recordingFetchService.lastSecretKey, ''); + assert.strictEqual(recordingFetchService.lastHeaders?.['Authorization'], undefined); + }); + + test('BYOK 401 does not reset the Copilot token and points at the apiKey', async function () { + const result = await assertResponseWithContext(accessor, 401, undefined, fakeCustomModel()); + + assert.ok(result.type === 'failed' && result.reason.includes('API key')); + assert.ok(resetSpy.notCalled, 'resetToken should not be called for custom endpoints'); + }); + + test('BYOK 404 points at the configured completionsUrl', async function () { + const result = await assertResponseWithContext(accessor, 404, undefined, fakeCustomModel()); + + assert.ok(result.type === 'failed' && result.reason.includes('https://custom.example.com/v1/completions')); + }); + + test('BYOK 402 points at the provider billing instead of the Copilot quota', async function () { + const result = await assertResponseWithContext(accessor, 402, undefined, fakeCustomModel()); + + // 402 from a custom endpoint is a provider billing/quota problem, not the + // Copilot free-tier quota: no quota-exhausted state, no quota command and + // no token reset. + assert.ok(result.type === 'failed' && result.reason.includes('402')); + assert.ok(result.type === 'failed' && result.reason.includes('billing/quota')); + assert.ok(resetSpy.notCalled, 'resetToken should not be called for custom endpoints'); + }); + test('HTTP `Too many requests` enforces rate limiting locally', async function () { const mockFetch = new MockCompletionsFetchService(); const serviceCollection = createLibTestingContext(); @@ -368,7 +483,7 @@ async function assertResponseWithStatus( return assertResponseWithContext(accessor, statusCode, headers); } -async function assertResponseWithContext(accessor: ServicesAccessor, statusCode: number, headers?: Record) { +async function assertResponseWithContext(accessor: ServicesAccessor, statusCode: number, headers?: Record, customModel?: CompletionParams['customModel']) { const fakeHeaders = new HeadersImpl({ 'x-github-request-id': '1', ...headers, @@ -394,7 +509,7 @@ async function assertResponseWithContext(accessor: ServicesAccessor, statusCode: return accessor.get(IInstantiationService).createInstance(LiveOpenAIFetcher); } })(); - const completionParams: CompletionParams = fakeCompletionParams(); + const completionParams: CompletionParams = fakeCompletionParams(customModel); const result = await fetcher.fetchAndStreamCompletions( completionParams, TelemetryWithExp.createEmptyConfigForTesting(), @@ -404,7 +519,19 @@ async function assertResponseWithContext(accessor: ServicesAccessor, statusCode: return result; } -function fakeCompletionParams(): CompletionParams { +function fakeCustomModel(): CompletionParams['customModel'] { + return { + id: 'custom-model', + label: 'Custom Model', + vendor: 'customendpoint', + groupName: 'Custom', + completionsUrl: 'https://custom.example.com/v1/completions', + apiKey: 'sk-test', + model: 'custom-model', + }; +} + +function fakeCompletionParams(customModel?: CompletionParams['customModel']): CompletionParams { return { prompt: { prefix: 'xxx', @@ -419,6 +546,7 @@ function fakeCompletionParams(): CompletionParams { uiKind: CopilotUiKind.GhostText, postOptions: {}, extra: {}, + ...(customModel ? { customModel } : {}), }; } @@ -428,15 +556,22 @@ class MockCompletionsFetchService implements ICompletionsFetchService { nextResult: Result | undefined; lastParams: Completions.ModelParams | undefined; lastHeaders: Record | undefined; + lastUrl: string | undefined; + lastSecretKey: string | undefined; + lastIsCustomEndpoint: boolean | undefined; async fetch( - _url: string, - _secretKey: string, + url: string, + secretKey: string, params: Completions.ModelParams, _requestId: string, _ct: CancellationToken, - headerOverrides?: Record + headerOverrides?: Record, + isCustomEndpoint?: boolean ): Promise> { + this.lastUrl = url; + this.lastSecretKey = secretKey; + this.lastIsCustomEndpoint = isCustomEndpoint; this.lastParams = params; this.lastHeaders = headerOverrides; if (this.nextResult) { diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/test/model.test.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/test/model.test.ts new file mode 100644 index 00000000000000..c4e02a492df58a --- /dev/null +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/test/model.test.ts @@ -0,0 +1,79 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { clearByokCompletionModelConfigs, updateByokCompletionModelConfig } from '../../../../../../byok/common/byokCompletionModels'; +import { ConfigKey, ICompletionsConfigProvider, InMemoryConfigProvider } from '../../config'; +import { createLibTestingContext } from '../../test/context'; +import { ICompletionsModelManagerService } from '../model'; + +suite('AvailableModelsManager BYOK models', function () { + + teardown(function () { + clearByokCompletionModelConfigs(); + }); + + test('honors a user selected custom BYOK model even when no CAPI models are available', function () { + const serviceCollection = createLibTestingContext(); + const accessor = serviceCollection.createTestingAccessor(); + (accessor.get(ICompletionsConfigProvider) as InMemoryConfigProvider).setConfig(ConfigKey.UserSelectedCompletionModel, 'custom-model'); + + updateByokCompletionModelConfig('customendpoint', 'Custom', { + apiKey: 'sk-test', + models: [ + { + id: 'custom-model', + name: 'Custom Model', + url: 'https://custom.example.com/v1/chat/completions', + completionsUrl: 'https://custom.example.com/v1/completions', + }, + ], + }); + + const manager = accessor.get(ICompletionsModelManagerService); + const info = manager.getCurrentModelRequestInfo(); + + assert.strictEqual(info.modelId, 'custom-model'); + assert.strictEqual(info.modelChoiceSource, 'modelpicker'); + assert.ok(info.customModel, 'customModel should be set'); + assert.strictEqual(info.customModel!.completionsUrl, 'https://custom.example.com/v1/completions'); + assert.strictEqual(info.customModel!.apiKey, 'sk-test'); + }); + + test('surfaces custom BYOK models in the model picker list', function () { + const serviceCollection = createLibTestingContext(); + const accessor = serviceCollection.createTestingAccessor(); + + updateByokCompletionModelConfig('customendpoint', 'Custom', { + models: [ + { + id: 'custom-model', + name: 'Custom Model', + url: 'https://custom.example.com/v1/chat/completions', + completionsUrl: 'https://custom.example.com/v1/completions', + }, + ], + }); + + const manager = accessor.get(ICompletionsModelManagerService); + const customModels = manager.getCustomCompletionModels(); + + assert.strictEqual(customModels.length, 1); + assert.strictEqual(customModels[0].modelId, 'custom-model'); + assert.strictEqual(customModels[0].custom, true); + assert.strictEqual(customModels[0].customGroup, 'Custom'); + }); + + test('still falls back to the default model for unknown model ids', function () { + const serviceCollection = createLibTestingContext(); + const accessor = serviceCollection.createTestingAccessor(); + (accessor.get(ICompletionsConfigProvider) as InMemoryConfigProvider).setConfig(ConfigKey.UserSelectedCompletionModel, 'not-a-real-model'); + + const manager = accessor.get(ICompletionsModelManagerService); + const info = manager.getCurrentModelRequestInfo(); + + assert.strictEqual(info.customModel, undefined); + }); +}); diff --git a/extensions/copilot/src/extension/completions/vscode-node/completionsCoreContribution.ts b/extensions/copilot/src/extension/completions/vscode-node/completionsCoreContribution.ts index 1931e4816d2ab5..45a390ae5af35f 100644 --- a/extensions/copilot/src/extension/completions/vscode-node/completionsCoreContribution.ts +++ b/extensions/copilot/src/extension/completions/vscode-node/completionsCoreContribution.ts @@ -9,7 +9,8 @@ import { ConfigKey, IConfigurationService } from '../../../platform/configuratio import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { autorun, observableFromEvent } from '../../../util/vs/base/common/observableInternal'; -import { registerUnificationCommands } from '../../completions-core/vscode-node/completionsServiceBridges'; +import { getByokCompletionModels, onDidChangeByokCompletionModels } from '../../byok/common/byokCompletionModels'; +import { registerUnificationCommands, setupByokCompletionModels } from '../../completions-core/vscode-node/completionsServiceBridges'; import { ICopilotInlineCompletionItemProviderService } from '../common/copilotInlineCompletionItemProviderService'; import { unificationStateObservable } from './completionsUnificationContribution'; @@ -17,6 +18,13 @@ export class CompletionsCoreContribution extends Disposable { private readonly _copilotToken = observableFromEvent(this, this.authenticationService.onDidCopilotTokenChange, () => this.authenticationService.copilotToken); + /** Whether custom BYOK (OpenAI-compatible) completion models are configured — these work fully offline. */ + private readonly _hasCustomCompletionModels = observableFromEvent( + this, + listener => onDidChangeByokCompletionModels(listener), + () => getByokCompletionModels().length > 0 + ); + constructor( @ICopilotInlineCompletionItemProviderService _copilotInlineCompletionItemProviderService: ICopilotInlineCompletionItemProviderService, @IConfigurationService configurationService: IConfigurationService, @@ -25,6 +33,12 @@ export class CompletionsCoreContribution extends Disposable { ) { super(); + // Bridge BYOK (chatLanguageModels.json) models into the completions pipeline early, + // before the inline completion provider is registered (signed-out/offline scenarios + // never produce a Copilot token, yet must still serve custom completions). + const completionsInstaService = _copilotInlineCompletionItemProviderService.getOrCreateInstantiationService(); + this._register(completionsInstaService.invokeFunction(setupByokCompletionModels)); + const unificationState = unificationStateObservable(this); this._register(autorun(reader => { @@ -32,13 +46,15 @@ export class CompletionsCoreContribution extends Disposable { const configEnabled = configurationService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsEnableGhCompletionsProvider, experimentationService).read(reader); const extensionUnification = unificationStateValue?.extensionUnification ?? false; const copilotToken = this._copilotToken.read(reader); + const hasCustomModels = this._hasCustomCompletionModels.read(reader); let hasInstantiatedProvider = false; // Completions require a Copilot token to call the completions endpoint, so don't // register the provider in air-gapped / signed-out scenarios — it would just fail - // with GitHubLoginFailedError on every keystroke. - const wantsProvider = unificationStateValue?.codeUnification || extensionUnification || configEnabled || copilotToken?.isNoAuthUser; - if (wantsProvider && copilotToken) { + // with GitHubLoginFailedError on every keystroke. Custom BYOK (OpenAI-compatible) + // completion models are an exception: they work fully offline, without a token. + const wantsProvider = unificationStateValue?.codeUnification || extensionUnification || configEnabled || copilotToken?.isNoAuthUser || hasCustomModels; + if (wantsProvider && (copilotToken || hasCustomModels)) { const provider = _copilotInlineCompletionItemProviderService.getOrCreateProvider(); reader.store.add( languages.registerInlineCompletionItemProvider( diff --git a/extensions/copilot/src/platform/nesFetch/common/completionsFetchService.ts b/extensions/copilot/src/platform/nesFetch/common/completionsFetchService.ts index ae0cba67f51c6d..d4e77c37c326a7 100644 --- a/extensions/copilot/src/platform/nesFetch/common/completionsFetchService.ts +++ b/extensions/copilot/src/platform/nesFetch/common/completionsFetchService.ts @@ -92,7 +92,12 @@ export interface ICompletionsFetchService { params: Completions.ModelParams, requestId: string, ct: CancellationToken, - headerOverrides?: Record + headerOverrides?: Record, + /** + * When true the request goes to a custom (BYOK) OpenAI-compatible FIM endpoint: + * Copilot-specific headers are omitted and no Copilot token semantics apply. + */ + isCustomEndpoint?: boolean ): Promise>; disconnectAll(): Promise; diff --git a/extensions/copilot/src/platform/nesFetch/node/completionsFetchServiceImpl.ts b/extensions/copilot/src/platform/nesFetch/node/completionsFetchServiceImpl.ts index 7ca4393d346a05..8b7077703f9405 100644 --- a/extensions/copilot/src/platform/nesFetch/node/completionsFetchServiceImpl.ts +++ b/extensions/copilot/src/platform/nesFetch/node/completionsFetchServiceImpl.ts @@ -51,6 +51,7 @@ export class CompletionsFetchService implements ICompletionsFetchService { requestId: string, ct: CancellationToken, headerOverrides?: Record, + isCustomEndpoint?: boolean, ): Promise> { const startTimeMs = Date.now(); @@ -62,14 +63,14 @@ export class CompletionsFetchService implements ICompletionsFetchService { const options = { requestId, - headers: this.getHeaders(requestId, secretKey, headerOverrides), + headers: this.getHeaders(requestId, secretKey, headerOverrides, isCustomEndpoint), body: JSON.stringify({ ...params, stream: true, }) }; - const fetchResponse = await this._fetchFromUrl(url, options, ct); + const fetchResponse = await this._fetchFromUrl(url, options, ct, isCustomEndpoint); if (fetchResponse.isError()) { this._logCompletionsRequest(url, params, requestId, startTimeMs, fetchResponse); @@ -101,7 +102,7 @@ export class CompletionsFetchService implements ICompletionsFetchService { } } - protected async _fetchFromUrl(url: string, options: Completions.Internal.FetchOptions, ct: CancellationToken): Promise> { + protected async _fetchFromUrl(url: string, options: Completions.Internal.FetchOptions, ct: CancellationToken, isCustomEndpoint?: boolean): Promise> { const fetchAbortCtl = this.fetcherService.makeAbortController(); @@ -121,12 +122,14 @@ export class CompletionsFetchService implements ICompletionsFetchService { const response = await this.fetcherService.fetch(url, request); - if (response.status === 200 && this.authService.copilotToken?.isFreeUser && this.authService.copilotToken?.isChatQuotaExceeded) { + // Free-tier quota and 402 handling only apply to the Copilot proxy; custom + // (BYOK) endpoints have no Copilot token semantics. + if (!isCustomEndpoint && response.status === 200 && this.authService.copilotToken?.isFreeUser && this.authService.copilotToken?.isChatQuotaExceeded) { this.authService.resetCopilotToken(); } if (response.status !== 200) { - if (response.status === 402) { + if (!isCustomEndpoint && response.status === 402) { // When we receive a 402, we have exceed the free tier quota // This is stored on the token so let's refresh it if (!this.authService.copilotToken?.isCompletionsQuotaExceeded) { @@ -288,16 +291,22 @@ export class CompletionsFetchService implements ICompletionsFetchService { requestId: string, secretKey: string, headerOverrides: Record = {}, + isCustomEndpoint?: boolean, ): Record { const headers: Record = { 'Content-Type': 'application/json', - 'x-policy-id': 'nil', - Authorization: 'Bearer ' + secretKey, 'X-Request-Id': requestId, - 'X-GitHub-Api-Version': '2025-04-01', + // Copilot-specific headers are omitted for custom (BYOK) endpoints. + ...(isCustomEndpoint ? {} : { 'x-policy-id': 'nil', 'X-GitHub-Api-Version': '2025-04-01' }), ...headerOverrides, }; + // Custom endpoints without an API key (e.g. local servers like llama.cpp) + // send no Authorization header at all. + if (secretKey) { + headers['Authorization'] = 'Bearer ' + secretKey; + } + return headers; } } diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index db5b0006326ef8..1f74631dd8e0bc 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -184,7 +184,16 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { if (!data) { return []; } - const modelInformation: vscode.LanguageModelChatInformation[] = await data.provider.provideLanguageModelChatInformation({ silent: options.silent, configuration: options.configuration }, token) ?? []; + // `group` is not part of the public `PrepareLanguageModelChatModelOptions` + // type yet (it is forwarded at runtime so multiple groups per vendor can + // coexist); build the options as a variable to avoid excess-property errors + // from the object literal while keeping the runtime shape identical. + const chatModelOptions: vscode.PrepareLanguageModelChatModelOptions & Pick = { + silent: options.silent, + configuration: options.configuration, + group: options.group, + }; + const modelInformation: vscode.LanguageModelChatInformation[] = await data.provider.provideLanguageModelChatInformation(chatModelOptions, token) ?? []; const modelMetadataAndIdentifier: ILanguageModelChatMetadataAndIdentifier[] = modelInformation.map((m): ILanguageModelChatMetadataAndIdentifier => { let auth; if (m.requiresAuthorization && isProposedApiEnabled(data.extension, 'chatProvider')) { From ae330039e216367d50b60bbd694f1487dced1aa4 Mon Sep 17 00:00:00 2001 From: unbadfish <3066893506@qq.com> Date: Sat, 15 Aug 2026 16:44:54 +0800 Subject: [PATCH 2/8] [fix] Inline completions: initialize BYOK models in the joint provider The BYOK model sync lived in CompletionsCoreContribution, which is not instantiated when the joint completions provider is active - custom completion models were never resolved in the default configuration. - Add ByokCompletionBridgeContribution, registered unconditionally, to wire the BYOK model registry into the completions-core instantiation service. - Remove the bridge initialization from CompletionsCoreContribution. - Register completions in the joint provider when custom completion models exist, even without a Copilot token (offline BYOK). --- .../byokCompletionBridgeContribution.ts | 29 +++++++++++++++ .../completionsCoreContribution.ts | 8 +---- .../extension/vscode-node/contributions.ts | 4 +++ .../jointInlineCompletionProvider.ts | 35 +++++++++++++------ 4 files changed, 58 insertions(+), 18 deletions(-) create mode 100644 extensions/copilot/src/extension/completions/vscode-node/byokCompletionBridgeContribution.ts diff --git a/extensions/copilot/src/extension/completions/vscode-node/byokCompletionBridgeContribution.ts b/extensions/copilot/src/extension/completions/vscode-node/byokCompletionBridgeContribution.ts new file mode 100644 index 00000000000000..cd6d4fbf9e27bb --- /dev/null +++ b/extensions/copilot/src/extension/completions/vscode-node/byokCompletionBridgeContribution.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { IExtensionContribution } from '../../common/contributions'; +import { setupByokCompletionModels } from '../../completions-core/vscode-node/completionsServiceBridges'; +import { ICopilotInlineCompletionItemProviderService } from '../common/copilotInlineCompletionItemProviderService'; + +/** + * Unconditionally bridges `chatLanguageModels.json` custom (BYOK) models into the + * completions model manager. This must live outside `CompletionsCoreContribution`, + * because the joint completions provider (`JointCompletionsProviderContribution`) + * replaces that contribution entirely and would otherwise never populate the + * registry — breaking signed-out/offline completions against custom endpoints. + */ +export class ByokCompletionBridgeContribution extends Disposable implements IExtensionContribution { + + public readonly id: string = 'byok-completion-bridge'; + + constructor( + @ICopilotInlineCompletionItemProviderService copilotInlineCompletionItemProviderService: ICopilotInlineCompletionItemProviderService, + ) { + super(); + const completionsInstaService = copilotInlineCompletionItemProviderService.getOrCreateInstantiationService(); + this._register(completionsInstaService.invokeFunction(setupByokCompletionModels)); + } +} diff --git a/extensions/copilot/src/extension/completions/vscode-node/completionsCoreContribution.ts b/extensions/copilot/src/extension/completions/vscode-node/completionsCoreContribution.ts index 45a390ae5af35f..db32fc0b3322f0 100644 --- a/extensions/copilot/src/extension/completions/vscode-node/completionsCoreContribution.ts +++ b/extensions/copilot/src/extension/completions/vscode-node/completionsCoreContribution.ts @@ -10,7 +10,7 @@ import { IExperimentationService } from '../../../platform/telemetry/common/null import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { autorun, observableFromEvent } from '../../../util/vs/base/common/observableInternal'; import { getByokCompletionModels, onDidChangeByokCompletionModels } from '../../byok/common/byokCompletionModels'; -import { registerUnificationCommands, setupByokCompletionModels } from '../../completions-core/vscode-node/completionsServiceBridges'; +import { registerUnificationCommands } from '../../completions-core/vscode-node/completionsServiceBridges'; import { ICopilotInlineCompletionItemProviderService } from '../common/copilotInlineCompletionItemProviderService'; import { unificationStateObservable } from './completionsUnificationContribution'; @@ -33,12 +33,6 @@ export class CompletionsCoreContribution extends Disposable { ) { super(); - // Bridge BYOK (chatLanguageModels.json) models into the completions pipeline early, - // before the inline completion provider is registered (signed-out/offline scenarios - // never produce a Copilot token, yet must still serve custom completions). - const completionsInstaService = _copilotInlineCompletionItemProviderService.getOrCreateInstantiationService(); - this._register(completionsInstaService.invokeFunction(setupByokCompletionModels)); - const unificationState = unificationStateObservable(this); this._register(autorun(reader => { diff --git a/extensions/copilot/src/extension/extension/vscode-node/contributions.ts b/extensions/copilot/src/extension/extension/vscode-node/contributions.ts index 57d18656c6bbfa..4fca541f006952 100644 --- a/extensions/copilot/src/extension/extension/vscode-node/contributions.ts +++ b/extensions/copilot/src/extension/extension/vscode-node/contributions.ts @@ -14,6 +14,7 @@ import { SessionStoreTracker } from '../../chronicle/vscode-node/sessionStoreTra import * as sessionSyncContribution from '../../chronicle/vscode-node/sessionSync.contribution'; import * as chatBlockLanguageContribution from '../../codeBlocks/vscode-node/chatBlockLanguageFeatures.contribution'; import { IExtensionContributionFactory, asContributionFactory } from '../../common/contributions'; +import { ByokCompletionBridgeContribution } from '../../completions/vscode-node/byokCompletionBridgeContribution'; import { CompletionsUnificationContribution } from '../../completions/vscode-node/completionsUnificationContribution'; import { ConfigurationMigrationContribution } from '../../configuration/vscode-node/configurationMigration'; import { ContextKeysContribution } from '../../contextKeys/vscode-node/contextKeys.contribution'; @@ -84,6 +85,9 @@ export const vscodeNodeContributions: IExtensionContributionFactory[] = [ // replaced by JointCompletionsProviderContribution // asContributionFactory(InlineEditProviderFeatureContribution), // asContributionFactory(CompletionsCoreContribution), + // Bridges chatLanguageModels.json BYOK models into the completions pipeline + // regardless of which completions provider mode is active. + asContributionFactory(ByokCompletionBridgeContribution), asContributionFactory(SettingsSchemaFeature), asContributionFactory(WorkspaceRecorderFeature), asContributionFactory(SurveyCommandContribution), diff --git a/extensions/copilot/src/extension/inlineEdits/vscode-node/jointInlineCompletionProvider.ts b/extensions/copilot/src/extension/inlineEdits/vscode-node/jointInlineCompletionProvider.ts index af512805a007d0..5fb933d261e14d 100644 --- a/extensions/copilot/src/extension/inlineEdits/vscode-node/jointInlineCompletionProvider.ts +++ b/extensions/copilot/src/extension/inlineEdits/vscode-node/jointInlineCompletionProvider.ts @@ -32,6 +32,7 @@ import { Range } from '../../../util/vs/editor/common/core/range'; import { StringText } from '../../../util/vs/editor/common/core/text/abstractText'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { IExtensionContribution } from '../../common/contributions'; +import { getByokCompletionModels, onDidChangeByokCompletionModels } from '../../byok/common/byokCompletionModels'; import { registerUnificationCommands } from '../../completions-core/vscode-node/completionsServiceBridges'; import { GhostTextCompletionItem, GhostTextCompletionList } from '../../completions-core/vscode-node/extension/src/ghostText/ghostTextProvider'; import { CopilotInlineCompletionItemProvider } from '../../completions-core/vscode-node/extension/src/vscodeInlineCompletionItemProvider'; @@ -66,6 +67,13 @@ export class JointCompletionsProviderContribution extends Disposable implements private readonly _excludedProviders = this._configurationService.getExperimentBasedConfigObservable(ConfigKey.TeamInternal.InlineEditsExcludedProviders, this._expService).map(v => v ? v.split(',').map(v => v.trim()).filter(v => v !== '') : []); private readonly _copilotToken = observableFromEvent(this, this._authenticationService.onDidCopilotTokenChange, () => this._authenticationService.copilotToken); + /** Whether custom BYOK (OpenAI-compatible) completion models are configured — these work fully offline. */ + private readonly _hasCustomCompletionModels = observableFromEvent( + this, + listener => onDidChangeByokCompletionModels(listener), + () => getByokCompletionModels().length > 0 + ); + public readonly inlineEditsEnabled = derived(this, (reader) => { const copilotToken = this._copilotToken.read(reader); if (copilotToken === undefined) { @@ -212,19 +220,24 @@ export class JointCompletionsProviderContribution extends Disposable implements // @ulugbekna: note that we don't want it if modelUnification is on const modelUnification = unificationStateValue?.modelUnification ?? false; - if ( - (!modelUnification || unificationStateValue?.codeUnification || extensionUnification || configEnabled || this._copilotToken.read(reader)?.isNoAuthUser) && - !isExcluded - ) { - completionsProvider = this._copilotInlineCompletionItemProviderService.getOrCreateProvider() as CopilotInlineCompletionItemProvider; - } + // Custom BYOK (OpenAI-compatible) completion models work fully offline: + // register the completions provider without a Copilot token when any + // are configured (same semantics as CompletionsCoreContribution). + const hasCustomModels = this._hasCustomCompletionModels.read(reader); + if ( + (!modelUnification || unificationStateValue?.codeUnification || extensionUnification || configEnabled || this._copilotToken.read(reader)?.isNoAuthUser || hasCustomModels) && + (this._copilotToken.read(reader) || hasCustomModels) && + !isExcluded + ) { + completionsProvider = this._copilotInlineCompletionItemProviderService.getOrCreateProvider() as CopilotInlineCompletionItemProvider; + } - void vscode.commands.executeCommand('setContext', 'github.copilot.extensionUnification.activated', extensionUnification); + void vscode.commands.executeCommand('setContext', 'github.copilot.extensionUnification.activated', extensionUnification); - if (extensionUnification && completionsProvider) { - const completionsInstaService = this._copilotInlineCompletionItemProviderService.getOrCreateInstantiationService(); - reader.store.add(completionsInstaService.invokeFunction(registerUnificationCommands)); - } + if (extensionUnification && completionsProvider) { + const completionsInstaService = this._copilotInlineCompletionItemProviderService.getOrCreateInstantiationService(); + reader.store.add(completionsInstaService.invokeFunction(registerUnificationCommands)); + } } const singularProvider = reader.store.add(this._instantiationService.createInstance(JointCompletionsProvider, completionsProvider, inlineEditProvider)); From da15ee797169031975fc9a16f9a26bda2b92f20e Mon Sep 17 00:00:00 2001 From: unbadfish <3066893506@qq.com> Date: Sat, 15 Aug 2026 16:45:06 +0800 Subject: [PATCH 3/8] [feat] Inline completions: forward sanitized custom request headers The completions fetcher silently dropped user-configured request headers, breaking endpoints behind APIM gateways or vanity domains. - Extract OpenAIEndpoint's custom header sanitizer into a shared sanitizeCustomRequestHeaders helper in byok/common. - Reuse it in the BYOK completion fetcher so custom request headers (e.g. x-api-key) are forwarded with the same injection protections as chat, minus authentication and transport headers owned by the fetch service. - Extend the reserved set with completions-specific headers (openai-organization, x-policy-id, x-copilot-async, x-copilot-speculative). --- .../byok/common/sanitizeCustomHeaders.ts | 190 ++++++++++++++++++ .../src/extension/byok/node/openAIEndpoint.ts | 162 +-------------- .../vscode-node/lib/src/openai/fetch.ts | 24 ++- .../lib/src/openai/test/fetch.test.ts | 44 ++++ 4 files changed, 263 insertions(+), 157 deletions(-) create mode 100644 extensions/copilot/src/extension/byok/common/sanitizeCustomHeaders.ts diff --git a/extensions/copilot/src/extension/byok/common/sanitizeCustomHeaders.ts b/extensions/copilot/src/extension/byok/common/sanitizeCustomHeaders.ts new file mode 100644 index 00000000000000..2c8fcfcb79b82a --- /dev/null +++ b/extensions/copilot/src/extension/byok/common/sanitizeCustomHeaders.ts @@ -0,0 +1,190 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Shared sanitizer for user-configured `requestHeaders` on BYOK endpoints. + * + * Both the chat endpoint (`OpenAIEndpoint`) and the inline completion fetcher + * (`LiveOpenAIFetcher`) forward user-configured headers to OpenAI-compatible + * endpoints. Headers that collide with authentication, transport-level or + * Copilot-internal headers, forbidden browser headers, or header-injection + * attempts are dropped here so both pipelines apply the same rules. + */ +export interface SanitizeCustomRequestHeadersOptions { + /** + * Decides whether a lowercased header name must be rejected. Defaults to + * {@link DEFAULT_FORBIDDEN_CUSTOM_HEADERS} plus + * {@link SanitizeCustomRequestHeadersOptions.extraForbiddenHeaders}. + */ + readonly isReservedHeader?: (lowerKey: string) => boolean; + /** Extra forbidden header names (lowercase), on top of the default reserved set. */ + readonly extraForbiddenHeaders?: ReadonlySet; + /** Model identifier used in warning messages. */ + readonly modelId?: string; + /** Prefix prepended to each warning message (e.g. '[OpenAIEndpoint] '). */ + readonly logPrefix?: string; + /** Receives one warning message per skipped header / exceeded limit. */ + readonly onWarning?: (message: string) => void; +} + +// Reserved headers that cannot be overridden for security and functionality reasons +// Including forbidden request headers: https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header +export const DEFAULT_FORBIDDEN_CUSTOM_HEADERS: ReadonlySet = new Set([ + // Forbidden Request Headers + 'accept-charset', + 'accept-encoding', + 'access-control-request-headers', + 'access-control-request-method', + 'connection', + 'content-length', + 'cookie', + 'date', + 'dnt', + 'expect', + 'host', + 'keep-alive', + 'origin', + 'permissions-policy', + 'referer', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'user-agent', + 'via', + // Forwarding & Routing + 'forwarded', + 'x-forwarded-for', + 'x-forwarded-host', + 'x-forwarded-proto', + // Others + 'api-key', + 'authorization', + 'content-type', + 'openai-intent', + 'x-github-api-version', + 'x-initiator', + 'x-interaction-id', + 'x-interaction-type', + 'x-onbehalf-extension-id', + 'x-request-id', + 'x-vscode-user-agent-library-version', + // Pattern-based forbidden headers are checked separately: + // - 'proxy-*' headers (handled in sanitization logic) + // - 'sec-*' headers (handled in sanitization logic) + // - 'x-http-method*' with forbidden methods CONNECT, TRACE, TRACK (handled in sanitization logic) +]); + +// RFC 7230 compliant header name pattern: token characters only +const VALID_HEADER_NAME_PATTERN = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/; + +// Maximum limits to prevent abuse +const MAX_HEADER_NAME_LENGTH = 256; +const MAX_HEADER_VALUE_LENGTH = 8192; +const MAX_CUSTOM_HEADER_COUNT = 20; + +function sanitizeHeaderValue(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + + if (trimmed.length > MAX_HEADER_VALUE_LENGTH) { + return undefined; + } + + // Disallow control characters including CR, LF, and others (0x00-0x1F, 0x7F) + // This prevents HTTP header injection and response splitting attacks + if (/[\x00-\x1F\x7F]/.test(trimmed)) { + return undefined; + } + + // Additional check for potential Unicode issues + // Reject headers with bidirectional override characters or zero-width characters + if (/[\u200B-\u200D\u202A-\u202E\uFEFF]/.test(trimmed)) { + return undefined; + } + + return trimmed; +} + +export function sanitizeCustomRequestHeaders( + headers: Readonly> | undefined, + options?: SanitizeCustomRequestHeadersOptions +): Record { + if (!headers) { + return {}; + } + + const modelId = options?.modelId; + const warn = (suffix: string) => options?.onWarning?.(`${options?.logPrefix ?? ''}${modelId ? `Model '${modelId}' ` : ''}${suffix}`); + const isReservedHeader = options?.isReservedHeader + ?? ((lowerKey: string) => DEFAULT_FORBIDDEN_CUSTOM_HEADERS.has(lowerKey) || (options?.extraForbiddenHeaders?.has(lowerKey) ?? false)); + + const entries = Object.entries(headers); + + if (entries.length > MAX_CUSTOM_HEADER_COUNT) { + warn(`has ${entries.length} custom headers, exceeding limit of ${MAX_CUSTOM_HEADER_COUNT}. Only first ${MAX_CUSTOM_HEADER_COUNT} will be processed.`); + } + + const sanitized: Record = {}; + let processedCount = 0; + + for (const [rawKey, rawValue] of entries) { + if (processedCount >= MAX_CUSTOM_HEADER_COUNT) { + break; + } + + const key = rawKey.trim(); + if (!key) { + warn('has empty header name, skipping.'); + continue; + } + + if (key.length > MAX_HEADER_NAME_LENGTH) { + warn(`has header name exceeding ${MAX_HEADER_NAME_LENGTH} characters, skipping.`); + continue; + } + + if (!VALID_HEADER_NAME_PATTERN.test(key)) { + warn(`has invalid header name format: '${key}', Skipping.`); + continue; + } + + const lowerKey = key.toLowerCase(); + if (isReservedHeader(lowerKey)) { + warn(`attempted to override reserved header '${key}', skipping.`); + continue; + } + + // Check for pattern-based forbidden headers + if (lowerKey.startsWith('proxy-') || lowerKey.startsWith('sec-')) { + warn(`attempted to set forbidden header pattern '${key}', skipping.`); + continue; + } + + // Check for X-HTTP-Method* headers with forbidden methods + if (lowerKey === 'x-http-method' || lowerKey === 'x-http-method-override' || lowerKey === 'x-method-override') { + const forbiddenMethods = ['connect', 'trace', 'track']; + const methodValue = String(rawValue).toLowerCase().trim(); + if (forbiddenMethods.includes(methodValue)) { + warn(`attempted to set forbidden method '${methodValue}' in header '${key}', skipping.`); + continue; + } + } + + const sanitizedValue = sanitizeHeaderValue(rawValue); + if (sanitizedValue === undefined) { + warn(`has invalid value for header '${key}': '${rawValue}', skipping.`); + continue; + } + + sanitized[key] = sanitizedValue; + processedCount++; + } + + return sanitized; +} diff --git a/extensions/copilot/src/extension/byok/node/openAIEndpoint.ts b/extensions/copilot/src/extension/byok/node/openAIEndpoint.ts index 7193675b8be904..9789800004fdf1 100644 --- a/extensions/copilot/src/extension/byok/node/openAIEndpoint.ts +++ b/extensions/copilot/src/extension/byok/node/openAIEndpoint.ts @@ -18,6 +18,7 @@ import { IChatWebSocketManager } from '../../../platform/networking/node/chatWeb import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; import { ITokenizerProvider } from '../../../platform/tokenizer/node/tokenizer'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; +import { DEFAULT_FORBIDDEN_CUSTOM_HEADERS, sanitizeCustomRequestHeaders } from '../common/sanitizeCustomHeaders'; function hydrateBYOKErrorMessages(response: ChatResponse): ChatResponse { if (response.type === ChatFetchResponseType.Failed && response.streamError) { @@ -55,62 +56,6 @@ export function isBYOKModel(endpoint: IChatEndpoint | undefined): number { } export class OpenAIEndpoint extends ChatEndpoint { - // Reserved headers that cannot be overridden for security and functionality reasons - // Including forbidden request headers: https://developer.mozilla.org/en-US/docs/Glossary/Forbidden_request_header - private static readonly _reservedHeaders: ReadonlySet = new Set([ - // Forbidden Request Headers - 'accept-charset', - 'accept-encoding', - 'access-control-request-headers', - 'access-control-request-method', - 'connection', - 'content-length', - 'cookie', - 'date', - 'dnt', - 'expect', - 'host', - 'keep-alive', - 'origin', - 'permissions-policy', - 'referer', - 'te', - 'trailer', - 'transfer-encoding', - 'upgrade', - 'user-agent', - 'via', - // Forwarding & Routing - 'forwarded', - 'x-forwarded-for', - 'x-forwarded-host', - 'x-forwarded-proto', - // Others - 'api-key', - 'authorization', - 'content-type', - 'openai-intent', - 'x-github-api-version', - 'x-initiator', - 'x-interaction-id', - 'x-interaction-type', - 'x-onbehalf-extension-id', - 'x-request-id', - 'x-vscode-user-agent-library-version', - // Pattern-based forbidden headers are checked separately: - // - 'proxy-*' headers (handled in sanitization logic) - // - 'sec-*' headers (handled in sanitization logic) - // - 'x-http-method*' with forbidden methods CONNECT, TRACE, TRACK (handled in sanitization logic) - ]); - - // RFC 7230 compliant header name pattern: token characters only - private static readonly _validHeaderNamePattern = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/; - - // Maximum limits to prevent abuse - private static readonly _maxHeaderNameLength = 256; - private static readonly _maxHeaderValueLength = 8192; - private static readonly _maxCustomHeaderCount = 20; - protected readonly _customHeaders: Record; constructor( _modelMetadata: IChatModelInformation, @@ -136,7 +81,12 @@ export class OpenAIEndpoint extends ChatEndpoint { chatWebSocketService, logService ); - this._customHeaders = this._sanitizeCustomHeaders(_modelMetadata.requestHeaders); + this._customHeaders = sanitizeCustomRequestHeaders(_modelMetadata.requestHeaders, { + modelId: this.modelMetadata.id, + logPrefix: '[OpenAIEndpoint] ', + isReservedHeader: lowerKey => this._isReservedHeader(lowerKey), + onWarning: message => this.logService.warn(message), + }); } /** @@ -162,103 +112,7 @@ export class OpenAIEndpoint extends ChatEndpoint { } protected _isReservedHeader(lowerKey: string): boolean { - return OpenAIEndpoint._reservedHeaders.has(lowerKey); - } - - private _sanitizeCustomHeaders(headers: Readonly> | undefined): Record { - if (!headers) { - return {}; - } - - const entries = Object.entries(headers); - - if (entries.length > OpenAIEndpoint._maxCustomHeaderCount) { - this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has ${entries.length} custom headers, exceeding limit of ${OpenAIEndpoint._maxCustomHeaderCount}. Only first ${OpenAIEndpoint._maxCustomHeaderCount} will be processed.`); - } - - const sanitized: Record = {}; - let processedCount = 0; - - for (const [rawKey, rawValue] of entries) { - if (processedCount >= OpenAIEndpoint._maxCustomHeaderCount) { - break; - } - - const key = rawKey.trim(); - if (!key) { - this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has empty header name, skipping.`); - continue; - } - - if (key.length > OpenAIEndpoint._maxHeaderNameLength) { - this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has header name exceeding ${OpenAIEndpoint._maxHeaderNameLength} characters, skipping.`); - continue; - } - - if (!OpenAIEndpoint._validHeaderNamePattern.test(key)) { - this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has invalid header name format: '${key}', Skipping.`); - continue; - } - - const lowerKey = key.toLowerCase(); - if (this._isReservedHeader(lowerKey)) { - this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' attempted to override reserved header '${key}', skipping.`); - continue; - } - - // Check for pattern-based forbidden headers - if (lowerKey.startsWith('proxy-') || lowerKey.startsWith('sec-')) { - this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' attempted to set forbidden header pattern '${key}', skipping.`); - continue; - } - - // Check for X-HTTP-Method* headers with forbidden methods - if ((lowerKey === 'x-http-method' || lowerKey === 'x-http-method-override' || lowerKey === 'x-method-override')) { - const forbiddenMethods = ['connect', 'trace', 'track']; - const methodValue = String(rawValue).toLowerCase().trim(); - if (forbiddenMethods.includes(methodValue)) { - this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' attempted to set forbidden method '${methodValue}' in header '${key}', skipping.`); - continue; - } - } - - const sanitizedValue = this._sanitizeHeaderValue(rawValue); - if (sanitizedValue === undefined) { - this.logService.warn(`[OpenAIEndpoint] Model '${this.modelMetadata.id}' has invalid value for header '${key}': '${rawValue}', skipping.`); - continue; - } - - sanitized[key] = sanitizedValue; - processedCount++; - } - - return sanitized; - } - - private _sanitizeHeaderValue(value: unknown): string | undefined { - if (typeof value !== 'string') { - return undefined; - } - - const trimmed = value.trim(); - - if (trimmed.length > OpenAIEndpoint._maxHeaderValueLength) { - return undefined; - } - - // Disallow control characters including CR, LF, and others (0x00-0x1F, 0x7F) - // This prevents HTTP header injection and response splitting attacks - if (/[\x00-\x1F\x7F]/.test(trimmed)) { - return undefined; - } - - // Additional check for potential Unicode issues - // Reject headers with bidirectional override characters or zero-width characters - if (/[\u200B-\u200D\u202A-\u202E\uFEFF]/.test(trimmed)) { - return undefined; - } - - return trimmed; + return DEFAULT_FORBIDDEN_CUSTOM_HEADERS.has(lowerKey); } override createRequestBody(options: ICreateEndpointBodyOptions): IEndpointBody { diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/fetch.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/fetch.ts index 86a0acdb804204..4ee8097bdb3f69 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/fetch.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/fetch.ts @@ -8,6 +8,7 @@ import { CopilotAnnotations, StreamCopilotAnnotations } from '../../../../../../ import { IEnvService } from '../../../../../../platform/env/common/envService'; import { Completion } from '../../../../../../platform/nesFetch/common/completionsAPI'; import { ByokCompletionModel } from '../../../../../byok/common/byokCompletionModels'; +import { sanitizeCustomRequestHeaders } from '../../../../../byok/common/sanitizeCustomHeaders'; import { Completions, ICompletionsFetchService } from '../../../../../../platform/nesFetch/common/completionsFetchService'; import { ResponseStream } from '../../../../../../platform/nesFetch/common/responseStream'; import { RequestId, getRequestId } from '../../../../../../platform/networking/common/fetch'; @@ -166,6 +167,18 @@ function uiKindToIntent(uiKind: CopilotUiKind): string | undefined { } } +/** + * Copilot-proxy headers that only exist on the completions path and must never + * be forwarded from user configuration to a custom (BYOK) endpoint. They extend + * the shared reserved set used by {@link sanitizeCustomRequestHeaders}. + */ +const COMPLETIONS_FORBIDDEN_CUSTOM_HEADERS: ReadonlySet = new Set([ + 'openai-organization', + 'x-policy-id', + 'x-copilot-async', + 'x-copilot-speculative', +]); + // Request methods export interface CopilotError { @@ -465,9 +478,14 @@ export class LiveOpenAIFetcher extends OpenAIFetcher { let fullHeaders: Record; if (customModel) { - // Minimal headers for custom endpoints: Content-Type, X-Request-Id and - // Authorization (Bearer ) are added by the fetch service. - fullHeaders = {}; + // Content-Type, X-Request-Id and Authorization (Bearer ) are added + // by the fetch service. User-configured requestHeaders (e.g. x-api-key, + // APIM subscription keys) are sanitized and forwarded so custom auth + // schemes keep working. + fullHeaders = sanitizeCustomRequestHeaders(customModel.requestHeaders, { + modelId: customModel.id, + extraForbiddenHeaders: COMPLETIONS_FORBIDDEN_CUSTOM_HEADERS, + }); } else { fullHeaders = { ...headers, diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/test/fetch.test.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/test/fetch.test.ts index f2f9a1f9fe34e4..19b58870002e72 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/test/fetch.test.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/test/fetch.test.ts @@ -250,6 +250,50 @@ suite('"Fetch" unit tests', function () { assert.strictEqual(recordingFetchService.lastHeaders?.['Authorization'], undefined); }); + test('BYOK forwards sanitized custom requestHeaders', async function () { + const recordingFetchService = new MockCompletionsFetchService(); + const serviceCollectionClone = serviceCollection.clone(); + serviceCollectionClone.define(ICompletionsFetchService, recordingFetchService); + const accessor = serviceCollectionClone.createTestingAccessor(); + + const openAIFetcher = accessor.get(IInstantiationService).createInstance(LiveOpenAIFetcher); + const params: CompletionParams = { + prompt: { prefix: 'prefix', suffix: '', isFimEnabled: false }, + languageId: '', + repoInfo: undefined, + engineModelId: 'custom-model', + count: 1, + uiKind: CopilotUiKind.GhostText, + ourRequestId: generateUuid(), + extra: {}, + customModel: { + id: 'custom-model', + label: 'Custom Model', + vendor: 'customendpoint', + groupName: 'Custom', + completionsUrl: 'https://custom.example.com/v1/completions', + model: 'custom-model', + requestHeaders: { + // Explicitly configured custom auth headers are forwarded. + 'x-api-key': 'apim-subscription-key', + 'x-custom-tenant': 'tenant-1', + // Auth/content headers managed by the fetch service are stripped. + Authorization: 'Bearer user-managed', + 'Content-Type': 'text/plain', + 'X-Request-Id': 'user-set', + 'X-GitHub-Api-Version': '2099-01-01', + }, + }, + }; + + await openAIFetcher.fetchAndStreamCompletions(params, TelemetryWithExp.createEmptyConfigForTesting(), () => undefined); + + assert.deepStrictEqual(recordingFetchService.lastHeaders, { + 'x-api-key': 'apim-subscription-key', + 'x-custom-tenant': 'tenant-1', + }); + }); + test('BYOK 401 does not reset the Copilot token and points at the apiKey', async function () { const result = await assertResponseWithContext(accessor, 401, undefined, fakeCustomModel()); From 37f9097f8033a78f253273fcd5f7afb44501c493 Mon Sep 17 00:00:00 2001 From: unbadfish <3066893506@qq.com> Date: Sat, 15 Aug 2026 16:45:17 +0800 Subject: [PATCH 4/8] [fix] Inline completions: prefer Copilot models on id collision A custom model whose id collides with a Copilot model could hijack completion requests, leaking the BYOK apiKey to the Copilot proxy. - Resolve the request model from the Copilot model list first and fall back to custom (BYOK) models only when no Copilot model matches. - Qualify colliding custom models in the picker with a group/id id. --- .../vscode-node/lib/src/openai/model.ts | 39 ++++++++++------ .../lib/src/openai/test/model.test.ts | 46 ++++++++++++++++++- 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/model.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/model.ts index 2950f69ef32afb..a4af9722c1c9ea 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/model.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/model.ts @@ -121,14 +121,22 @@ export class AvailableModelsManager extends Disposable implements ICompletionsMo * model id (or `${group}/${id}` when ambiguous) in `github.copilot.selectedCompletionModel`. */ getCustomCompletionModels(): ModelItem[] { - return this.byokModels.map(model => ({ - modelId: model.id, - label: model.label, - preview: false, - tokenizer: TokenizerName.o200k, - custom: true, - customGroup: model.groupName, - })); + // A custom model whose id collides with a CAPI cloud completion model gets a + // `group/id` qualified id in the picker: the cloud entry keeps the bare id + // (resolved first in getCurrentModelRequestInfo) and the custom entry stays + // selectable without ambiguity. + const genericIds = new Set(this.getGenericCompletionModels().map(model => model.modelId)); + return this.byokModels.map(model => { + const collidesWithGeneric = !model.id.includes('/') && genericIds.has(model.id); + return { + modelId: collidesWithGeneric ? `${model.groupName}/${model.id}` : model.id, + label: model.label, + preview: false, + tokenizer: TokenizerName.o200k, + custom: true, + customGroup: model.groupName, + }; + }); } getTokenizerForModel(modelId: string): TokenizerName { @@ -169,14 +177,17 @@ export class AvailableModelsManager extends Disposable implements ICompletionsMo const defaultModelId = this.getDefaultModelId(); let userSelectedCompletionModel = this._instantiationService.invokeFunction(getUserSelectedModelConfiguration); if (userSelectedCompletionModel) { - // A custom BYOK (OpenAI-compatible) completion model is always valid, even - // when the CAPI model list is empty (e.g. signed out / fully offline). - const customModel = getByokCompletionModelById(userSelectedCompletionModel); - if (customModel) { - return new ModelRequestInfo(userSelectedCompletionModel, 'modelpicker', customModel); - } const genericModels = this.getGenericCompletionModels().map(model => model.modelId); + // Resolve against the CAPI cloud model list FIRST: a custom model whose id + // collides with a cloud completion model must never hijack the request or + // leak its API key to the custom endpoint. if (!genericModels.includes(userSelectedCompletionModel)) { + // A custom BYOK (OpenAI-compatible) completion model is valid even when + // the CAPI model list is empty (e.g. signed out / fully offline). + const customModel = getByokCompletionModelById(userSelectedCompletionModel); + if (customModel) { + return new ModelRequestInfo(userSelectedCompletionModel, 'modelpicker', customModel); + } if (genericModels.length > 0) { this._logService.logIt( LogLevel.INFO, diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/test/model.test.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/test/model.test.ts index c4e02a492df58a..6433bc7e0a02c7 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/test/model.test.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/openai/test/model.test.ts @@ -4,10 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; +import { ICompletionModelInformation } from '../../../../../../../platform/endpoint/common/endpointProvider'; import { clearByokCompletionModelConfigs, updateByokCompletionModelConfig } from '../../../../../../byok/common/byokCompletionModels'; import { ConfigKey, ICompletionsConfigProvider, InMemoryConfigProvider } from '../../config'; import { createLibTestingContext } from '../../test/context'; -import { ICompletionsModelManagerService } from '../model'; +import { AvailableModelsManager, ICompletionsModelManagerService } from '../model'; suite('AvailableModelsManager BYOK models', function () { @@ -76,4 +77,47 @@ suite('AvailableModelsManager BYOK models', function () { assert.strictEqual(info.customModel, undefined); }); + + test('a cloud model with the same id takes precedence over the custom model', function () { + const serviceCollection = createLibTestingContext(); + const accessor = serviceCollection.createTestingAccessor(); + (accessor.get(ICompletionsConfigProvider) as InMemoryConfigProvider).setConfig(ConfigKey.UserSelectedCompletionModel, 'colliding-model'); + + updateByokCompletionModelConfig('customendpoint', 'Custom', { + apiKey: 'sk-test', + models: [ + { + id: 'colliding-model', + name: 'Custom Model', + url: 'https://custom.example.com/v1/chat/completions', + completionsUrl: 'https://custom.example.com/v1/completions', + }, + ], + }); + + const manager = accessor.get(ICompletionsModelManagerService) as AvailableModelsManager; + manager.fetchedModelData = [{ + id: 'colliding-model', + vendor: 'copilot', + name: 'Cloud Model', + model_picker_enabled: true, + is_chat_default: false, + is_chat_fallback: false, + version: '1', + capabilities: { type: 'completion', family: 'cloud', tokenizer: 'o200k' }, + } as unknown as ICompletionModelInformation]; + + const info = manager.getCurrentModelRequestInfo(); + + // The cloud entry wins: the request goes to the Copilot proxy, not the + // custom endpoint, so no customModel (and no API key) is attached. + assert.strictEqual(info.modelId, 'colliding-model'); + assert.strictEqual(info.customModel, undefined); + + // The picker surfaces the custom entry under a qualified id instead of a + // duplicate bare id. + const customModels = manager.getCustomCompletionModels(); + assert.strictEqual(customModels.length, 1); + assert.strictEqual(customModels[0].modelId, 'Custom/colliding-model'); + }); }); From e0a75f67d367a005d174f94f032f9ff96362a967 Mon Sep 17 00:00:00 2001 From: unbadfish <3066893506@qq.com> Date: Sat, 15 Aug 2026 16:45:27 +0800 Subject: [PATCH 5/8] [fix] Inline completions: reconcile BYOK model registry on hot swap Groups deleted from chatLanguageModels.json while the extension runs previously lingered in the registry and the model picker. Treat a provider invocation without a group as the start of a resolution pass: drop all groups previously registered for that vendor, then re-add the groups that still exist. --- .../byok/common/byokCompletionModels.ts | 21 ++++- .../common/test/byokCompletionModels.spec.ts | 87 +++++++++++++++++++ 2 files changed, 105 insertions(+), 3 deletions(-) diff --git a/extensions/copilot/src/extension/byok/common/byokCompletionModels.ts b/extensions/copilot/src/extension/byok/common/byokCompletionModels.ts index 97b1504dc7132d..41704aa3ca6d42 100644 --- a/extensions/copilot/src/extension/byok/common/byokCompletionModels.ts +++ b/extensions/copilot/src/extension/byok/common/byokCompletionModels.ts @@ -54,14 +54,29 @@ export const onDidChangeByokCompletionModels: Event = _onDidChange.event; * point secrets such as `${input:...}` api keys are already decoded, so this is the * only place where the completion pipeline can obtain them. * - * Passing `undefined` configuration removes the group (e.g. group deleted in the file). + * `groupName === undefined` marks the start of a new resolution pass: the language + * models service invokes the provider once without a group before the per-group + * calls, so every group previously seen for this vendor is dropped and then + * re-added from the groups that still exist. This keeps the registry in sync when + * a group is deleted from the file while the extension is running (hot swap). */ export function updateByokCompletionModelConfig(vendor: string, groupName: string | undefined, configuration: IStringDictionary | undefined): void { - const key = `${vendor}/${groupName ?? ''}`; + if (groupName === undefined) { + // Start of a new resolution pass for this vendor: drop its stale groups so + // deleted groups do not linger in the registry and in the model picker. + for (const [key, config] of registeredGroupConfigs) { + if (config.vendor === vendor) { + registeredGroupConfigs.delete(key); + } + } + recomputeCompletionModels(); + return; + } + const key = `${vendor}/${groupName}`; if (!configuration) { registeredGroupConfigs.delete(key); } else { - registeredGroupConfigs.set(key, { vendor, groupName: groupName ?? '', configuration }); + registeredGroupConfigs.set(key, { vendor, groupName, configuration }); } recomputeCompletionModels(); } diff --git a/extensions/copilot/src/extension/byok/common/test/byokCompletionModels.spec.ts b/extensions/copilot/src/extension/byok/common/test/byokCompletionModels.spec.ts index b7258b23eb425a..cddfe209210c41 100644 --- a/extensions/copilot/src/extension/byok/common/test/byokCompletionModels.spec.ts +++ b/extensions/copilot/src/extension/byok/common/test/byokCompletionModels.spec.ts @@ -166,6 +166,93 @@ describe('byokCompletionModels', () => { expect(getByokCompletionModels()).toEqual([]); }); + it('reconciles groups per resolution pass (hot swap of chatLanguageModels.json)', () => { + // Pass 1: two groups exist. The language models service calls the provider + // once without a group before the per-group calls. + updateByokCompletionModelConfig('customendpoint', undefined, undefined); + updateByokCompletionModelConfig('customendpoint', 'A', { + completionsUrl: 'https://a.example.com/v1/completions', + models: [ + { + id: 'a-model', + name: 'Model A', + url: 'https://a.example.com/v1/chat/completions', + }, + ], + }); + updateByokCompletionModelConfig('customendpoint', 'B', { + completionsUrl: 'https://b.example.com/v1/completions', + models: [ + { + id: 'b-model', + name: 'Model B', + url: 'https://b.example.com/v1/chat/completions', + }, + ], + }); + expect(getByokCompletionModels()).toHaveLength(2); + + // Pass 2: group B was deleted from the file while the extension is running. + // The pass-start call drops every stale group for the vendor; only the + // groups that still exist are re-added. + updateByokCompletionModelConfig('customendpoint', undefined, undefined); + updateByokCompletionModelConfig('customendpoint', 'A', { + completionsUrl: 'https://a.example.com/v1/completions', + models: [ + { + id: 'a-model', + name: 'Model A', + url: 'https://a.example.com/v1/chat/completions', + }, + ], + }); + const models = getByokCompletionModels(); + expect(models).toHaveLength(1); + expect(models[0].groupName).toBe('A'); + expect(getByokCompletionModelById('b-model')).toBeUndefined(); + }); + + it('pass-start reconciliation only affects the given vendor', () => { + updateByokCompletionModelConfig('customendpoint', 'A', { + completionsUrl: 'https://a.example.com/v1/completions', + models: [ + { + id: 'a-model', + name: 'Model A', + url: 'https://a.example.com/v1/chat/completions', + }, + ], + }); + updateByokCompletionModelConfig('customoai', 'B', { + completionsUrl: 'https://b.example.com/v1/completions', + models: [ + { + id: 'b-model', + name: 'Model B', + url: 'https://b.example.com/v1/chat/completions', + }, + ], + }); + expect(getByokCompletionModels()).toHaveLength(2); + + // A resolution pass for customendpoint alone must not drop customoai groups. + updateByokCompletionModelConfig('customendpoint', undefined, undefined); + updateByokCompletionModelConfig('customendpoint', 'A', { + completionsUrl: 'https://a.example.com/v1/completions', + models: [ + { + id: 'a-model', + name: 'Model A', + url: 'https://a.example.com/v1/chat/completions', + }, + ], + }); + + const models = getByokCompletionModels(); + expect(models).toHaveLength(2); + expect(models.some(m => m.vendor === 'customoai' && m.model === 'b-model')).toBe(true); + }); + it('clears all models', () => { updateByokCompletionModelConfig('customendpoint', 'A', { completionsUrl: 'https://a.example.com/v1/completions', From 75babba8de2f06216c2b038fed4c8ddd4f8d7d6a Mon Sep 17 00:00:00 2001 From: unbadfish <3066893506@qq.com> Date: Sat, 15 Aug 2026 16:45:36 +0800 Subject: [PATCH 6/8] [feat] Inline completions: support completionsUrl in customoai groups Mirror the customendpoint group schema so customoai models can back inline completions as well. --- extensions/copilot/package.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 8f972c8ae123b9..089dc868784b8c 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1874,6 +1874,12 @@ "title": "API Key", "markdownDeprecationMessage": "**Deprecated.** Use the `customendpoint` provider (\"Custom Endpoint\") instead. It supports the Chat Completions API, the Responses API, and the Messages API — selectable per model via the `apiType` property." }, + "completionsUrl": { + "type": "string", + "pattern": "^https?://.+", + "patternErrorMessage": "URL must start with http:// or https://", + "markdownDescription": "Default full URL used **verbatim** for inline code completions (FIM) requests for models in this group, e.g. `https://api.deepseek.com/beta/completions` or `https://api.siliconflow.cn/v1/completions`. Individual models can override this with their own `completionsUrl` property. When neither is set, the group's models are only available for chat." + }, "models": { "type": "array", "markdownDeprecationMessage": "**Deprecated.** Use the `customendpoint` provider (\"Custom Endpoint\") instead. It supports the Chat Completions API, the Responses API, and the Messages API — selectable per model via the `apiType` property.", From 1ec285ff45e931cf116f6132feefcedfe44b589c Mon Sep 17 00:00:00 2001 From: unbadfish <3066893506@qq.com> Date: Sat, 15 Aug 2026 16:53:18 +0800 Subject: [PATCH 7/8] [docs] Inline completions: clarify completionsUrl fallback semantics The model-level description only mentioned the "omitted" case. State that the group-level completionsUrl is used as the fallback and the model loses inline completions only when neither level is configured. --- extensions/copilot/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 089dc868784b8c..ba42e880787b95 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1919,7 +1919,7 @@ "type": "string", "pattern": "^https?://.+", "patternErrorMessage": "URL must start with http:// or https://", - "markdownDescription": "Full URL used **verbatim** for inline code completions (FIM) requests, e.g. `https://api.deepseek.com/beta/completions` or `https://api.siliconflow.cn/v1/completions`. When omitted, this model is only available for chat, not for inline code completions." + "markdownDescription": "Full URL used **verbatim** for inline code completions (FIM) requests, e.g. `https://api.deepseek.com/beta/completions` or `https://api.siliconflow.cn/v1/completions`. When omitted, the group-level `completionsUrl` is used; if neither is set, this model is only available for chat, not for inline code completions." }, "toolCalling": { "type": "boolean", @@ -2098,7 +2098,7 @@ "type": "string", "pattern": "^https?://.+", "patternErrorMessage": "URL must start with http:// or https://", - "markdownDescription": "Full URL used **verbatim** for inline code completions (FIM) requests, e.g. `https://api.deepseek.com/beta/completions` or `https://api.siliconflow.cn/v1/completions`. When omitted, this model is only available for chat, not for inline code completions." + "markdownDescription": "Full URL used **verbatim** for inline code completions (FIM) requests, e.g. `https://api.deepseek.com/beta/completions` or `https://api.siliconflow.cn/v1/completions`. When omitted, the group-level `completionsUrl` is used; if neither is set, this model is only available for chat, not for inline code completions." }, "apiType": { "type": "string", From 606f17536bb4a74d5e784dc7527dab373c59c873 Mon Sep 17 00:00:00 2001 From: unbadfish <3066893506@qq.com> Date: Mon, 31 Aug 2026 16:09:29 +0800 Subject: [PATCH 8/8] [docs] Inline completions: mention both SiliconFlow endpoints (.cn/.com) in completionsUrl examples --- extensions/copilot/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index da2eecd13c2805..4b8d2cc26fc5a5 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -1873,7 +1873,7 @@ "type": "string", "pattern": "^https?://.+", "patternErrorMessage": "URL must start with http:// or https://", - "markdownDescription": "Default full URL used **verbatim** for inline code completions (FIM) requests for models in this group, e.g. `https://api.deepseek.com/beta/completions` or `https://api.siliconflow.cn/v1/completions`. Individual models can override this with their own `completionsUrl` property. When neither is set, the group's models are only available for chat." + "markdownDescription": "Default full URL used **verbatim** for inline code completions (FIM) requests for models in this group, e.g. DeepSeek: `https://api.deepseek.com/beta/completions`, or SiliconFlow: `https://api.siliconflow.cn/v1/completions` / `https://api.siliconflow.com/v1/completions`. Individual models can override this with their own `completionsUrl` property. When neither is set, the group's models are only available for chat." }, "models": { "type": "array", @@ -1914,7 +1914,7 @@ "type": "string", "pattern": "^https?://.+", "patternErrorMessage": "URL must start with http:// or https://", - "markdownDescription": "Full URL used **verbatim** for inline code completions (FIM) requests, e.g. `https://api.deepseek.com/beta/completions` or `https://api.siliconflow.cn/v1/completions`. When omitted, the group-level `completionsUrl` is used; if neither is set, this model is only available for chat, not for inline code completions." + "markdownDescription": "Full URL used **verbatim** for inline code completions (FIM) requests, e.g. DeepSeek: `https://api.deepseek.com/beta/completions`, or SiliconFlow: `https://api.siliconflow.cn/v1/completions` / `https://api.siliconflow.com/v1/completions`. When omitted, the group-level `completionsUrl` is used; if neither is set, this model is only available for chat, not for inline code completions." }, "toolCalling": { "type": "boolean", @@ -2051,7 +2051,7 @@ "type": "string", "pattern": "^https?://.+", "patternErrorMessage": "URL must start with http:// or https://", - "markdownDescription": "Default full URL used **verbatim** for inline code completions (FIM) requests for models in this group, e.g. `https://api.deepseek.com/beta/completions` or `https://api.siliconflow.cn/v1/completions`. Individual models can override this with their own `completionsUrl` property. When neither is set, the group's models are only available for chat." + "markdownDescription": "Default full URL used **verbatim** for inline code completions (FIM) requests for models in this group, e.g. DeepSeek: `https://api.deepseek.com/beta/completions`, or SiliconFlow: `https://api.siliconflow.cn/v1/completions` / `https://api.siliconflow.com/v1/completions`. Individual models can override this with their own `completionsUrl` property. When neither is set, the group's models are only available for chat." }, "models": { "type": "array", @@ -2093,7 +2093,7 @@ "type": "string", "pattern": "^https?://.+", "patternErrorMessage": "URL must start with http:// or https://", - "markdownDescription": "Full URL used **verbatim** for inline code completions (FIM) requests, e.g. `https://api.deepseek.com/beta/completions` or `https://api.siliconflow.cn/v1/completions`. When omitted, the group-level `completionsUrl` is used; if neither is set, this model is only available for chat, not for inline code completions." + "markdownDescription": "Full URL used **verbatim** for inline code completions (FIM) requests, e.g. DeepSeek: `https://api.deepseek.com/beta/completions`, or SiliconFlow: `https://api.siliconflow.cn/v1/completions` / `https://api.siliconflow.com/v1/completions`. When omitted, the group-level `completionsUrl` is used; if neither is set, this model is only available for chat, not for inline code completions." }, "apiType": { "type": "string",