diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index 2db47dea..5b758856 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -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 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. - - 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). + - 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. - 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` diff --git a/packages/vscode/src/opencodeConfig.providers.test.ts b/packages/vscode/src/opencodeConfig.providers.test.ts index d4e64e87..1da73ede 100644 --- a/packages/vscode/src/opencodeConfig.providers.test.ts +++ b/packages/vscode/src/opencodeConfig.providers.test.ts @@ -136,6 +136,110 @@ describe('custom provider config persistence (VS Code parity)', () => { assert.deepEqual(written.disabled_providers, ['other']); }); + test('upsertProviderConfig preserves unmanaged provider and model metadata', () => { + const configPath = path.join(projectDir, 'opencode.json'); + writeJson(configPath, { + provider: { + 'campus-llm': { + npm: '@ai-sdk/openai-compatible', + name: 'Old', + customProviderField: { owner: 'user' }, + env: ['OLD_KEY'], + options: { + baseURL: 'https://old.example.edu/v1', + headers: { 'X-Old': '1' }, + timeout: 45_000, + }, + models: { + retained: { + name: 'Old retained name', + reasoning: true, + attachment: true, + tool_call: true, + modalities: { input: ['text', 'image', 'pdf'], output: ['text'] }, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, + options: { instructions: 'Keep this instruction' }, + variants: { + low: { reasoningEffort: 'low' }, + high: { reasoningEffort: 'high' }, + }, + customModelField: { source: 'manual' }, + }, + removed: { + name: 'Remove me', + reasoning: true, + }, + }, + }, + }, + }); + + upsertProviderConfig('campus-llm', { + name: 'Campus LLM', + options: { baseURL: 'https://new.example.edu/v1' }, + models: { + retained: { name: 'Retained model' }, + added: { name: 'Added model' }, + }, + }, projectDir, 'project', { hasStoredAuth: true }); + + const written = readJson(configPath).provider['campus-llm']; + assert.deepEqual(written, { + npm: '@ai-sdk/openai-compatible', + name: 'Campus LLM', + customProviderField: { owner: 'user' }, + options: { + baseURL: 'https://new.example.edu/v1', + timeout: 45_000, + }, + models: { + retained: { + name: 'Retained model', + reasoning: true, + attachment: true, + tool_call: true, + modalities: { input: ['text', 'image', 'pdf'], output: ['text'] }, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, + options: { instructions: 'Keep this instruction' }, + variants: { + low: { reasoningEffort: 'low' }, + high: { reasoningEffort: 'high' }, + }, + customModelField: { source: 'manual' }, + }, + added: { name: 'Added model' }, + }, + }); + }); + + test('upsertProviderConfig preserves metadata while migrating the legacy providers alias', () => { + const configPath = path.join(projectDir, 'opencode.json'); + writeJson(configPath, { + providers: { + legacy: { + name: 'Legacy provider', + options: { baseURL: 'https://old.example.com/v1', timeout: 30_000 }, + models: { model: { name: 'Old model', reasoning: true } }, + }, + }, + }); + + upsertProviderConfig('legacy', { + name: 'Updated provider', + options: { baseURL: 'https://new.example.com/v1' }, + models: { model: { name: 'Updated model' } }, + }, projectDir, 'project', { hasStoredAuth: true }); + + const written = readJson(configPath); + assert.equal(written.providers, undefined); + assert.deepEqual(written.provider.legacy, { + npm: '@ai-sdk/openai-compatible', + name: 'Updated provider', + options: { baseURL: 'https://new.example.com/v1', timeout: 30_000 }, + models: { model: { name: 'Updated model', reasoning: true } }, + }); + }); + test('upsert then remove restores absence', () => { upsertProviderConfig('temp-provider', { name: 'Temp', diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index 78297fc6..341dc87f 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -2267,6 +2267,21 @@ const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/; const BASE_URL_PATTERN = /^https?:\/\//; const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible'; +type JsonValue = string | number | boolean | null | JsonObject | JsonValue[]; +type JsonObject = { [key: string]: JsonValue }; +type NormalizedCustomProviderModel = JsonObject & { name: string }; +type NormalizedCustomProviderOptions = JsonObject & { + baseURL: string; + headers?: Record; +}; +type NormalizedCustomProviderConfig = JsonObject & { + npm: string; + name: string; + options: NormalizedCustomProviderOptions; + models: Record; + env?: string[]; +}; + export const validateCustomProviderConfig = ( providerId: string, config: unknown, @@ -2308,7 +2323,7 @@ export const validateCustomProviderConfig = ( return { ok: false as const, error: 'At least one model is required' }; } - const normalizedModels: Record = {}; + const normalizedModels: Record = {}; for (const [modelId, modelValue] of Object.entries(models)) { const trimmedId = typeof modelId === 'string' ? modelId.trim() : ''; if (!trimmedId) { @@ -2324,7 +2339,7 @@ export const validateCustomProviderConfig = ( normalizedModels[trimmedId] = { name: modelName }; } - const normalized: Record = { + const normalized: NormalizedCustomProviderConfig = { npm: OPENAI_COMPATIBLE_NPM, name, options: { @@ -2359,13 +2374,44 @@ export const validateCustomProviderConfig = ( headers[headerKey.trim()] = headerValue.trim(); } if (Object.keys(headers).length > 0) { - (normalized.options as Record).headers = headers; + normalized.options.headers = headers; } } return { ok: true as const, value: { providerId, config: normalized } }; }; +const mergeCustomProviderConfig = ( + existingValue: JsonValue | undefined, + normalizedConfig: NormalizedCustomProviderConfig, +) => { + const existing = isPlainObject(existingValue) ? existingValue : {}; + const existingOptions = isPlainObject(existing.options) ? existing.options : {}; + const mergedOptions = { ...existingOptions, ...normalizedConfig.options }; + if (!Object.prototype.hasOwnProperty.call(normalizedConfig.options, 'headers')) { + delete mergedOptions.headers; + } + + const existingModels = isPlainObject(existing.models) ? existing.models : {}; + const mergedModels = Object.fromEntries( + Object.entries(normalizedConfig.models).map(([modelId, normalizedModel]) => { + const existingModel = isPlainObject(existingModels[modelId]) ? existingModels[modelId] : {}; + return [modelId, { ...existingModel, ...normalizedModel }]; + }), + ); + + const merged = { + ...existing, + ...normalizedConfig, + options: mergedOptions, + models: mergedModels, + }; + if (!Object.prototype.hasOwnProperty.call(normalizedConfig, 'env')) { + delete merged.env; + } + return merged; +}; + export const upsertProviderConfig = ( providerId: string, config: unknown, @@ -2401,8 +2447,24 @@ export const upsertProviderConfig = ( const providerConfig = isPlainObject(targetConfig.provider) ? { ...(targetConfig.provider as Record) } : {}; - providerConfig[validated.value.providerId] = validated.value.config; + const providersAlias = isPlainObject(targetConfig.providers) + ? { ...targetConfig.providers } + : {}; + const existingProviderValue = providerConfig[validated.value.providerId] + ?? providersAlias[validated.value.providerId]; + // SAFETY: config layers come from the JSONC parser, so provider entries are JSON values. + const existingProvider = existingProviderValue as JsonValue | undefined; + const mergedConfig = mergeCustomProviderConfig(existingProvider, validated.value.config); + providerConfig[validated.value.providerId] = mergedConfig; targetConfig.provider = providerConfig; + if (Object.prototype.hasOwnProperty.call(providersAlias, validated.value.providerId)) { + delete providersAlias[validated.value.providerId]; + if (Object.keys(providersAlias).length === 0) { + delete targetConfig.providers; + } else { + targetConfig.providers = providersAlias; + } + } if (Array.isArray(targetConfig.disabled_providers)) { targetConfig.disabled_providers = targetConfig.disabled_providers.filter( @@ -2416,7 +2478,7 @@ export const upsertProviderConfig = ( return { providerId: validated.value.providerId, path: writePath, - config: validated.value.config, + config: mergedConfig, }; }; diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 27d08386..8576cde3 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -63,7 +63,7 @@ This module provides OpenCode server integration utilities for the web server ru ## Public exports (providers.js) - `getProviderSources(providerId, workingDirectory)`: Resolves which OpenCode config layers define a provider. -- `upsertProviderConfig(providerId, config, workingDirectory, scope?, options?)`: Validates and writes a custom OpenAI-compatible provider block (`npm`, `name`, `options.baseURL`, `models`, optional `env`/`headers`) into the user/project/custom config layer. Does not store API keys. Requires `config.env` or `options.hasStoredAuth` (auth already written via OpenCode `auth.set`). Edit flows must pass the provider's effective existing layer (`custom` > `project` > `user`) so updates do not create a global user override. +- `upsertProviderConfig(providerId, config, workingDirectory, scope?, options?)`: Validates and writes a custom OpenAI-compatible provider block (`npm`, `name`, `options.baseURL`, `models`, optional `env`/`headers`) into the user/project/custom config layer. Existing provider, option, and retained-model fields not managed by the form are preserved; omitted models, headers, and env credentials remain explicit removals. Updating a legacy `providers` entry migrates it to the canonical `provider` key. Does not store API keys. Requires `config.env` or `options.hasStoredAuth` (auth already written via OpenCode `auth.set`). Edit flows must pass the provider's effective existing layer (`custom` > `project` > `user`) so updates do not create a global user override. - `validateCustomProviderConfig(providerId, config, options?)`: Structural validation for custom provider payloads (id format, http(s) base URL, models, credentials via `env` or `hasStoredAuth`). - `removeProviderConfig(providerId, workingDirectory, scope?)`: Removes a provider block from the selected config layer. diff --git a/packages/web/server/lib/opencode/providers.js b/packages/web/server/lib/opencode/providers.js index 42600165..46663c09 100644 --- a/packages/web/server/lib/opencode/providers.js +++ b/packages/web/server/lib/opencode/providers.js @@ -152,6 +152,37 @@ function validateCustomProviderConfig(providerId, config, options = {}) { return { ok: true, value: { providerId, config: normalized } }; } +function mergeCustomProviderConfig(existingValue, normalizedConfig) { + const existing = isPlainObject(existingValue) ? existingValue : {}; + const existingOptions = isPlainObject(existing.options) ? existing.options : {}; + const normalizedOptions = isPlainObject(normalizedConfig.options) ? normalizedConfig.options : {}; + const mergedOptions = { ...existingOptions, ...normalizedOptions }; + if (!Object.prototype.hasOwnProperty.call(normalizedOptions, 'headers')) { + delete mergedOptions.headers; + } + + const existingModels = isPlainObject(existing.models) ? existing.models : {}; + const normalizedModels = isPlainObject(normalizedConfig.models) ? normalizedConfig.models : {}; + const mergedModels = Object.fromEntries( + Object.entries(normalizedModels).map(([modelId, normalizedModel]) => { + const existingModel = isPlainObject(existingModels[modelId]) ? existingModels[modelId] : {}; + const nextModel = isPlainObject(normalizedModel) ? normalizedModel : {}; + return [modelId, { ...existingModel, ...nextModel }]; + }), + ); + + const merged = { + ...existing, + ...normalizedConfig, + options: mergedOptions, + models: mergedModels, + }; + if (!Object.prototype.hasOwnProperty.call(normalizedConfig, 'env')) { + delete merged.env; + } + return merged; +} + /** * Persist (create or update) a custom provider block in OpenCode user/project/custom config. * Does not write secrets — API keys remain in auth.json via the OpenCode auth API. @@ -183,8 +214,19 @@ function upsertProviderConfig(providerId, config, workingDirectory, scope = 'use const targetConfig = getConfigForPath(layers, targetPath); const providerConfig = isPlainObject(targetConfig.provider) ? { ...targetConfig.provider } : {}; - providerConfig[validated.value.providerId] = validated.value.config; + const providersAlias = isPlainObject(targetConfig.providers) ? { ...targetConfig.providers } : {}; + const existingProvider = providerConfig[validated.value.providerId] ?? providersAlias[validated.value.providerId]; + const mergedConfig = mergeCustomProviderConfig(existingProvider, validated.value.config); + providerConfig[validated.value.providerId] = mergedConfig; targetConfig.provider = providerConfig; + if (Object.prototype.hasOwnProperty.call(providersAlias, validated.value.providerId)) { + delete providersAlias[validated.value.providerId]; + if (Object.keys(providersAlias).length === 0) { + delete targetConfig.providers; + } else { + targetConfig.providers = providersAlias; + } + } if (Array.isArray(targetConfig.disabled_providers)) { targetConfig.disabled_providers = targetConfig.disabled_providers.filter( @@ -198,7 +240,7 @@ function upsertProviderConfig(providerId, config, workingDirectory, scope = 'use return { providerId: validated.value.providerId, path: writePath, - config: validated.value.config, + config: mergedConfig, }; } diff --git a/packages/web/server/lib/opencode/providers.test.js b/packages/web/server/lib/opencode/providers.test.js index 9ce3715a..35078f05 100644 --- a/packages/web/server/lib/opencode/providers.test.js +++ b/packages/web/server/lib/opencode/providers.test.js @@ -162,6 +162,110 @@ describe('custom provider config persistence', () => { expect(written.disabled_providers).toEqual(['other']); }); + test('upsertProviderConfig preserves unmanaged provider and model metadata', () => { + const configPath = path.join(projectDir, 'opencode.json'); + writeJson(configPath, { + provider: { + 'campus-llm': { + npm: '@ai-sdk/openai-compatible', + name: 'Old', + customProviderField: { owner: 'user' }, + env: ['OLD_KEY'], + options: { + baseURL: 'https://old.example.edu/v1', + headers: { 'X-Old': '1' }, + timeout: 45_000, + }, + models: { + retained: { + name: 'Old retained name', + reasoning: true, + attachment: true, + tool_call: true, + modalities: { input: ['text', 'image', 'pdf'], output: ['text'] }, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, + options: { instructions: 'Keep this instruction' }, + variants: { + low: { reasoningEffort: 'low' }, + high: { reasoningEffort: 'high' }, + }, + customModelField: { source: 'manual' }, + }, + removed: { + name: 'Remove me', + reasoning: true, + }, + }, + }, + }, + }); + + upsertProviderConfig('campus-llm', { + name: 'Campus LLM', + options: { baseURL: 'https://new.example.edu/v1' }, + models: { + retained: { name: 'Retained model' }, + added: { name: 'Added model' }, + }, + }, projectDir, 'project', { hasStoredAuth: true }); + + const written = readJson(configPath).provider['campus-llm']; + expect(written).toEqual({ + npm: '@ai-sdk/openai-compatible', + name: 'Campus LLM', + customProviderField: { owner: 'user' }, + options: { + baseURL: 'https://new.example.edu/v1', + timeout: 45_000, + }, + models: { + retained: { + name: 'Retained model', + reasoning: true, + attachment: true, + tool_call: true, + modalities: { input: ['text', 'image', 'pdf'], output: ['text'] }, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, + options: { instructions: 'Keep this instruction' }, + variants: { + low: { reasoningEffort: 'low' }, + high: { reasoningEffort: 'high' }, + }, + customModelField: { source: 'manual' }, + }, + added: { name: 'Added model' }, + }, + }); + }); + + test('upsertProviderConfig preserves metadata while migrating the legacy providers alias', () => { + const configPath = path.join(projectDir, 'opencode.json'); + writeJson(configPath, { + providers: { + legacy: { + name: 'Legacy provider', + options: { baseURL: 'https://old.example.com/v1', timeout: 30_000 }, + models: { model: { name: 'Old model', reasoning: true } }, + }, + }, + }); + + upsertProviderConfig('legacy', { + name: 'Updated provider', + options: { baseURL: 'https://new.example.com/v1' }, + models: { model: { name: 'Updated model' } }, + }, projectDir, 'project', { hasStoredAuth: true }); + + const written = readJson(configPath); + expect(written.providers).toBeUndefined(); + expect(written.provider.legacy).toEqual({ + npm: '@ai-sdk/openai-compatible', + name: 'Updated provider', + options: { baseURL: 'https://new.example.com/v1', timeout: 30_000 }, + models: { model: { name: 'Updated model', reasoning: true } }, + }); + }); + test('upsert then remove restores absence', () => { upsertProviderConfig('temp-provider', { name: 'Temp',