Files
makelore/electron/opencode/provider-config.ts
2026-07-29 17:22:35 +08:00

457 lines
14 KiB
TypeScript

import type { ProviderAccount, ProviderProtocol } from '../shared/providers/types';
import {
getProviderBackendConfig,
getProviderDefaultModel,
getProviderDefinition,
} from '../shared/providers/registry';
import { getProviderService } from '../services/providers/provider-service';
import { getApiKey } from '../utils/secure-storage';
import {
resolveOpencodeProviderId,
sanitizeOpencodeProviderId,
} from '../../shared/opencode-provider-id';
import {
NIANCODE_USER_MODEL_ACCOUNT_ID,
normalizeImportedUserModelId,
selectUserModelRuntimeAccounts,
} from '../../shared/user-model-config';
import { getImportedModelProfile } from '../../shared/imported-model-profile';
const OPENCODE_CONFIG_SCHEMA = 'https://opencode.ai/config.json';
const OPENCODE_ENV_PREFIX = 'NIANCODE_OPENCODE';
const OPENAI_COMPATIBLE_PACKAGE = '@ai-sdk/openai-compatible';
const OPENAI_PACKAGE = '@ai-sdk/openai';
const ANTHROPIC_PACKAGE = '@ai-sdk/anthropic';
const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway';
const OPENCODE_BUILTIN_PROVIDER_IDS = new Set([
'anthropic',
'openai',
'google',
'openrouter',
'moonshot',
'deepseek',
]);
export interface OpencodeProviderEntry {
npm?: string;
name?: string;
options?: Record<string, unknown>;
models?: Record<string, OpencodeProviderModelEntry>;
}
export interface OpencodeMcpServerEntry {
type: 'local';
command: string[];
enabled: boolean;
env?: Record<string, string>;
}
type OpencodeModelModality = 'text' | 'audio' | 'image' | 'pdf';
export interface OpencodeModelLimit {
context: number;
output: number;
}
export interface OpencodeProviderModelEntry {
name: string;
modalities?: {
input: OpencodeModelModality[];
output: OpencodeModelModality[];
};
limit?: OpencodeModelLimit;
}
export interface OpencodeRuntimeConfig {
'$schema': string;
enabled_providers: string[];
permission: {
external_directory: 'ask';
};
provider: Record<string, OpencodeProviderEntry>;
mcp?: Record<string, OpencodeMcpServerEntry>;
model?: string;
small_model?: string;
}
export interface OpencodeRuntimeConfigResult {
config: OpencodeRuntimeConfig;
env: Record<string, string>;
}
export interface OpencodeRuntimeConfigSummary {
model: string | null;
smallModel: string | null;
providerIds: string[];
enabledProviderIds: string[];
providerCount: number;
providers: OpencodeProviderSummary[];
mcpServerIds: string[];
mcpServerCount: number;
mcpServers: OpencodeMcpServerSummary[];
}
export interface OpencodeProviderSummary {
id: string;
name?: string;
npm?: string;
baseURL?: string;
modelIds: string[];
imageInputModelIds?: string[];
modelLimits?: Record<string, OpencodeModelLimit>;
hasApiKey: boolean;
apiKeyEnv?: string;
headerNames: string[];
}
export interface OpencodeMcpServerSummary {
id: string;
type: string;
enabled: boolean;
command: string[];
envKeys: string[];
}
export type ResolveOpencodeProviderApiKey = (
account: ProviderAccount,
opencodeProviderId: string,
) => Promise<string | null>;
export interface BuildOpencodeRuntimeConfigOptions {
accounts: ProviderAccount[];
defaultAccountId?: string | null;
resolveApiKey: ResolveOpencodeProviderApiKey;
mcpServers?: Record<string, OpencodeMcpServerEntry>;
}
function envVarNameForAccount(accountId: string): string {
const suffix = sanitizeOpencodeProviderId(accountId)
.replace(/-/g, '_')
.toUpperCase();
return `${OPENCODE_ENV_PREFIX}_${suffix}_API_KEY`;
}
function normalizeBaseUrl(baseUrl: string | undefined): string | undefined {
const trimmed = baseUrl?.trim().replace(/\/+$/, '');
return trimmed || undefined;
}
function normalizeRuntimeBaseUrlForAccount(
account: ProviderAccount,
baseUrl: string | undefined,
): string | undefined {
if (!baseUrl || account.metadata?.worksSquareCredentialMode !== WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE) {
return baseUrl;
}
try {
const parsed = new URL(baseUrl);
if (parsed.pathname === '' || parsed.pathname === '/') {
parsed.pathname = '/v1';
return parsed.toString().replace(/\/+$/, '');
}
} catch {
return baseUrl;
}
return baseUrl;
}
function headersForAccount(
account: ProviderAccount,
apiKeyEnvVarName: string | undefined,
): Record<string, string> | undefined {
const headers = { ...(account.headers ?? {}) };
if (
apiKeyEnvVarName
&& account.metadata?.worksSquareCredentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE
&& !Object.keys(headers).some((key) => key.toLowerCase() === 'authorization')
) {
headers.Authorization = `Bearer {env:${apiKeyEnvVarName}}`;
}
return Object.keys(headers).length > 0 ? headers : undefined;
}
function protocolForAccount(account: ProviderAccount): ProviderProtocol | undefined {
return account.apiProtocol ?? getProviderBackendConfig(account.vendorId)?.api;
}
function packageForAccount(account: ProviderAccount): string | undefined {
const hasExplicitRuntimeShape = Boolean(account.baseUrl || account.apiProtocol);
if (
!hasExplicitRuntimeShape
&& OPENCODE_BUILTIN_PROVIDER_IDS.has(account.vendorId)
) {
return undefined;
}
const protocol = protocolForAccount(account);
if (protocol === 'openai-responses') {
return OPENAI_PACKAGE;
}
if (protocol === 'anthropic-messages') {
return ANTHROPIC_PACKAGE;
}
return OPENAI_COMPATIBLE_PACKAGE;
}
function baseUrlForAccount(account: ProviderAccount): string | undefined {
return normalizeRuntimeBaseUrlForAccount(account, normalizeBaseUrl(
account.baseUrl
?? getProviderBackendConfig(account.vendorId)?.baseUrl
?? getProviderDefinition(account.vendorId)?.defaultBaseUrl,
));
}
function normalizeModelIdForAccount(
rawModel: string | undefined,
opencodeProviderId: string,
account: ProviderAccount,
): string | undefined {
const trimmed = rawModel?.trim();
if (!trimmed) {
return undefined;
}
const providerPrefix = `${opencodeProviderId}/`;
const modelId = trimmed.startsWith(providerPrefix)
? trimmed.slice(providerPrefix.length)
: trimmed;
return account.id === NIANCODE_USER_MODEL_ACCOUNT_ID
? normalizeImportedUserModelId(modelId)
: modelId;
}
function modelIdsForAccount(account: ProviderAccount, opencodeProviderId: string): string[] {
const modelIds: string[] = [];
const seen = new Set<string>();
const rawModels = [
account.model ?? getProviderDefaultModel(account.vendorId),
...(account.fallbackModels ?? []),
];
for (const rawModel of rawModels) {
const modelId = normalizeModelIdForAccount(rawModel, opencodeProviderId, account);
if (!modelId || seen.has(modelId)) {
continue;
}
seen.add(modelId);
modelIds.push(modelId);
}
return modelIds;
}
function modelEntryForAccount(account: ProviderAccount, modelId: string): OpencodeProviderModelEntry {
const entry: OpencodeProviderModelEntry = { name: modelId };
if (account.id !== NIANCODE_USER_MODEL_ACCOUNT_ID) return entry;
const profile = getImportedModelProfile(modelId);
if (profile?.modalities) {
entry.modalities = {
input: [...profile.modalities.input],
output: [...profile.modalities.output],
};
}
if (profile?.limit) {
entry.limit = { ...profile.limit };
}
return entry;
}
function modelEntryHasImageInput(model: OpencodeProviderModelEntry | undefined): boolean {
return model?.modalities?.input.includes('image') ?? false;
}
function accountNeedsApiKey(account: ProviderAccount): boolean {
return account.authMode !== 'local' && account.vendorId !== 'ollama';
}
function sortAccountsForSelection(
accounts: ProviderAccount[],
defaultAccountId?: string | null,
): ProviderAccount[] {
return [...accounts].sort((left, right) => {
if (left.id === defaultAccountId) return -1;
if (right.id === defaultAccountId) return 1;
return 0;
});
}
export async function buildOpencodeRuntimeConfig(
options: BuildOpencodeRuntimeConfigOptions,
): Promise<OpencodeRuntimeConfigResult> {
const config: OpencodeRuntimeConfig = {
'$schema': OPENCODE_CONFIG_SCHEMA,
enabled_providers: [],
permission: {
external_directory: 'ask',
},
provider: {},
};
if (options.mcpServers && Object.keys(options.mcpServers).length > 0) {
config.mcp = options.mcpServers;
}
const env: Record<string, string> = {};
let selectedModel: string | undefined;
for (const account of sortAccountsForSelection(
selectUserModelRuntimeAccounts(options.accounts),
options.defaultAccountId,
)) {
if (!account.enabled) {
continue;
}
const opencodeProviderId = resolveOpencodeProviderId(account);
if (config.provider[opencodeProviderId]) {
continue;
}
const apiKey = await options.resolveApiKey(account, opencodeProviderId);
if (!apiKey && accountNeedsApiKey(account)) {
continue;
}
const providerEntry: OpencodeProviderEntry = {};
const providerPackage = packageForAccount(account);
if (providerPackage) {
providerEntry.npm = providerPackage;
providerEntry.name = account.label;
}
const entryOptions: Record<string, unknown> = {};
const baseURL = providerPackage ? baseUrlForAccount(account) : normalizeBaseUrl(account.baseUrl);
if (baseURL) {
entryOptions.baseURL = baseURL;
}
let envVarName: string | undefined;
if (apiKey) {
envVarName = envVarNameForAccount(account.id);
env[envVarName] = apiKey;
entryOptions.apiKey = `{env:${envVarName}}`;
}
const headers = headersForAccount(account, envVarName);
if (headers) {
entryOptions.headers = headers;
}
if (Object.keys(entryOptions).length > 0) {
providerEntry.options = entryOptions;
}
const modelIds = modelIdsForAccount(account, opencodeProviderId);
if (modelIds.length > 0) {
providerEntry.models = Object.fromEntries(
modelIds.map((modelId) => [modelId, modelEntryForAccount(account, modelId)]),
);
}
const primaryModelId = modelIds[0];
config.provider[opencodeProviderId] = providerEntry;
const modelRef = primaryModelId ? `${opencodeProviderId}/${primaryModelId}` : undefined;
if (modelRef && (account.id === options.defaultAccountId || !selectedModel)) {
selectedModel = modelRef;
}
}
if (selectedModel) {
config.model = selectedModel;
}
config.enabled_providers = Object.keys(config.provider);
return { config, env };
}
async function resolveNianCodeApiKey(
account: ProviderAccount,
_opencodeProviderId: string,
): Promise<string | null> {
return await getApiKey(account.id);
}
export interface BuildOpencodeRuntimeConfigFromNianCodeProvidersOptions {
mcpServers?: Record<string, OpencodeMcpServerEntry>;
}
export async function buildOpencodeRuntimeConfigFromNianCodeProviders(
options: BuildOpencodeRuntimeConfigFromNianCodeProvidersOptions = {},
): Promise<OpencodeRuntimeConfigResult> {
const providerService = getProviderService();
const [accounts, defaultAccountId] = await Promise.all([
providerService.listAccounts(),
providerService.getDefaultAccountId(),
]);
return buildOpencodeRuntimeConfig({
accounts,
defaultAccountId,
resolveApiKey: resolveNianCodeApiKey,
mcpServers: options.mcpServers,
});
}
export function summarizeOpencodeRuntimeConfig(
result: OpencodeRuntimeConfigResult,
): OpencodeRuntimeConfigSummary {
const providerIds = Object.keys(result.config.provider);
const mcp = result.config.mcp ?? {};
const mcpServerIds = Object.keys(mcp);
const mcpServers = mcpServerIds.map((serverId) => {
const server = mcp[serverId];
return {
id: serverId,
type: server.type,
enabled: server.enabled,
command: [...server.command],
envKeys: Object.keys(server.env ?? {}).sort(),
};
});
const providers = providerIds.map((providerId) => {
const provider = result.config.provider[providerId];
const options = provider?.options ?? {};
const rawApiKey = typeof options.apiKey === 'string' ? options.apiKey : undefined;
const apiKeyEnv = rawApiKey?.match(/^\{env:([^}]+)\}$/)?.[1];
const headers = options.headers && typeof options.headers === 'object' && !Array.isArray(options.headers)
? options.headers as Record<string, unknown>
: {};
const models = provider?.models ?? {};
const modelLimits: Record<string, OpencodeModelLimit> = Object.fromEntries(
Object.entries(models).flatMap(([modelId, model]) => (
model.limit ? [[modelId, { ...model.limit }] as const] : []
)),
);
return {
id: providerId,
...(provider?.name ? { name: provider.name } : {}),
...(provider?.npm ? { npm: provider.npm } : {}),
...(typeof options.baseURL === 'string' ? { baseURL: options.baseURL } : {}),
modelIds: Object.keys(models),
imageInputModelIds: Object.entries(models).flatMap(([modelId, model]) => (
modelEntryHasImageInput(model) ? [modelId] : []
)),
...(Object.keys(modelLimits).length > 0 ? { modelLimits } : {}),
hasApiKey: Boolean(rawApiKey),
...(apiKeyEnv ? { apiKeyEnv } : {}),
headerNames: Object.keys(headers),
};
});
return {
model: result.config.model ?? null,
smallModel: result.config.small_model ?? null,
providerIds,
enabledProviderIds: result.config.enabled_providers ?? providerIds,
providerCount: providerIds.length,
providers,
mcpServerIds,
mcpServerCount: mcpServerIds.length,
mcpServers,
};
}
export async function buildOpencodeRuntimeConfigSummaryFromNianCodeProviders(
options: BuildOpencodeRuntimeConfigFromNianCodeProvidersOptions = {},
): Promise<OpencodeRuntimeConfigSummary> {
return summarizeOpencodeRuntimeConfig(await buildOpencodeRuntimeConfigFromNianCodeProviders(options));
}