Merge pull request #3330 from hehuaiyu/fix/vscode-custom-provider-protocol-parity
fix(vscode): support custom provider protocols
This commit is contained in:
@@ -72,7 +72,7 @@ The webview build emits each worker as one self-contained file. VS Code webviews
|
|||||||
- Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`).
|
- Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`).
|
||||||
- Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`).
|
- Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`).
|
||||||
- Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade.
|
- Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade.
|
||||||
- Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI-compatible config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API). Updates preserve existing provider, option, and retained-model fields that the form does not manage while honoring explicit model, header, and env removal. Legacy `providers` entries migrate to the canonical `provider` key when edited.
|
- Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API). Updates preserve existing provider, option, and retained-model fields that the form does not manage while honoring explicit model, header, and env removal. Legacy `providers` entries migrate to the canonical `provider` key when edited.
|
||||||
- Quota handlers keep managed exe.dev, Ollama Cloud, and Cursor credentials in the extension data directory with the same private-file contract as the web runtime. exe.dev uses one command-scoped usage token for the aggregate billing shared by every `exe-*` model provider.
|
- Quota handlers keep managed exe.dev, Ollama Cloud, and Cursor credentials in the extension data directory with the same private-file contract as the web runtime. exe.dev uses one command-scoped usage token for the aggregate billing shared by every `exe-*` model provider.
|
||||||
|
|
||||||
- `opencode-upgrade-runtime.ts`
|
- `opencode-upgrade-runtime.ts`
|
||||||
|
|||||||
@@ -72,6 +72,33 @@ describe('custom provider config persistence (VS Code parity)', () => {
|
|||||||
}).ok, true);
|
}).ok, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('accepts supported protocol adapters and rejects arbitrary npm packages', () => {
|
||||||
|
for (const npm of ['@ai-sdk/openai-compatible', '@ai-sdk/openai', '@ai-sdk/anthropic']) {
|
||||||
|
const result = validateCustomProviderConfig('ok', {
|
||||||
|
name: 'X',
|
||||||
|
npm,
|
||||||
|
env: ['MY_KEY'],
|
||||||
|
options: { baseURL: 'https://api.example.com/v1' },
|
||||||
|
models: { m: { name: 'M' } },
|
||||||
|
});
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
if (result.ok) assert.equal(result.value.config.npm, npm);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsupported = validateCustomProviderConfig('ok', {
|
||||||
|
name: 'X',
|
||||||
|
npm: 'untrusted-provider-package',
|
||||||
|
env: ['MY_KEY'],
|
||||||
|
options: { baseURL: 'https://api.example.com/v1' },
|
||||||
|
models: { m: { name: 'M' } },
|
||||||
|
});
|
||||||
|
assert.equal(unsupported.ok, false);
|
||||||
|
assert.equal(
|
||||||
|
unsupported.error,
|
||||||
|
'Custom providers must use @ai-sdk/openai-compatible, @ai-sdk/openai, or @ai-sdk/anthropic',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('upsertProviderConfig writes and round-trips project config', () => {
|
test('upsertProviderConfig writes and round-trips project config', () => {
|
||||||
const result = upsertProviderConfig('campus-llm', {
|
const result = upsertProviderConfig('campus-llm', {
|
||||||
name: 'Campus LLM',
|
name: 'Campus LLM',
|
||||||
@@ -109,6 +136,23 @@ describe('custom provider config persistence (VS Code parity)', () => {
|
|||||||
assert.equal(sources.project.path, result.path);
|
assert.equal(sources.project.path, result.path);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('round-trips non-default protocol adapters through project config', () => {
|
||||||
|
for (const [providerId, npm] of [
|
||||||
|
['responses-provider', '@ai-sdk/openai'],
|
||||||
|
['anthropic-provider', '@ai-sdk/anthropic'],
|
||||||
|
]) {
|
||||||
|
const result = upsertProviderConfig(providerId, {
|
||||||
|
name: providerId,
|
||||||
|
npm,
|
||||||
|
env: ['PROVIDER_KEY'],
|
||||||
|
options: { baseURL: 'https://api.example.com/v1' },
|
||||||
|
models: { model: { name: 'Model' } },
|
||||||
|
}, projectDir, 'project');
|
||||||
|
assert.equal(result.config.npm, npm);
|
||||||
|
assert.equal(readJson(result.path).provider[providerId].npm, npm);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('upsertProviderConfig updates existing entry and clears disabled_providers', () => {
|
test('upsertProviderConfig updates existing entry and clears disabled_providers', () => {
|
||||||
const configPath = path.join(projectDir, 'opencode.json');
|
const configPath = path.join(projectDir, 'opencode.json');
|
||||||
writeJson(configPath, {
|
writeJson(configPath, {
|
||||||
|
|||||||
@@ -2266,6 +2266,11 @@ export const removeProviderConfig = (providerId: string, workingDirectory?: stri
|
|||||||
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
|
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
|
||||||
const BASE_URL_PATTERN = /^https?:\/\//;
|
const BASE_URL_PATTERN = /^https?:\/\//;
|
||||||
const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible';
|
const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible';
|
||||||
|
const CUSTOM_PROVIDER_NPM_PACKAGES = new Set([
|
||||||
|
OPENAI_COMPATIBLE_NPM,
|
||||||
|
'@ai-sdk/openai',
|
||||||
|
'@ai-sdk/anthropic',
|
||||||
|
]);
|
||||||
|
|
||||||
type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
|
type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
|
||||||
type JsonObject = { [key: string]: JsonValue };
|
type JsonObject = { [key: string]: JsonValue };
|
||||||
@@ -2301,8 +2306,11 @@ export const validateCustomProviderConfig = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM;
|
const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM;
|
||||||
if (npm !== OPENAI_COMPATIBLE_NPM) {
|
if (!CUSTOM_PROVIDER_NPM_PACKAGES.has(npm)) {
|
||||||
return { ok: false as const, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` };
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
error: 'Custom providers must use @ai-sdk/openai-compatible, @ai-sdk/openai, or @ai-sdk/anthropic',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const optionsBlock = isPlainObject(config.options) ? config.options : null;
|
const optionsBlock = isPlainObject(config.options) ? config.options : null;
|
||||||
@@ -2340,7 +2348,7 @@ export const validateCustomProviderConfig = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalized: NormalizedCustomProviderConfig = {
|
const normalized: NormalizedCustomProviderConfig = {
|
||||||
npm: OPENAI_COMPATIBLE_NPM,
|
npm,
|
||||||
name,
|
name,
|
||||||
options: {
|
options: {
|
||||||
baseURL,
|
baseURL,
|
||||||
|
|||||||
Reference in New Issue
Block a user