fix custom provider credentials, edit path, and failure UX
Require an API key or {env:VAR} on client and server, add edit/prefill for
existing custom providers, save auth before config, and surface incomplete
auth plus disconnect after partial failures. Add VS Code parity tests and
drop the unused allProvidersConnected locale key.
Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
co-authored by
Serhii Dziupin
parent
d84e4e0312
commit
d40bb9e5a0
@@ -59,8 +59,8 @@ 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?)`: 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.
|
||||
- `validateCustomProviderConfig(providerId, config)`: Structural validation for custom provider payloads (id format, http(s) base URL, models).
|
||||
- `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`).
|
||||
- `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.
|
||||
|
||||
## Public exports (shared.js)
|
||||
|
||||
@@ -44,8 +44,11 @@ function getProviderSources(providerId, workingDirectory) {
|
||||
/**
|
||||
* Validate a custom OpenAI-compatible provider config payload before persistence.
|
||||
* Returns { ok: true, value } or { ok: false, error }.
|
||||
*
|
||||
* Credentials: either config.env contains a variable name, or hasStoredAuth is true
|
||||
* (auth.json already has a key — typically after auth.set, or when editing).
|
||||
*/
|
||||
function validateCustomProviderConfig(providerId, config) {
|
||||
function validateCustomProviderConfig(providerId, config, options = {}) {
|
||||
if (!providerId || typeof providerId !== 'string' || !PROVIDER_ID_PATTERN.test(providerId)) {
|
||||
return { ok: false, error: 'Provider ID must match /^[a-z0-9][a-z0-9-_]*$/' };
|
||||
}
|
||||
@@ -64,12 +67,12 @@ function validateCustomProviderConfig(providerId, config) {
|
||||
return { ok: false, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` };
|
||||
}
|
||||
|
||||
const options = isPlainObject(config.options) ? config.options : null;
|
||||
if (!options) {
|
||||
const optionsBlock = isPlainObject(config.options) ? config.options : null;
|
||||
if (!optionsBlock) {
|
||||
return { ok: false, error: 'Provider options are required' };
|
||||
}
|
||||
|
||||
const baseURL = typeof options.baseURL === 'string' ? options.baseURL.trim() : '';
|
||||
const baseURL = typeof optionsBlock.baseURL === 'string' ? optionsBlock.baseURL.trim() : '';
|
||||
if (!baseURL) {
|
||||
return { ok: false, error: 'Base URL is required' };
|
||||
}
|
||||
@@ -107,8 +110,9 @@ function validateCustomProviderConfig(providerId, config) {
|
||||
models: normalizedModels,
|
||||
};
|
||||
|
||||
let env = [];
|
||||
if (Array.isArray(config.env)) {
|
||||
const env = config.env
|
||||
env = config.env
|
||||
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
|
||||
.map((entry) => entry.trim());
|
||||
if (env.length > 0) {
|
||||
@@ -116,9 +120,17 @@ function validateCustomProviderConfig(providerId, config) {
|
||||
}
|
||||
}
|
||||
|
||||
if (isPlainObject(options.headers)) {
|
||||
const hasStoredAuth = Boolean(options.hasStoredAuth);
|
||||
if (env.length === 0 && !hasStoredAuth) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'API key or {env:VAR} credentials are required',
|
||||
};
|
||||
}
|
||||
|
||||
if (isPlainObject(optionsBlock.headers)) {
|
||||
const headers = {};
|
||||
for (const [headerKey, headerValue] of Object.entries(options.headers)) {
|
||||
for (const [headerKey, headerValue] of Object.entries(optionsBlock.headers)) {
|
||||
if (typeof headerKey !== 'string' || !headerKey.trim()) {
|
||||
continue;
|
||||
}
|
||||
@@ -139,8 +151,8 @@ function validateCustomProviderConfig(providerId, config) {
|
||||
* 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.
|
||||
*/
|
||||
function upsertProviderConfig(providerId, config, workingDirectory, scope = 'user') {
|
||||
const validated = validateCustomProviderConfig(providerId, config);
|
||||
function upsertProviderConfig(providerId, config, workingDirectory, scope = 'user', options = {}) {
|
||||
const validated = validateCustomProviderConfig(providerId, config, options);
|
||||
if (!validated.ok) {
|
||||
const error = new Error(validated.error);
|
||||
error.statusCode = 400;
|
||||
|
||||
@@ -50,6 +50,27 @@ describe('custom provider config persistence', () => {
|
||||
}).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('validateCustomProviderConfig rejects missing credentials', () => {
|
||||
expect(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).ok).toBe(false);
|
||||
|
||||
expect(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, { hasStoredAuth: true }).ok).toBe(true);
|
||||
|
||||
expect(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
env: ['MY_KEY'],
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('upsertProviderConfig writes and round-trips project config', () => {
|
||||
const result = upsertProviderConfig('campus-llm', {
|
||||
name: 'Campus LLM',
|
||||
@@ -105,6 +126,7 @@ describe('custom provider config persistence', () => {
|
||||
name: 'Campus LLM',
|
||||
options: { baseURL: 'https://llm.example.edu/v1' },
|
||||
models: { b: { name: 'B' } },
|
||||
env: ['CAMPUS_KEY'],
|
||||
}, projectDir, 'project');
|
||||
|
||||
const written = readJson(configPath);
|
||||
@@ -118,6 +140,7 @@ describe('custom provider config persistence', () => {
|
||||
name: 'Temp',
|
||||
options: { baseURL: 'https://api.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
env: ['TEMP_KEY'],
|
||||
}, projectDir, 'project');
|
||||
|
||||
expect(getProviderSources('temp-provider', projectDir).sources.project.exists).toBe(true);
|
||||
@@ -131,7 +154,19 @@ describe('custom provider config persistence', () => {
|
||||
name: 'X',
|
||||
options: { baseURL: 'not-a-url' },
|
||||
models: { m: { name: 'M' } },
|
||||
env: ['X'],
|
||||
}, projectDir, 'project')).toThrow(/Base URL/);
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
});
|
||||
|
||||
test('upsert with hasStoredAuth allows config without env', () => {
|
||||
const result = upsertProviderConfig('keyed-provider', {
|
||||
name: 'Keyed',
|
||||
options: { baseURL: 'https://api.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, projectDir, 'project', { hasStoredAuth: true });
|
||||
|
||||
expect(result.providerId).toBe('keyed-provider');
|
||||
expect(result.config.env).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -482,14 +482,16 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
}
|
||||
}
|
||||
|
||||
const result = upsertProviderConfig(providerID, config, directory, scope);
|
||||
const { getProviderAuth } = await getAuthLibrary();
|
||||
const hasStoredAuth = Boolean(getProviderAuth(providerID));
|
||||
const upsertResult = upsertProviderConfig(providerID, config, directory, scope, { hasStoredAuth });
|
||||
await refreshOpenCodeAfterConfigChange(`provider ${providerID} upserted (${scope})`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
providerId: result.providerId,
|
||||
path: result.path,
|
||||
config: result.config,
|
||||
providerId: upsertResult.providerId,
|
||||
path: upsertResult.path,
|
||||
config: upsertResult.config,
|
||||
requiresReload: true,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user