2026-02-20 10:57:44 -03:00
|
|
|
import {
|
|
|
|
|
CONFIG_FILE,
|
|
|
|
|
readConfigLayers,
|
|
|
|
|
isPlainObject,
|
|
|
|
|
getConfigForPath,
|
|
|
|
|
writeConfig,
|
|
|
|
|
} from './shared.js';
|
|
|
|
|
|
2026-08-02 09:12:28 +00:00
|
|
|
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
|
|
|
|
|
const BASE_URL_PATTERN = /^https?:\/\//;
|
|
|
|
|
const OPENAI_COMPATIBLE_NPM = '@ai-sdk/openai-compatible';
|
|
|
|
|
|
2026-02-20 10:57:44 -03:00
|
|
|
function getProviderSources(providerId, workingDirectory) {
|
|
|
|
|
const layers = readConfigLayers(workingDirectory);
|
|
|
|
|
const { userConfig, projectConfig, customConfig, paths } = layers;
|
|
|
|
|
|
|
|
|
|
const customProviders = isPlainObject(customConfig?.provider) ? customConfig.provider : {};
|
|
|
|
|
const customProvidersAlias = isPlainObject(customConfig?.providers) ? customConfig.providers : {};
|
|
|
|
|
const projectProviders = isPlainObject(projectConfig?.provider) ? projectConfig.provider : {};
|
|
|
|
|
const projectProvidersAlias = isPlainObject(projectConfig?.providers) ? projectConfig.providers : {};
|
|
|
|
|
const userProviders = isPlainObject(userConfig?.provider) ? userConfig.provider : {};
|
|
|
|
|
const userProvidersAlias = isPlainObject(userConfig?.providers) ? userConfig.providers : {};
|
|
|
|
|
|
|
|
|
|
const customExists =
|
|
|
|
|
Object.prototype.hasOwnProperty.call(customProviders, providerId) ||
|
|
|
|
|
Object.prototype.hasOwnProperty.call(customProvidersAlias, providerId);
|
|
|
|
|
const projectExists =
|
|
|
|
|
Object.prototype.hasOwnProperty.call(projectProviders, providerId) ||
|
|
|
|
|
Object.prototype.hasOwnProperty.call(projectProvidersAlias, providerId);
|
|
|
|
|
const userExists =
|
|
|
|
|
Object.prototype.hasOwnProperty.call(userProviders, providerId) ||
|
|
|
|
|
Object.prototype.hasOwnProperty.call(userProvidersAlias, providerId);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
sources: {
|
|
|
|
|
auth: { exists: false },
|
|
|
|
|
user: { exists: userExists, path: paths.userPath },
|
|
|
|
|
project: { exists: projectExists, path: paths.projectPath || null },
|
|
|
|
|
custom: { exists: customExists, path: paths.customPath }
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 09:12:28 +00:00
|
|
|
/**
|
|
|
|
|
* Validate a custom OpenAI-compatible provider config payload before persistence.
|
|
|
|
|
* Returns { ok: true, value } or { ok: false, error }.
|
2026-08-02 11:40:02 +00:00
|
|
|
*
|
|
|
|
|
* 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).
|
2026-08-02 09:12:28 +00:00
|
|
|
*/
|
2026-08-02 11:40:02 +00:00
|
|
|
function validateCustomProviderConfig(providerId, config, options = {}) {
|
2026-08-02 09:12:28 +00:00
|
|
|
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}` };
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 11:40:02 +00:00
|
|
|
const optionsBlock = isPlainObject(config.options) ? config.options : null;
|
|
|
|
|
if (!optionsBlock) {
|
2026-08-02 09:12:28 +00:00
|
|
|
return { ok: false, error: 'Provider options are required' };
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 11:40:02 +00:00
|
|
|
const baseURL = typeof optionsBlock.baseURL === 'string' ? optionsBlock.baseURL.trim() : '';
|
2026-08-02 09:12:28 +00:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-02 11:40:02 +00:00
|
|
|
let env = [];
|
2026-08-02 09:12:28 +00:00
|
|
|
if (Array.isArray(config.env)) {
|
2026-08-02 11:40:02 +00:00
|
|
|
env = config.env
|
2026-08-02 09:12:28 +00:00
|
|
|
.filter((entry) => typeof entry === 'string' && entry.trim().length > 0)
|
|
|
|
|
.map((entry) => entry.trim());
|
|
|
|
|
if (env.length > 0) {
|
|
|
|
|
normalized.env = env;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 11:40:02 +00:00
|
|
|
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)) {
|
2026-08-02 09:12:28 +00:00
|
|
|
const headers = {};
|
2026-08-02 11:40:02 +00:00
|
|
|
for (const [headerKey, headerValue] of Object.entries(optionsBlock.headers)) {
|
2026-08-02 09:12:28 +00:00
|
|
|
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.
|
|
|
|
|
*/
|
2026-08-02 11:40:02 +00:00
|
|
|
function upsertProviderConfig(providerId, config, workingDirectory, scope = 'user', options = {}) {
|
|
|
|
|
const validated = validateCustomProviderConfig(providerId, config, options);
|
2026-08-02 09:12:28 +00:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 10:57:44 -03:00
|
|
|
function removeProviderConfig(providerId, workingDirectory, scope = 'user') {
|
|
|
|
|
if (!providerId || typeof providerId !== 'string') {
|
|
|
|
|
throw new Error('Provider ID is required');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
targetPath = layers.paths.customPath;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const targetConfig = getConfigForPath(layers, targetPath);
|
|
|
|
|
const providerConfig = isPlainObject(targetConfig.provider) ? targetConfig.provider : {};
|
|
|
|
|
const providersConfig = isPlainObject(targetConfig.providers) ? targetConfig.providers : {};
|
|
|
|
|
const removedProvider = Object.prototype.hasOwnProperty.call(providerConfig, providerId);
|
|
|
|
|
const removedProviders = Object.prototype.hasOwnProperty.call(providersConfig, providerId);
|
|
|
|
|
|
|
|
|
|
if (!removedProvider && !removedProviders) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (removedProvider) {
|
|
|
|
|
delete providerConfig[providerId];
|
|
|
|
|
if (Object.keys(providerConfig).length === 0) {
|
|
|
|
|
delete targetConfig.provider;
|
|
|
|
|
} else {
|
|
|
|
|
targetConfig.provider = providerConfig;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (removedProviders) {
|
|
|
|
|
delete providersConfig[providerId];
|
|
|
|
|
if (Object.keys(providersConfig).length === 0) {
|
|
|
|
|
delete targetConfig.providers;
|
|
|
|
|
} else {
|
|
|
|
|
targetConfig.providers = providersConfig;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeConfig(targetConfig, targetPath || CONFIG_FILE);
|
|
|
|
|
console.log(`Removed provider ${providerId} from config: ${targetPath}`);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export {
|
|
|
|
|
getProviderSources,
|
|
|
|
|
removeProviderConfig,
|
2026-08-02 09:12:28 +00:00
|
|
|
upsertProviderConfig,
|
|
|
|
|
validateCustomProviderConfig,
|
2026-02-20 10:57:44 -03:00
|
|
|
};
|