import { describe, expect, it } from "bun:test"; import { agentModelOptions, isSupportedModel, resolveDefaultModel, supportedModels, } from "../../src/chat/models.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("disables native local-provider discovery while preserving explicitly configured local models", () => { const providers = Object.fromEntries(["vllm", "lmstudio", "ollama"].map((id) => [id, { name: id, api_url: "http://127.0.0.1:18000/v1", models: { chat: {} }, }])); const configuration = parseAgentModelConfig({ defaults: { chat: "vllm/chat", task: "lmstudio/chat", learning_gate: "ollama/chat" }, providers, }); const native = serviceOpencodeConfig({ plugins: ["opencode.provider.vllm"] }, configuration); for (const id of Object.keys(providers)) { expect(native.plugins).toContain(`-opencode.provider.${id}`); expect(native.providers[id]?.models.chat).toBeDefined(); } expect(native.plugins).toContain("*"); expect(native.agents.instruction.model).toBe("vllm/chat"); expect(native.agents.investigation.model).toBe("lmstudio/chat"); }); it("keeps every exposed option in the supported model list", () => { expect(agentModelOptions.map((model) => model.id)).toEqual(supportedModels); expect( agentModelOptions.every( (model) => model.label && model.description && model.icon, ), ).toBe(true); }); it("validates supported model ids", () => { expect(isSupportedModel(supportedModels[0]!)).toBe(true); expect(isSupportedModel("unknown/model")).toBe(false); }); it("rejects unsupported default models", () => { expect(resolveDefaultModel(supportedModels[0]!)).toBe(supportedModels[0]!); expect(() => resolveDefaultModel("unknown/model")).toThrow( "unsupported default agent model", ); }); it("generates native providers and the public catalog from one custom provider definition", () => { const configuration = parseAgentModelConfig({ defaults: { chat: "custom/chat", task: "custom/org/model", learning_gate: "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("requires an explicit nonempty model for every role without inheriting chat", () => { const input = { defaults: { chat: "custom/chat", task: "custom/chat", learning_gate: "custom/chat" }, providers: { custom: { name: "Custom", api_url: "https://example.com/v1", models: { chat: {} } } }, }; for (const role of ["chat", "task", "learning_gate"] as const) { const { [role]: omitted, ...remaining } = input.defaults; expect(omitted).toBe("custom/chat"); expect(() => parseAgentModelConfig({ ...input, defaults: remaining })).toThrow("invalid agent model configuration structure"); for (const value of ["", " ", null, undefined]) { expect(() => parseAgentModelConfig({ ...input, defaults: { ...input.defaults, [role]: value } })).toThrow("invalid agent model configuration structure"); } } const configuration = parseAgentModelConfig(input); expect(configuration.defaults).toEqual({ chat: "custom/chat", task: "custom/chat", learning_gate: "custom/chat" }); const native = serviceOpencodeConfig({}, configuration); expect(native.agents.instruction.model).toBe("custom/chat"); expect(native.agents.investigation.model).toBe("custom/chat"); }); it("routes same-name models across providers with independent limits and role overrides", () => { const configuration = parseAgentModelConfig({ defaults: { chat: "primary/chat", task: "secondary/chat", learning_gate: "secondary/gate" }, providers: { primary: { name: "Primary", api_url: "https://primary.example.com/v1", models: { chat: { limit: { context: 32000 } } } }, secondary: { name: "Secondary", api_url: "https://secondary.example.com/v1", models: { chat: { model_id: "upstream/chat", limit: { context: 64000 } }, gate: { selectable: false }, } }, }, }); expect(getAgentModelOptions(configuration).map(({ id }) => id)).toEqual(["primary/chat", "secondary/chat"]); expect(configuration.defaults.learning_gate).toBe("secondary/gate"); const native = serviceOpencodeConfig({}, configuration); expect(native.model).toBe("primary/chat"); expect(native.agents.instruction.model).toBe("primary/chat"); expect(native.agents.investigation.model).toBe("secondary/chat"); expect(native.providers.primary.models.chat.limit?.context).toBe(32000); expect(native.providers.secondary.models.chat).toMatchObject({ modelID: "upstream/chat", limit: { context: 64000 } }); expect(modelCheckTargets(configuration).models.map(({ id, wire_id }) => ({ id, wire_id }))).toEqual([ { id: "primary/chat", wire_id: "chat" }, { id: "secondary/chat", wire_id: "upstream/chat" }, { id: "secondary/gate", wire_id: "gate" }, ]); }); it("requires grouped defaults and rejects hidden chat defaults and unknown role names", () => { const providers = { custom: { name: "Custom", api_url: "https://example.com/v1", models: { chat: {}, hidden: { selectable: false } } } }; const defaults = { chat: "custom/chat", task: "custom/chat", learning_gate: "custom/chat" }; for (const input of [ { providers }, { defaults: {}, providers }, { defaults: { ...defaults, typo: "custom/chat" }, providers }, { default_model: "custom/chat", providers }, { defaults, task_model: "custom/chat", providers }, ]) expect(() => parseAgentModelConfig(input)).toThrow("invalid agent model configuration structure"); expect(() => parseAgentModelConfig({ defaults: { ...defaults, chat: "custom/hidden" }, providers })).toThrow("defaults.chat must be selectable"); }); it("rejects unknown, disabled or non-tool role models and invalid endpoints without echoing keys", () => { const base = { defaults: { chat: "custom/chat", task: "custom/chat", learning_gate: "custom/chat" }, providers: { custom: { name: "Custom", api_url: "https://example.com/v1", api_key: "TEST_ONLY_SECRET", models: { chat: {}, disabled: { disabled: true }, no_tools: { capabilities: { tools: false } }, image: { capabilities: { output: ["image"] } } } } } }; for (const role of ["chat", "task", "learning_gate"]) { for (const model of ["custom/missing", "custom/disabled", "custom/no_tools", "custom/image"]) { expect(() => parseAgentModelConfig({ ...base, defaults: { ...base.defaults, [role]: model } })).toThrow(`defaults.${role} must name an 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"); expect(native.providers.waternetwork.models["gpt-6-luna"].limit?.context).toBe(272000); expect(native.providers.waternetwork.models["gpt-6.1-sol"].limit?.context).toBe(475000); }); });