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
@@ -61,6 +61,7 @@ The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can r
|
||||
- Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`).
|
||||
- Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`).
|
||||
- Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade.
|
||||
- Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`).
|
||||
|
||||
- `opencode-upgrade-runtime.ts`
|
||||
- Owns managed-versus-external capability decisions, latest-version checks, serialized OpenCode self-upgrades, and restart-after-upgrade behavior.
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { removeProviderConfig, getProviderSources } from './opencodeConfig';
|
||||
import { removeProviderConfig, getProviderSources, upsertProviderConfig } from './opencodeConfig';
|
||||
import { getProviderAuth, removeProviderAuth } from './opencodeAuth';
|
||||
import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders';
|
||||
import { fetchOpenCodeGoUsage } from './opencodeGoQuota';
|
||||
@@ -485,6 +485,63 @@ export async function handleSystemBridgeMessage(
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:provider:upsert': {
|
||||
const {
|
||||
providerID,
|
||||
providerId: providerIdAlias,
|
||||
config,
|
||||
scope,
|
||||
directory,
|
||||
} = (payload || {}) as {
|
||||
providerID?: string;
|
||||
providerId?: string;
|
||||
config?: unknown;
|
||||
scope?: string;
|
||||
directory?: string;
|
||||
};
|
||||
const providerId = (typeof providerID === 'string' && providerID.trim())
|
||||
|| (typeof providerIdAlias === 'string' && providerIdAlias.trim())
|
||||
|| '';
|
||||
if (!providerId) {
|
||||
return { id, type, success: false, error: 'Provider ID is required' };
|
||||
}
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
||||
return { id, type, success: false, error: 'Provider config is required' };
|
||||
}
|
||||
const normalizedScope = typeof scope === 'string' ? scope : 'user';
|
||||
if (normalizedScope !== 'user' && normalizedScope !== 'project' && normalizedScope !== 'custom') {
|
||||
return { id, type, success: false, error: 'Invalid scope' };
|
||||
}
|
||||
try {
|
||||
const workingDirectory = typeof directory === 'string' && directory.trim().length > 0
|
||||
? directory.trim()
|
||||
: ctx?.manager?.getWorkingDirectory();
|
||||
const result = upsertProviderConfig(
|
||||
providerId,
|
||||
config,
|
||||
workingDirectory,
|
||||
normalizedScope,
|
||||
);
|
||||
await ctx?.manager?.restart();
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
success: true,
|
||||
data: {
|
||||
success: true,
|
||||
providerId: result.providerId,
|
||||
path: result.path,
|
||||
config: result.config,
|
||||
requiresReload: true,
|
||||
reloadDelayMs: deps.clientReloadDelayMs,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return { id, type, success: false, error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
case 'api:quota:providers': {
|
||||
try {
|
||||
const providers = listConfiguredQuotaProviders();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user