import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { z } from "zod"; export type SupportedModel = string; export type AgentModelIcon = "bolt" | "sparkle"; export type AgentModelOption = { id: SupportedModel; label: string; description: string; icon: AgentModelIcon }; export const defaultModelConfigPath = fileURLToPath(new URL("../../agent-models.json", import.meta.url)); const text = z.string().trim().min(1); const modelSchema = z.object({ name: text.optional(), description: text.optional(), icon: z.enum(["bolt", "sparkle"]).default("sparkle"), model_id: text.optional(), disabled: z.boolean().default(false), selectable: z.boolean().default(true), capabilities: z.object({ tools: z.boolean().default(true), input: z.array(text).default(["text"]), output: z.array(text).default(["text"]) }).strict().default({}), limit: z.object({ context: z.number().int().positive().optional(), output: z.number().int().positive().optional() }).strict().optional(), compatibility: z.record(z.unknown()).optional(), }).strict(); const modelConfigSchema = z.object({ defaults: z.object({ chat: text, task: text, learning_gate: text, }).strict(), providers: z.record(z.object({ name: text, api_url: text, api_key: text.optional(), package: text.default("@opencode/ai/providers/openai-compatible"), models: z.record(modelSchema), }).strict()), }).strict(); export type AgentModelConfig = z.infer; export function parseModelReference(reference: string) { const separator = reference.indexOf("/"); if (separator < 1 || separator === reference.length - 1 || /[\s#]/.test(reference)) throw new Error("model reference must contain provider/model without whitespace or variants"); return { providerID: reference.slice(0, separator), modelID: reference.slice(separator + 1) }; } export const toRuntimeModel = (model?: string) => model ? parseModelReference(model) : undefined; function configuredToolModels(configuration: AgentModelConfig): AgentModelOption[] { return Object.entries(configuration.providers).flatMap(([providerID, provider]) => Object.entries(provider.models).filter(([, model]) => !model.disabled && model.capabilities.tools && model.capabilities.output.includes("text")).map(([modelID, model]) => ({ id: `${providerID}/${modelID}`, label: model.name ?? modelID, description: model.description ?? provider.name, icon: model.icon, })), ); } export function getAgentModelOptions(configuration: AgentModelConfig) { return configuredToolModels(configuration).filter((option) => { const { providerID, modelID } = parseModelReference(option.id); return configuration.providers[providerID]!.models[modelID]!.selectable; }); } export const getAgentModelIds = (options: AgentModelOption[]) => options.map((model) => model.id); export function parseAgentModelConfig(input: unknown): AgentModelConfig { const parsed = modelConfigSchema.safeParse(input); // Do not echo invalid values: provider configuration can contain credentials. if (!parsed.success) throw new Error("invalid agent model configuration structure; configure role models under defaults.chat, defaults.task and defaults.learning_gate"); const configuration = parsed.data; for (const [id, provider] of Object.entries(configuration.providers)) { if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error("provider ID must contain only letters, digits, _ or -"); try { const endpoint = provider.api_url.replace(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name: string) => { const value = process.env[name]?.trim(); if (!value) throw new Error(); return value; }); const url = new URL(endpoint); if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error(); } catch { throw new Error("provider api_url must be an HTTP(S) base URL without credentials, query or fragment"); } if (!Object.keys(provider.models).length) throw new Error("provider models must include at least one model"); for (const modelID of Object.keys(provider.models)) parseModelReference(`${id}/${modelID}`); } const allowed = getAgentModelIds(configuredToolModels(configuration)); for (const role of ["chat", "task", "learning_gate"] as const) { if (!allowed.includes(configuration.defaults[role])) throw new Error(`defaults.${role} must name an enabled configured text model with tool support`); } if (!getAgentModelIds(getAgentModelOptions(configuration)).includes(configuration.defaults.chat)) throw new Error("defaults.chat must be selectable in the chat catalog"); return configuration; } export function loadAgentModelConfig(path = process.env.AGENT_MODEL_CONFIG_PATH?.trim() || defaultModelConfigPath) { let input: unknown; try { input = JSON.parse(readFileSync(resolve(path), "utf8")); } catch { throw new Error("agent model configuration file must be readable valid JSON"); } return parseAgentModelConfig(input); } export function compileOpencodeModels(configuration: AgentModelConfig) { const providers = Object.fromEntries(Object.entries(configuration.providers).map(([id, provider]) => { const keyVariable = provider.api_key?.match(/^\{env:([A-Za-z_][A-Za-z0-9_]*)\}$/)?.[1]; return [id, { name: provider.name, package: provider.package, ...(keyVariable ? { env: [keyVariable] } : {}), settings: { baseURL: provider.api_url, ...(provider.api_key ? { apiKey: provider.api_key } : {}) }, models: Object.fromEntries(Object.entries(provider.models).map(([modelID, model]) => [modelID, { name: model.name ?? modelID, disabled: model.disabled, capabilities: model.capabilities, ...(model.model_id ? { modelID: model.model_id } : {}), ...(model.limit ? { limit: model.limit } : {}), ...(model.compatibility ? { compatibility: model.compatibility } : {}), }])), }]; })); return { model: configuration.defaults.chat, providers }; }