feat: add custom/other OpenAI-compatible LLM providers #2571

This commit is contained in:
Bohdan Triapitsyn
2026-08-03 13:21:45 +03:00
committed by GitHub
30 changed files with 3125 additions and 67 deletions
@@ -57,8 +57,14 @@ 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?, 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.
- `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)
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`, `CUSTOM_CONFIG_FILE`: Path constants.
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`: Path constants. `OPENCODE_CONFIG` is resolved at call time for the custom config layer path.
- `AGENT_SCOPE`, `COMMAND_SCOPE`, `SKILL_SCOPE`: Scope constants with USER and PROJECT values.
- `ensureDirs()`: Creates required OpenCode directories.
- `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter.
@@ -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 via `scope`; 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.
@@ -1070,6 +1070,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
req.path.startsWith('/api/push') ||
req.path.startsWith('/api/notifications') ||
req.path.startsWith('/api/permission-auto-accept') ||
req.path.startsWith('/api/provider') ||
req.path.startsWith('/api/session-folders') ||
req.path.startsWith('/api/small-model') ||
req.path.startsWith('/api/walkthrough') ||
@@ -127,6 +127,37 @@ describe('core-routes', () => {
expect(response.body).toEqual({ body: { content: 'Snippet body' } });
});
it('should parse JSON bodies for custom provider upsert routes', async () => {
const app = express();
registerCommonRequestMiddleware(app, { express });
app.put('/api/provider', (req, res) => {
res.json({ body: req.body });
});
const response = await request(app)
.put('/api/provider')
.send({
providerID: 'campus-llm',
config: {
name: 'Campus LLM',
options: { baseURL: 'https://llm.example.edu/v1' },
models: { fast: { name: 'Fast' } },
},
})
.expect(200);
expect(response.body).toEqual({
body: {
providerID: 'campus-llm',
config: {
name: 'Campus LLM',
options: { baseURL: 'https://llm.example.edu/v1' },
models: { fast: { name: 'Fast' } },
},
},
});
});
it('should require API auth before probing loopback preview URLs', async () => {
const app = express();
const originalFetch = globalThis.fetch;
@@ -19,7 +19,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';
@@ -145,6 +145,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,162 @@ 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, 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-_]*$/' };
}
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 optionsBlock = isPlainObject(config.options) ? config.options : null;
if (!optionsBlock) {
return { ok: false, error: 'Provider options are required' };
}
const baseURL = typeof optionsBlock.baseURL === 'string' ? optionsBlock.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,
};
let env = [];
if (Array.isArray(config.env)) {
env = config.env
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
.map((entry) => entry.trim());
if (env.length > 0) {
normalized.env = env;
}
}
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(optionsBlock.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', options = {}) {
const validated = validateCustomProviderConfig(providerId, config, options);
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 +253,6 @@ function removeProviderConfig(providerId, workingDirectory, scope = 'user') {
export {
getProviderSources,
removeProviderConfig,
upsertProviderConfig,
validateCustomProviderConfig,
};
@@ -0,0 +1,261 @@
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('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',
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' } },
env: ['CAMPUS_KEY'],
}, 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' } },
env: ['TEMP_KEY'],
}, 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' } },
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);
});
test('project-scope edit updates project layer without creating a user entry', () => {
const providerId = `proj-scope-${Date.now()}`;
const configPath = path.join(projectDir, 'opencode.json');
upsertProviderConfig(providerId, {
name: 'Project Scoped',
options: { baseURL: 'https://project.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'project', { hasStoredAuth: true });
upsertProviderConfig(providerId, {
name: 'Project Scoped Updated',
options: { baseURL: 'https://project.example.com/v2', headers: { 'X-Project': '1' } },
models: { m: { name: 'M2' } },
}, projectDir, 'project', { hasStoredAuth: true });
const written = readJson(configPath);
expect(written.provider[providerId]).toEqual({
npm: '@ai-sdk/openai-compatible',
name: 'Project Scoped Updated',
options: {
baseURL: 'https://project.example.com/v2',
headers: { 'X-Project': '1' },
},
models: { m: { name: 'M2' } },
});
const sources = getProviderSources(providerId, projectDir);
expect(sources.sources.project.exists).toBe(true);
expect(sources.sources.user.exists).toBe(false);
expect(sources.sources.custom.exists).toBe(false);
for (const userPath of [
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
]) {
if (!fs.existsSync(userPath)) continue;
const userConfig = readJson(userPath);
expect(userConfig.provider?.[providerId]).toBeUndefined();
expect(userConfig.providers?.[providerId]).toBeUndefined();
}
});
test('custom-scope edit updates custom layer without creating a user entry', () => {
const providerId = `custom-scope-${Date.now()}`;
const customPath = path.join(projectDir, 'custom-opencode.json');
const previousEnv = process.env.OPENCODE_CONFIG;
process.env.OPENCODE_CONFIG = customPath;
try {
upsertProviderConfig(providerId, {
name: 'Custom Scoped',
options: { baseURL: 'https://custom.example.com/v1' },
models: { m: { name: 'M' } },
}, projectDir, 'custom', { hasStoredAuth: true });
upsertProviderConfig(providerId, {
name: 'Custom Scoped Updated',
options: { baseURL: 'https://custom.example.com/v2' },
models: { n: { name: 'N' } },
}, projectDir, 'custom', { hasStoredAuth: true });
const written = readJson(customPath);
expect(written.provider[providerId].name).toBe('Custom Scoped Updated');
expect(written.provider[providerId].options.baseURL).toBe('https://custom.example.com/v2');
const sources = getProviderSources(providerId, projectDir);
expect(sources.sources.custom.exists).toBe(true);
expect(sources.sources.user.exists).toBe(false);
expect(sources.sources.project.exists).toBe(false);
for (const userPath of [
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
]) {
if (!fs.existsSync(userPath)) continue;
const userConfig = readJson(userPath);
expect(userConfig.provider?.[providerId]).toBeUndefined();
expect(userConfig.providers?.[providerId]).toBeUndefined();
}
} finally {
if (previousEnv === undefined) {
delete process.env.OPENCODE_CONFIG;
} else {
process.env.OPENCODE_CONFIG = previousEnv;
}
}
});
});
@@ -18,6 +18,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
resolveProjectDirectory,
getProviderSources,
removeProviderConfig,
upsertProviderConfig,
refreshOpenCodeAfterConfigChange,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
@@ -443,6 +444,64 @@ 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 { 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: upsertResult.providerId,
path: upsertResult.path,
config: upsertResult.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;
+4 -4
View File
@@ -11,9 +11,6 @@ const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json');
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null;
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
// ============== SCOPE TYPE CONSTANTS ==============
@@ -121,7 +118,10 @@ function getConfigPaths(workingDirectory) {
path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'),
],
projectPath: getProjectConfigPath(workingDirectory),
customPath: CUSTOM_CONFIG_FILE
// Resolve at call time so OPENCODE_CONFIG changes (and tests) take effect.
customPath: process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null,
};
}