feat(agent): unify models and validate OpenCode providers

Generate chat, task and learning models from agent-models.json.
Use WaterNetwork fast/expert models with 272k context and validate
upstream names before readiness. Add an isolated model check command
with signal-safe cleanup and plugin, Skill and MCP management docs.

Verified in isolation: type checks, 26 focused tests and both model
catalog checks; no model inference requests.
This commit is contained in:
2026-10-09 11:28:37 +08:00
parent 75d8d526f9
commit d58bd19039
30 changed files with 1125 additions and 469 deletions
+96 -69
View File
@@ -1,78 +1,105 @@
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));
export type AgentModelOption = {
id: SupportedModel;
label: string;
description: string;
icon: AgentModelIcon;
};
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({
default_model: text, task_model: text.optional(), learning_gate_model: text.optional(),
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<typeof modelConfigSchema>;
export const defaultAgentModelOptions: AgentModelOption[] = [
{
id: "deepseek/deepseek-flash",
label: "快速",
description: "快速回答和任务执行",
icon: "bolt",
},
{
id: "deepseek/deepseek-v4-pro",
label: "专家",
description: "探索、解决复杂任务",
icon: "sparkle",
},
];
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 defaultAgentModelOptionsJson = JSON.stringify(defaultAgentModelOptions);
export const toRuntimeModel = (model?: string) => model ? parseModelReference(model) : undefined;
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
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 const parseAgentModelOptions = (value: string): AgentModelOption[] => {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new Error("OPENCODE_MODEL_OPTIONS must be valid JSON");
}
if (!Array.isArray(parsed)) {
throw new Error("OPENCODE_MODEL_OPTIONS must be a JSON array");
}
const seen = new Set<string>();
return parsed.map((item, index) => {
if (!isObjectRecord(item)) {
throw new Error(`OPENCODE_MODEL_OPTIONS[${index}] must be an object`);
}
const id = typeof item.id === "string" ? item.id.trim() : "";
if (!id) {
throw new Error(`OPENCODE_MODEL_OPTIONS[${index}].id is required`);
}
if (seen.has(id)) {
throw new Error(`duplicate OPENCODE_MODEL_OPTIONS id: ${id}`);
}
seen.add(id);
const label =
typeof item.label === "string" && item.label.trim()
? item.label.trim()
: id;
const description =
typeof item.description === "string" && item.description.trim()
? item.description.trim()
: label;
const icon = item.icon === "bolt" || item.icon === "sparkle"
? item.icon
: "sparkle";
return {
id,
label,
description,
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((option) => option.id);
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");
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 ["default_model", "task_model", "learning_gate_model"] as const) {
if (configuration[role] && !allowed.includes(configuration[role]!)) throw new Error(`${role} must name an enabled configured text model with tool support`);
}
if (!getAgentModelIds(getAgentModelOptions(configuration)).includes(configuration.default_model)) throw new Error("default_model 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.default_model, providers };
}