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:
Cursor Agent
2026-08-02 09:12:28 +00:00
co-authored by Serhii Dziupin
parent 5d24d6cb2a
commit be87e25c7d
25 changed files with 1980 additions and 33 deletions
+147
View File
@@ -2168,6 +2168,153 @@ export const removeProviderConfig = (providerId: string, workingDirectory?: stri
return true;
};
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
const BASE_URL_PATTERN = /^https?:\/\//;
const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible';
export const validateCustomProviderConfig = (providerId: string, config: unknown) => {
if (!providerId || typeof providerId !== 'string' || !PROVIDER_ID_PATTERN.test(providerId)) {
return { ok: false as const, error: 'Provider ID must match /^[a-z0-9][a-z0-9-_]*$/' };
}
if (!isPlainObject(config)) {
return { ok: false as const, error: 'Provider config must be an object' };
}
const name = typeof config.name === 'string' ? config.name.trim() : '';
if (!name) {
return { ok: false as const, 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 as const, error: `Custom providers must use npm package ${OPENAI_COMPATIBLE_NPM}` };
}
const options = isPlainObject(config.options) ? config.options : null;
if (!options) {
return { ok: false as const, error: 'Provider options are required' };
}
const baseURL = typeof options.baseURL === 'string' ? options.baseURL.trim() : '';
if (!baseURL) {
return { ok: false as const, error: 'Base URL is required' };
}
if (!BASE_URL_PATTERN.test(baseURL)) {
return { ok: false as const, 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 as const, error: 'At least one model is required' };
}
const normalizedModels: Record<string, { name: string }> = {};
for (const [modelId, modelValue] of Object.entries(models)) {
const trimmedId = typeof modelId === 'string' ? modelId.trim() : '';
if (!trimmedId) {
return { ok: false as const, error: 'Model id is required' };
}
if (!isPlainObject(modelValue)) {
return { ok: false as const, error: `Model "${trimmedId}" must be an object` };
}
const modelName = typeof modelValue.name === 'string' ? modelValue.name.trim() : '';
if (!modelName) {
return { ok: false as const, error: `Model "${trimmedId}" requires a name` };
}
normalizedModels[trimmedId] = { name: modelName };
}
const normalized: Record<string, unknown> = {
npm: OPENAI_COMPATIBLE_NPM,
name,
options: {
baseURL,
},
models: normalizedModels,
};
if (Array.isArray(config.env)) {
const env = config.env
.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0)
.map((entry) => entry.trim());
if (env.length > 0) {
normalized.env = env;
}
}
if (isPlainObject(options.headers)) {
const headers: Record<string, string> = {};
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 as const, error: `Header "${headerKey}" requires a non-empty value` };
}
headers[headerKey.trim()] = headerValue.trim();
}
if (Object.keys(headers).length > 0) {
(normalized.options as Record<string, unknown>).headers = headers;
}
}
return { ok: true as const, value: { providerId, config: normalized } };
};
export const upsertProviderConfig = (
providerId: string,
config: unknown,
workingDirectory?: string,
scope: 'user' | 'project' | 'custom' = 'user',
) => {
const validated = validateCustomProviderConfig(providerId, config);
if (!validated.ok) {
const error = new Error(validated.error) as Error & { statusCode?: number };
error.statusCode = 400;
throw error;
}
const layers = readConfigLayers(workingDirectory);
let targetPath: string | null | undefined = 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) as Record<string, unknown>;
const providerConfig = isPlainObject(targetConfig.provider)
? { ...(targetConfig.provider as Record<string, unknown>) }
: {};
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,
};
};
export const deleteCommand = (commandName: string, workingDirectory?: string) => {
let deleted = false;