457 lines
14 KiB
TypeScript
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));
|
|
}
|