Merge pull request #3329 from hehuaiyu/fix/preserve-custom-provider-model-metadata

fix(providers): preserve custom model metadata on edit
This commit is contained in:
Bohdan Triapitsyn
2026-09-05 01:24:23 +03:00
committed by GitHub
6 changed files with 321 additions and 9 deletions
@@ -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.
+44 -2
View File
@@ -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',