feat: add custom/other OpenAI-compatible LLM providers
Allow Settings → Providers to define custom providers (id, name, base URL, API key, models, headers) without code changes. Persist config via OpenCode layers, store keys through auth.set, and keep web/VS Code parity. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
co-authored by
Serhii Dziupin
parent
5d24d6cb2a
commit
be87e25c7d
@@ -57,6 +57,12 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `AUTH_FILE`: Auth file path constant.
|
||||
- `OPENCODE_DATA_DIR`: OpenCode data directory path constant.
|
||||
|
||||
## 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).
|
||||
- `removeProviderConfig(providerId, workingDirectory, scope?)`: Removes a provider block from the selected config layer.
|
||||
|
||||
## Public exports (shared.js)
|
||||
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`, `CUSTOM_CONFIG_FILE`: Path constants.
|
||||
- `AGENT_SCOPE`, `COMMAND_SCOPE`, `SKILL_SCOPE`: Scope constants with USER and PROJECT values.
|
||||
@@ -82,6 +88,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
||||
- `GET /api/opencode/upgrade-status` (returns version availability plus the authoritative `upgrade.supported`, `upgrade.manager`, and `upgrade.reason` capability)
|
||||
- `POST /api/opencode/directory`
|
||||
- `GET /api/provider/:providerId/source`
|
||||
- `PUT /api/provider` (create/update custom OpenAI-compatible provider config in OpenCode user/project/custom layers; secrets stay in auth via the OpenCode auth API)
|
||||
- `DELETE /api/provider/:providerId/auth`
|
||||
- Owns lazy auth library loading for provider auth checks/removal.
|
||||
- Keeps route behavior independent from composition root; `index.js` now supplies dependencies only.
|
||||
|
||||
@@ -18,7 +18,7 @@ import { registerPluginRoutes } from './plugin-routes.js';
|
||||
import { getNpmInfo, clearCache as clearNpmCache } from './npm-registry.js';
|
||||
import { parseNpmSpec, parsePathSpec, isExactSemver } from './plugin-spec.js';
|
||||
import { registerOpenCodeRoutes } from './routes.js';
|
||||
import { getProviderSources, removeProviderConfig } from './providers.js';
|
||||
import { getProviderSources, removeProviderConfig, upsertProviderConfig } from './providers.js';
|
||||
import { getAgentSources, getAgentConfig, createAgent, updateAgent, deleteAgent } from './agents.js';
|
||||
import { getCommandSources, createCommand, updateCommand, deleteCommand } from './commands.js';
|
||||
import { listMcpConfigs, getMcpConfig, createMcpConfig, updateMcpConfig, deleteMcpConfig } from './mcp.js';
|
||||
@@ -132,6 +132,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
||||
resolveProjectDirectory,
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
upsertProviderConfig,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
writeConfig,
|
||||
} from './shared.js';
|
||||
|
||||
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
|
||||
const BASE_URL_PATTERN = /^https?:\/\//;
|
||||
const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible';
|
||||
|
||||
function getProviderSources(providerId, workingDirectory) {
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
const { userConfig, projectConfig, customConfig, paths } = layers;
|
||||
@@ -37,6 +41,150 @@ function getProviderSources(providerId, workingDirectory) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a custom OpenAI-compatible provider config payload before persistence.
|
||||
* Returns { ok: true, value } or { ok: false, error }.
|
||||
*/
|
||||
function validateCustomProviderConfig(providerId, config) {
|
||||
if (!providerId || typeof providerId !== 'string' || !PROVIDER_ID_PATTERN.test(providerId)) {
|
||||
return { ok: false, error: 'Provider ID must match /^[a-z0-9][a-z0-9-_]*$/' };
|
||||
}
|
||||
|
||||
if (!isPlainObject(config)) {
|
||||
return { ok: false, error: 'Provider config must be an object' };
|
||||
}
|
||||
|
||||
const name = typeof config.name === 'string' ? config.name.trim() : '';
|
||||
if (!name) {
|
||||
return { ok: false, error: 'Provider name is required' };
|
||||
}
|
||||
|
||||
const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM;
|
||||
if (npm !== OPENAI_COMPATIBLE_NPM) {
|
||||
return { ok: false, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` };
|
||||
}
|
||||
|
||||
const options = isPlainObject(config.options) ? config.options : null;
|
||||
if (!options) {
|
||||
return { ok: false, error: 'Provider options are required' };
|
||||
}
|
||||
|
||||
const baseURL = typeof options.baseURL === 'string' ? options.baseURL.trim() : '';
|
||||
if (!baseURL) {
|
||||
return { ok: false, error: 'Base URL is required' };
|
||||
}
|
||||
if (!BASE_URL_PATTERN.test(baseURL)) {
|
||||
return { ok: false, error: 'Base URL must start with http:// or https://' };
|
||||
}
|
||||
|
||||
const models = isPlainObject(config.models) ? config.models : null;
|
||||
if (!models || Object.keys(models).length === 0) {
|
||||
return { ok: false, error: 'At least one model is required' };
|
||||
}
|
||||
|
||||
const normalizedModels = {};
|
||||
for (const [modelId, modelValue] of Object.entries(models)) {
|
||||
const trimmedId = typeof modelId === 'string' ? modelId.trim() : '';
|
||||
if (!trimmedId) {
|
||||
return { ok: false, error: 'Model id is required' };
|
||||
}
|
||||
if (!isPlainObject(modelValue)) {
|
||||
return { ok: false, error: `Model "${trimmedId}" must be an object` };
|
||||
}
|
||||
const modelName = typeof modelValue.name === 'string' ? modelValue.name.trim() : '';
|
||||
if (!modelName) {
|
||||
return { ok: false, error: `Model "${trimmedId}" requires a name` };
|
||||
}
|
||||
normalizedModels[trimmedId] = { name: modelName };
|
||||
}
|
||||
|
||||
const normalized = {
|
||||
npm: OPENAI_COMPATIBLE_NPM,
|
||||
name,
|
||||
options: {
|
||||
baseURL,
|
||||
},
|
||||
models: normalizedModels,
|
||||
};
|
||||
|
||||
if (Array.isArray(config.env)) {
|
||||
const env = config.env
|
||||
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
|
||||
.map((entry) => entry.trim());
|
||||
if (env.length > 0) {
|
||||
normalized.env = env;
|
||||
}
|
||||
}
|
||||
|
||||
if (isPlainObject(options.headers)) {
|
||||
const headers = {};
|
||||
for (const [headerKey, headerValue] of Object.entries(options.headers)) {
|
||||
if (typeof headerKey !== 'string' || !headerKey.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (typeof headerValue !== 'string' || !headerValue.trim()) {
|
||||
return { ok: false, error: `Header "${headerKey}" requires a non-empty value` };
|
||||
}
|
||||
headers[headerKey.trim()] = headerValue.trim();
|
||||
}
|
||||
if (Object.keys(headers).length > 0) {
|
||||
normalized.options.headers = headers;
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true, value: { providerId, config: normalized } };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
if (!validated.ok) {
|
||||
const error = new Error(validated.error);
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const layers = readConfigLayers(workingDirectory);
|
||||
let targetPath = layers.paths.userPath;
|
||||
|
||||
if (scope === 'project') {
|
||||
if (!workingDirectory) {
|
||||
throw new Error('Working directory is required for project scope');
|
||||
}
|
||||
targetPath = layers.paths.projectPath || targetPath;
|
||||
} else if (scope === 'custom') {
|
||||
if (!layers.paths.customPath) {
|
||||
throw new Error('Custom config path (OPENCODE_CONFIG) is not set');
|
||||
}
|
||||
targetPath = layers.paths.customPath;
|
||||
} else if (scope !== 'user') {
|
||||
throw new Error('Invalid scope');
|
||||
}
|
||||
|
||||
const targetConfig = getConfigForPath(layers, targetPath);
|
||||
const providerConfig = isPlainObject(targetConfig.provider) ? { ...targetConfig.provider } : {};
|
||||
providerConfig[validated.value.providerId] = validated.value.config;
|
||||
targetConfig.provider = providerConfig;
|
||||
|
||||
if (Array.isArray(targetConfig.disabled_providers)) {
|
||||
targetConfig.disabled_providers = targetConfig.disabled_providers.filter(
|
||||
(entry) => entry !== validated.value.providerId,
|
||||
);
|
||||
}
|
||||
|
||||
const writePath = targetPath || CONFIG_FILE;
|
||||
writeConfig(targetConfig, writePath);
|
||||
|
||||
return {
|
||||
providerId: validated.value.providerId,
|
||||
path: writePath,
|
||||
config: validated.value.config,
|
||||
};
|
||||
}
|
||||
|
||||
function removeProviderConfig(providerId, workingDirectory, scope = 'user') {
|
||||
if (!providerId || typeof providerId !== 'string') {
|
||||
throw new Error('Provider ID is required');
|
||||
@@ -93,4 +241,6 @@ function removeProviderConfig(providerId, workingDirectory, scope = 'user') {
|
||||
export {
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
upsertProviderConfig,
|
||||
validateCustomProviderConfig,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import {
|
||||
upsertProviderConfig,
|
||||
validateCustomProviderConfig,
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
} from './providers.js';
|
||||
|
||||
let projectDir;
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
describe('custom provider config persistence', () => {
|
||||
beforeEach(() => {
|
||||
projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-provider-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('validateCustomProviderConfig rejects invalid endpoint and credentials shape', () => {
|
||||
expect(validateCustomProviderConfig('Bad Id', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).ok).toBe(false);
|
||||
|
||||
expect(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'ftp://api.example.com' },
|
||||
models: { m: { name: 'M' } },
|
||||
}).error).toContain('http://');
|
||||
|
||||
expect(validateCustomProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'https://api.example.com' },
|
||||
models: {},
|
||||
}).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('upsertProviderConfig writes and round-trips project config', () => {
|
||||
const result = upsertProviderConfig('campus-llm', {
|
||||
name: 'Campus LLM',
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
options: {
|
||||
baseURL: 'https://llm.example.edu/v1',
|
||||
headers: { 'X-Campus': '1' },
|
||||
},
|
||||
models: {
|
||||
'fast-model': { name: 'Fast' },
|
||||
},
|
||||
env: ['CAMPUS_KEY'],
|
||||
}, projectDir, 'project');
|
||||
|
||||
expect(result.providerId).toBe('campus-llm');
|
||||
expect(fs.existsSync(result.path)).toBe(true);
|
||||
expect(result.path.startsWith(projectDir)).toBe(true);
|
||||
|
||||
const written = readJson(result.path);
|
||||
expect(written.provider['campus-llm']).toEqual({
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
name: 'Campus LLM',
|
||||
env: ['CAMPUS_KEY'],
|
||||
options: {
|
||||
baseURL: 'https://llm.example.edu/v1',
|
||||
headers: { 'X-Campus': '1' },
|
||||
},
|
||||
models: {
|
||||
'fast-model': { name: 'Fast' },
|
||||
},
|
||||
});
|
||||
|
||||
const sources = getProviderSources('campus-llm', projectDir);
|
||||
expect(sources.sources.project.exists).toBe(true);
|
||||
expect(sources.sources.project.path).toBe(result.path);
|
||||
});
|
||||
|
||||
test('upsertProviderConfig updates existing entry and clears disabled_providers', () => {
|
||||
const configPath = path.join(projectDir, 'opencode.json');
|
||||
writeJson(configPath, {
|
||||
provider: {
|
||||
'campus-llm': {
|
||||
npm: '@ai-sdk/openai-compatible',
|
||||
name: 'Old',
|
||||
options: { baseURL: 'https://old.example.edu/v1' },
|
||||
models: { a: { name: 'A' } },
|
||||
},
|
||||
},
|
||||
disabled_providers: ['campus-llm', 'other'],
|
||||
});
|
||||
|
||||
upsertProviderConfig('campus-llm', {
|
||||
name: 'Campus LLM',
|
||||
options: { baseURL: 'https://llm.example.edu/v1' },
|
||||
models: { b: { name: 'B' } },
|
||||
}, projectDir, 'project');
|
||||
|
||||
const written = readJson(configPath);
|
||||
expect(written.provider['campus-llm'].name).toBe('Campus LLM');
|
||||
expect(written.provider['campus-llm'].models).toEqual({ b: { name: 'B' } });
|
||||
expect(written.disabled_providers).toEqual(['other']);
|
||||
});
|
||||
|
||||
test('upsert then remove restores absence', () => {
|
||||
upsertProviderConfig('temp-provider', {
|
||||
name: 'Temp',
|
||||
options: { baseURL: 'https://api.example.com/v1' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, projectDir, 'project');
|
||||
|
||||
expect(getProviderSources('temp-provider', projectDir).sources.project.exists).toBe(true);
|
||||
expect(removeProviderConfig('temp-provider', projectDir, 'project')).toBe(true);
|
||||
expect(getProviderSources('temp-provider', projectDir).sources.project.exists).toBe(false);
|
||||
});
|
||||
|
||||
test('failed validation does not write config', () => {
|
||||
const configPath = path.join(projectDir, 'opencode.json');
|
||||
expect(() => upsertProviderConfig('ok', {
|
||||
name: 'X',
|
||||
options: { baseURL: 'not-a-url' },
|
||||
models: { m: { name: 'M' } },
|
||||
}, projectDir, 'project')).toThrow(/Base URL/);
|
||||
expect(fs.existsSync(configPath)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
resolveProjectDirectory,
|
||||
getProviderSources,
|
||||
removeProviderConfig,
|
||||
upsertProviderConfig,
|
||||
refreshOpenCodeAfterConfigChange,
|
||||
buildOpenCodeUrl,
|
||||
getOpenCodeAuthHeaders,
|
||||
@@ -443,6 +444,62 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/provider', async (req, res) => {
|
||||
try {
|
||||
const providerID = typeof req.body?.providerID === 'string'
|
||||
? req.body.providerID.trim()
|
||||
: (typeof req.body?.providerId === 'string' ? req.body.providerId.trim() : '');
|
||||
const config = req.body?.config;
|
||||
const scope = typeof req.body?.scope === 'string' ? req.body.scope : 'user';
|
||||
|
||||
if (!providerID) {
|
||||
return res.status(400).json({ error: 'Provider ID is required' });
|
||||
}
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
||||
return res.status(400).json({ error: 'Provider config is required' });
|
||||
}
|
||||
if (scope !== 'user' && scope !== 'project' && scope !== 'custom') {
|
||||
return res.status(400).json({ error: 'Invalid scope' });
|
||||
}
|
||||
|
||||
const headerDirectory = typeof req.get === 'function' ? req.get('x-opencode-directory') : null;
|
||||
const queryDirectory = Array.isArray(req.query?.directory)
|
||||
? req.query.directory[0]
|
||||
: req.query?.directory;
|
||||
const requestedDirectory = headerDirectory || queryDirectory || null;
|
||||
|
||||
let directory = null;
|
||||
if (scope === 'project' || requestedDirectory) {
|
||||
const resolved = await resolveProjectDirectory(req);
|
||||
if (!resolved.directory) {
|
||||
return res.status(400).json({ error: resolved.error || 'Working directory is required' });
|
||||
}
|
||||
directory = resolved.directory;
|
||||
} else {
|
||||
const resolved = await resolveProjectDirectory(req);
|
||||
if (resolved.directory) {
|
||||
directory = resolved.directory;
|
||||
}
|
||||
}
|
||||
|
||||
const result = upsertProviderConfig(providerID, config, directory, scope);
|
||||
await refreshOpenCodeAfterConfigChange(`provider ${providerID} upserted (${scope})`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
providerId: result.providerId,
|
||||
path: result.path,
|
||||
config: result.config,
|
||||
requiresReload: true,
|
||||
reloadDelayMs: clientReloadDelayMs,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = typeof error?.statusCode === 'number' ? error.statusCode : 500;
|
||||
console.error('Failed to upsert provider config:', error);
|
||||
return res.status(status).json({ error: error.message || 'Failed to save provider config' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/provider/:providerId/auth', async (req, res) => {
|
||||
try {
|
||||
const { providerId } = req.params;
|
||||
|
||||
Reference in New Issue
Block a user