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:
+60
-36
@@ -6,7 +6,9 @@ import {
|
||||
resolveDefaultModel,
|
||||
supportedModels,
|
||||
} from "../../src/chat/models.js";
|
||||
import { parseAgentModelOptions } from "../../src/chat/modelConfig.js";
|
||||
import { compileOpencodeModels, getAgentModelOptions, parseAgentModelConfig, parseModelReference } from "../../src/chat/modelConfig.js";
|
||||
import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
|
||||
import { modelCheckTargets } from "../../src/runtime/model-check.js";
|
||||
|
||||
describe("agent model config", () => {
|
||||
it("keeps every exposed option in the supported model list", () => {
|
||||
@@ -24,47 +26,69 @@ describe("agent model config", () => {
|
||||
});
|
||||
|
||||
it("rejects unsupported default models", () => {
|
||||
expect(resolveDefaultModel("deepseek/deepseek-v4-pro")).toBe(
|
||||
"deepseek/deepseek-v4-pro",
|
||||
);
|
||||
expect(resolveDefaultModel(supportedModels[0]!)).toBe(supportedModels[0]!);
|
||||
expect(() => resolveDefaultModel("unknown/model")).toThrow(
|
||||
"unsupported default agent model",
|
||||
);
|
||||
});
|
||||
|
||||
it("parses full model option config from JSON", () => {
|
||||
expect(
|
||||
parseAgentModelOptions(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "provider/model",
|
||||
label: "自定义",
|
||||
description: "自定义模型",
|
||||
icon: "bolt",
|
||||
},
|
||||
]),
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
id: "provider/model",
|
||||
label: "自定义",
|
||||
description: "自定义模型",
|
||||
icon: "bolt",
|
||||
},
|
||||
]);
|
||||
it("generates native providers and the public catalog from one custom provider definition", () => {
|
||||
const configuration = parseAgentModelConfig({
|
||||
default_model: "custom/chat", task_model: "custom/org/model", learning_gate_model: "custom/chat",
|
||||
providers: { custom: { name: "自定义服务", api_url: "https://example.com/v1", api_key: "{env:CUSTOM_API_KEY}", models: {
|
||||
chat: { name: "自定义", description: "自定义模型", icon: "bolt", model_id: "wire-chat" },
|
||||
"org/model": {}, hidden: { selectable: false }, image: { disabled: true, capabilities: { tools: false, output: ["image"] } },
|
||||
} } },
|
||||
});
|
||||
const options = getAgentModelOptions(configuration);
|
||||
expect(options.map((model) => model.id)).toEqual(["custom/chat", "custom/org/model"]);
|
||||
expect(options[0]).toEqual({ id: "custom/chat", label: "自定义", description: "自定义模型", icon: "bolt" });
|
||||
const native = compileOpencodeModels(configuration);
|
||||
expect(native.providers.custom).toMatchObject({
|
||||
env: ["CUSTOM_API_KEY"], package: "@opencode/ai/providers/openai-compatible",
|
||||
settings: { baseURL: "https://example.com/v1", apiKey: "{env:CUSTOM_API_KEY}" },
|
||||
models: { chat: { modelID: "wire-chat", capabilities: { tools: true } }, image: { disabled: true } },
|
||||
});
|
||||
expect(JSON.stringify(options)).not.toContain("CUSTOM_API_KEY");
|
||||
expect(JSON.stringify(options)).not.toContain("example.com");
|
||||
expect(modelCheckTargets(configuration).models.map((model) => model.id)).toEqual(["custom/chat", "custom/org/model", "custom/hidden"]);
|
||||
expect(modelCheckTargets(configuration).models[0]!.wire_id).toBe("wire-chat");
|
||||
const service = serviceOpencodeConfig({ model: "stale/model", providers: { stale: {} }, agents: { instruction: { model: "stale/model" } } }, configuration);
|
||||
expect(service.model).toBe("custom/chat");
|
||||
expect(service.agents.instruction.model).toBe("custom/chat");
|
||||
expect(Object.keys(service.providers)).toEqual(["custom"]);
|
||||
});
|
||||
|
||||
it("rejects invalid model option config", () => {
|
||||
expect(() => parseAgentModelOptions("not-json")).toThrow(
|
||||
"OPENCODE_MODEL_OPTIONS must be valid JSON",
|
||||
);
|
||||
expect(() =>
|
||||
parseAgentModelOptions(
|
||||
JSON.stringify([
|
||||
{ id: "provider/model", label: "模型" },
|
||||
{ id: "provider/model", label: "重复模型" },
|
||||
]),
|
||||
),
|
||||
).toThrow("duplicate OPENCODE_MODEL_OPTIONS id");
|
||||
it("rejects unknown, disabled or non-tool role models and invalid endpoints without echoing keys", () => {
|
||||
const base = { default_model: "custom/chat", providers: { custom: { name: "Custom", api_url: "https://example.com/v1", api_key: "TEST_ONLY_SECRET", models: { chat: {} } } } };
|
||||
for (const role of ["default_model", "task_model", "learning_gate_model"]) expect(() => parseAgentModelConfig({ ...base, [role]: "custom/missing" })).toThrow("enabled configured text model");
|
||||
for (const model of [{ disabled: true }, { capabilities: { tools: false } }, { capabilities: { output: ["image"] } }]) {
|
||||
expect(() => parseAgentModelConfig({ ...base, providers: { custom: { ...base.providers.custom, models: { chat: model } } } })).toThrow();
|
||||
}
|
||||
for (const url of ["file:///secret", "https://user:TEST_ONLY_SECRET@example.com/v1", "https://example.com/v1?key=TEST_ONLY_SECRET"]) {
|
||||
try { parseAgentModelConfig({ ...base, providers: { custom: { ...base.providers.custom, api_url: url } } }); throw new Error("accepted invalid URL"); }
|
||||
catch (error) { expect(String(error)).not.toContain("TEST_ONLY_SECRET"); expect(String(error)).toContain("api_url"); }
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps namespaced model IDs intact when splitting runtime references", () => {
|
||||
expect(parseModelReference("custom/org/model")).toEqual({ providerID: "custom", modelID: "org/model" });
|
||||
for (const invalid of ["model", "/model", "provider/", "provider/model name", "provider/model#variant"]) expect(() => parseModelReference(invalid)).toThrow();
|
||||
});
|
||||
|
||||
it("exposes only the two configured gateway models with their roles, icons and context limits", () => {
|
||||
expect(supportedModels).toEqual(["waternetwork/gpt-6-luna", "waternetwork/gpt-6.1-sol"]);
|
||||
expect(agentModelOptions.map(({ id, label, icon }) => ({ id, label, icon }))).toEqual([
|
||||
{ id: "waternetwork/gpt-6-luna", label: "快速", icon: "bolt" },
|
||||
{ id: "waternetwork/gpt-6.1-sol", label: "专家", icon: "sparkle" },
|
||||
]);
|
||||
expect(isSupportedModel("deepseek/deepseek-flash")).toBe(false);
|
||||
expect(isSupportedModel("waternetwork/deepseek-flash")).toBe(false);
|
||||
expect(isSupportedModel("waternetwork/gpt-image-2.5")).toBe(false);
|
||||
expect(isSupportedModel("waternetwork/codex-auto-review")).toBe(false);
|
||||
const native = serviceOpencodeConfig({});
|
||||
expect(Object.keys(native.providers)).toEqual(["waternetwork"]);
|
||||
expect(native.model).toBe("waternetwork/gpt-6-luna");
|
||||
for (const model of Object.values(native.providers.waternetwork.models)) expect(model.limit?.context).toBe(272000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Opt-in native catalog verification. Shares the startup check and never infers.
|
||||
import "../../scripts/check-models.js";
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { checkOpencodeModels } from "../../.opencode/model-check.js";
|
||||
import { assertModelCheckPassed, type ModelCheckInput } from "../../src/runtime/model-check.js";
|
||||
|
||||
const targets: ModelCheckInput = { models: [
|
||||
{ id: "custom/alias", provider_id: "custom", model_id: "alias", wire_id: "org/model", requires_tools: true },
|
||||
{ id: "custom/typo", provider_id: "custom", model_id: "typo", wire_id: "does-not-exist", requires_tools: true },
|
||||
] };
|
||||
|
||||
describe("OpenCode model validation", () => {
|
||||
it("rejects a manually registered invalid name and validates the actual upstream alias", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let queries = 0;
|
||||
globalThis.fetch = (async (input: unknown, init: RequestInit) => {
|
||||
queries++;
|
||||
expect(String(input)).toBe("https://example.com/v1/models");
|
||||
expect(new Headers(init.headers).get("Authorization")).toBe("Bearer TEST_ONLY_SECRET");
|
||||
expect(init.redirect).toBe("error");
|
||||
return Response.json({ data: [{ id: "org/model" }] });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const report = await checkOpencodeModels({
|
||||
model: { list: async () => ({ data: targets.models.map((model) => ({ id: model.model_id, modelID: model.wire_id, providerID: "custom", enabled: true, capabilities: { tools: true } })) }) },
|
||||
provider: { get: async () => ({ data: { package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://example.com/v1", apiKey: "TEST_ONLY_SECRET" } } }) },
|
||||
} as never, targets, new AbortController().signal);
|
||||
expect(report.models.map((model) => model.status)).toEqual(["valid", "not_advertised"]);
|
||||
expect(queries).toBe(1);
|
||||
expect(() => assertModelCheckPassed(report)).toThrow("custom/typo: not_advertised");
|
||||
expect(JSON.stringify(report)).not.toContain("TEST_ONLY_SECRET");
|
||||
expect(JSON.stringify(report)).not.toContain("example.com");
|
||||
} finally { globalThis.fetch = originalFetch; }
|
||||
});
|
||||
|
||||
it("reports unavailable, non-tool and mismatched native models", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => Response.json({ data: [{ id: "org/model" }, { id: "does-not-exist" }] })) as unknown as typeof fetch;
|
||||
try {
|
||||
for (const [native, expected] of [
|
||||
[undefined, "not_registered"],
|
||||
[{ enabled: false, modelID: "org/model", capabilities: { tools: true } }, "not_enabled"],
|
||||
[{ enabled: true, modelID: "org/model", capabilities: { tools: false } }, "no_tools"],
|
||||
[{ enabled: true, modelID: "different", capabilities: { tools: true } }, "wire_id_mismatch"],
|
||||
] as const) {
|
||||
const report = await checkOpencodeModels({
|
||||
model: { list: async () => ({ data: native ? [{ id: "alias", providerID: "custom", ...native }] : [] }) },
|
||||
provider: { get: async () => ({ data: { package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://example.com/v1", apiKey: "TEST_ONLY_SECRET" } } }) },
|
||||
} as never, { models: [targets.models[0]!] }, new AbortController().signal);
|
||||
expect(report.models[0]!.status).toBe(expected);
|
||||
expect(() => assertModelCheckPassed(report)).toThrow();
|
||||
}
|
||||
} finally { globalThis.fetch = originalFetch; }
|
||||
});
|
||||
|
||||
it("fails closed on auth, empty/invalid inventory and cancellation without exposing upstream errors", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const context = {
|
||||
model: { list: async () => ({ data: [{ id: "alias", modelID: "org/model", providerID: "custom", enabled: true, capabilities: { tools: true } }] }) },
|
||||
provider: { get: async () => ({ data: { package: "@opencode/ai/providers/openai-compatible", settings: { baseURL: "https://example.com/v1", apiKey: "TEST_ONLY_SECRET" } } }) },
|
||||
} as never;
|
||||
try {
|
||||
for (const response of [Response.json({ secret: "TEST_ONLY_SECRET" }, { status: 401 }), Response.json({ data: [] }), Response.json({ data: [{ wrong: true }] })]) {
|
||||
globalThis.fetch = (async () => response) as unknown as typeof fetch;
|
||||
const report = await checkOpencodeModels(context, { models: [targets.models[0]!] }, new AbortController().signal);
|
||||
expect(() => assertModelCheckPassed(report)).toThrow();
|
||||
expect(JSON.stringify(report)).not.toContain("TEST_ONLY_SECRET");
|
||||
}
|
||||
globalThis.fetch = (async (_input: unknown, init: RequestInit) => { init.signal!.throwIfAborted(); throw new Error("should not query"); }) as unknown as typeof fetch;
|
||||
const controller = new AbortController(); controller.abort();
|
||||
const report = await checkOpencodeModels(context, { models: [targets.models[0]!] }, controller.signal);
|
||||
expect(report.providers[0]!.status).toBe("catalog_unavailable");
|
||||
} finally { globalThis.fetch = originalFetch; }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
const alive = (pid: number) => {
|
||||
try { process.kill(pid, 0); return true; } catch { return false; }
|
||||
};
|
||||
|
||||
describe("model checker cancellation", () => {
|
||||
for (const signal of ["SIGTERM", "SIGINT"] as const) {
|
||||
it(`cleans its own OpenCode process and workspace when receiving ${signal} during startup`, async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "model-check-command-test-"));
|
||||
const statePath = join(root, "state.json");
|
||||
const command = join(root, "opencode-test");
|
||||
let state: { pid: number; xdg: string } | undefined;
|
||||
let checker: ReturnType<typeof spawn> | undefined;
|
||||
try {
|
||||
await writeFile(command, `#!${process.execPath}\nimport { writeFileSync } from "node:fs";\nwriteFileSync(process.env.MODEL_CHECK_TEST_STATE, JSON.stringify({ pid: process.pid, xdg: process.env.XDG_DATA_HOME }));\nsetInterval(() => {}, 1000);\n`, { mode: 0o700 });
|
||||
checker = spawn(process.execPath, ["scripts/check-models.ts"], {
|
||||
cwd: resolve("."), env: { ...process.env, OPENCODE_COMMAND: command, MODEL_CHECK_TEST_STATE: statePath },
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
});
|
||||
const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
||||
checker!.once("error", reject);
|
||||
checker!.once("close", (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
const deadline = Date.now() + 5000;
|
||||
while (!state && Date.now() < deadline) {
|
||||
try { state = JSON.parse(await readFile(statePath, "utf8")); } catch { await Bun.sleep(10); }
|
||||
}
|
||||
expect(state).toBeDefined();
|
||||
checker.kill(signal);
|
||||
const result = await Promise.race([exited, Bun.sleep(5000).then(() => { throw new Error("checker did not finish cancellation"); })]);
|
||||
expect(alive(state!.pid)).toBe(false);
|
||||
await expect(stat(dirname(state!.xdg))).rejects.toThrow();
|
||||
expect(result).toEqual({ code: signal === "SIGTERM" ? 143 : 130, signal: null });
|
||||
} finally {
|
||||
checker?.kill("SIGKILL");
|
||||
if (state && alive(state.pid)) process.kill(state.pid, "SIGKILL");
|
||||
if (state) await rm(dirname(state.xdg), { recursive: true, force: true });
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 10_000);
|
||||
}
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { config } from "../../src/config.js";
|
||||
import { modelCheckTargets } from "../../src/runtime/model-check.js";
|
||||
import {
|
||||
OpencodeRuntimeAdapter,
|
||||
resolveEmbeddedOpencodePort,
|
||||
@@ -141,7 +142,7 @@ describe("resolveEmbeddedOpencodePort", () => {
|
||||
|
||||
describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
const toolIds = ["shell", "web_search", "geocode", "query_project_data", "final_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "session_search", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "get_operational_task_context", "query_operational_task_data", "publish_execution_plan", "publish_task_report", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todoread", "todowrite"];
|
||||
async function createWarmupRuntime(missing: string[] = []) {
|
||||
async function createWarmupRuntime(missing: string[] = [], invalidModel?: string) {
|
||||
const root = await mkdtemp(join(tmpdir(), "warmup-workspace-test-"));
|
||||
const previousRoot = config.CONVERSATION_WORKSPACE_ROOT;
|
||||
config.CONVERSATION_WORKSPACE_ROOT = root;
|
||||
@@ -155,6 +156,14 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
},
|
||||
model: { list: async () => { calls.push("model.list"); return { data: [{ providerID, id, enabled: true, capabilities: { tools: true } }] }; } },
|
||||
rpc: { call: async (input: { method: string }) => {
|
||||
if (input.method === "check_models") {
|
||||
calls.push("model.check");
|
||||
const targets = modelCheckTargets(config.MODEL_CONFIG).models;
|
||||
return { output: {
|
||||
models: targets.map((model) => ({ id: model.id, model_id: model.wire_id, status: model.id === invalidModel ? "not_advertised" : "valid" })),
|
||||
providers: [...new Set(targets.map((model) => model.provider_id))].map((id) => ({ id, status: "checked" })),
|
||||
} };
|
||||
}
|
||||
if (input.method === "mcp") { calls.push("mcp.list"); return { output: { servers: [], configuration_errors: [] } }; }
|
||||
calls.push("tool.list");
|
||||
return { output: toolIds.filter((id) => !missing.includes(id)).map((id) => ({ id, direct: true, sandboxed: id === "shell", workspaceRoot: root })) };
|
||||
@@ -166,13 +175,21 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
it("verifies business tools and model before reporting ready and removes the warmup session", async () => {
|
||||
const { runtime, calls, cleanup } = await createWarmupRuntime();
|
||||
try { await runtime.warmup();
|
||||
expect(calls).toEqual(["health", "session.create", "tool.list", "mcp.list", "model.list", "session.remove"]); } finally { await cleanup(); }
|
||||
expect(calls).toEqual(["health", "session.create", "tool.list", "mcp.list", "model.check", "model.list", "session.remove"]); } finally { await cleanup(); }
|
||||
});
|
||||
it("fails readiness if a business tool was lost and still cleans up", async () => {
|
||||
const { runtime, calls, cleanup } = await createWarmupRuntime(["render_scada_analysis"]);
|
||||
try { await expect(runtime.warmup()).rejects.toThrow("business tools missing: render_scada_analysis");
|
||||
expect(calls.at(-1)).toBe("session.remove"); } finally { await cleanup(); }
|
||||
});
|
||||
it("rejects an invalid non-default model before readiness and cleans up the session", async () => {
|
||||
const { runtime, calls, cleanup } = await createWarmupRuntime([], "waternetwork/gpt-6.1-sol");
|
||||
try {
|
||||
await expect(runtime.warmup()).rejects.toThrow("waternetwork/gpt-6.1-sol: not_advertised");
|
||||
expect(calls).not.toContain("model.list");
|
||||
expect(calls.at(-1)).toBe("session.remove");
|
||||
} finally { await cleanup(); }
|
||||
});
|
||||
});
|
||||
|
||||
describe("conversation workspace binding", () => {
|
||||
|
||||
Reference in New Issue
Block a user