fix(providers): preserve custom model metadata on edit
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 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`
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, string>;
|
||||
};
|
||||
type NormalizedCustomProviderConfig = JsonObject & {
|
||||
npm: string;
|
||||
name: string;
|
||||
options: NormalizedCustomProviderOptions;
|
||||
models: Record<string, NormalizedCustomProviderModel>;
|
||||
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<string, { name: string }> = {};
|
||||
const normalizedModels: Record<string, NormalizedCustomProviderModel> = {};
|
||||
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<string, unknown> = {
|
||||
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<string, unknown>).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<string, unknown>) }
|
||||
: {};
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -61,7 +61,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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user