diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index c4c31720..531c5df5 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -411,6 +411,11 @@ export class OpenCodeExecutor extends BaseExecutor { transformRequest(model, body, stream, credentials) { if (body && typeof body === "object" && model && !body.model) body.model = model; if (isResponsesModel(model || body?.model) && body && typeof body === "object") { + // ponytail: chỉ model đã xác nhận auto-only; mở allowlist khi có bằng chứng. + if ("tool_choice" in body && body.tool_choice !== "auto" + && this.config.quirks?.forceAutoToolChoiceModels?.includes(baseModelId(model))) { + body.tool_choice = "auto"; + } const normalized = normalizeResponsesInput(body.input); if (normalized) body.input = normalized; if (!Array.isArray(body.input) || body.input.length === 0) { diff --git a/open-sse/providers/registry/opencode.js b/open-sse/providers/registry/opencode.js index fc2ab8c6..0914f431 100644 --- a/open-sse/providers/registry/opencode.js +++ b/open-sse/providers/registry/opencode.js @@ -18,6 +18,9 @@ export default { "x-opencode-client": "desktop", }, noAuth: true, + quirks: { + forceAutoToolChoiceModels: ["muse-spark-1.3-contributor-free"], + }, }, models: [ // Endpoint formats differ per model, so declare non-chat models explicitly. diff --git a/tests/unit/opencode-free-tool-choice.test.js b/tests/unit/opencode-free-tool-choice.test.js new file mode 100644 index 00000000..4d3aca91 --- /dev/null +++ b/tests/unit/opencode-free-tool-choice.test.js @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from "vitest"; +import { PROVIDERS } from "../../open-sse/config/providers.js"; +import { OpenCodeExecutor } from "../../open-sse/executors/opencode.js"; +import { proxyAwareFetch } from "../../open-sse/utils/proxyFetch.js"; + +vi.mock("../../open-sse/utils/proxyFetch.js", () => ({ + proxyAwareFetch: vi.fn(async () => ({ ok: true, status: 200, headers: { get: () => "" } })), +})); + +// Break caught: opencode/muse-spark-1.3-contributor-free 400 vì upstream +// chỉ nhận tool_choice "auto"; named/required/none phải demote sang "auto". +const FREE_13 = "muse-spark-1.3-contributor-free"; +const CREDS = { connectionId: "opencode-free-tool-choice-test" }; +const INPUT = [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }]; +const TOOLS = [{ type: "function", name: "get_weather", description: "w", parameters: { type: "object", properties: {} } }]; + +function responsesBody(model, tool_choice) { + const body = { model, input: structuredClone(INPUT), tools: structuredClone(TOOLS) }; + if (tool_choice !== undefined) body.tool_choice = tool_choice; + return body; +} + +describe("opencode Free 1.3 tool_choice auto-only", () => { + it("khai quirk đúng model 1.3-Free trong registry", () => { + expect(PROVIDERS.opencode.quirks?.forceAutoToolChoiceModels).toEqual([FREE_13]); + }); + + it.each([ + ["Responses named", { type: "function", name: "get_weather" }], + ["Chat function named", { type: "function", function: { name: "get_weather" } }], + ["Claude tool named", { type: "tool", name: "get_weather" }], + ["required", "required"], + ["none", "none"], + ])("demote %s sang auto (plain và max)", (_label, choice) => { + for (const model of [FREE_13, `${FREE_13}(max)`]) { + const body = responsesBody(model, structuredClone(choice)); + const out = new OpenCodeExecutor().transformRequest(model, body, true, CREDS); + expect(out.tool_choice).toBe("auto"); + expect(out.tools).toEqual(TOOLS); + expect(out.input).toEqual(INPUT); + } + }); + + it("giữ auto và absent; tools/input nguyên vẹn", () => { + const autoOut = new OpenCodeExecutor().transformRequest( + FREE_13, responsesBody(FREE_13, "auto"), true, CREDS, + ); + expect(autoOut.tool_choice).toBe("auto"); + expect(autoOut.tools).toEqual(TOOLS); + expect(autoOut.input).toEqual(INPUT); + + const absentOut = new OpenCodeExecutor().transformRequest( + FREE_13, responsesBody(FREE_13, undefined), true, CREDS, + ); + expect("tool_choice" in absentOut).toBe(false); + expect(absentOut.tools).toEqual(TOOLS); + expect(absentOut.input).toEqual(INPUT); + }); + + it.each([ + ["1.2-Free", "muse-spark-1.2-contributor-free"], + ["future 1.4-Free", "muse-spark-1.4-contributor-free"], + ["Go id", "muse-spark-1.3-contributor"], + ["non-Muse", "big-pickle"], + ])("không đổi tool_choice của %s", (_label, model) => { + const choice = { type: "function", name: "get_weather" }; + const body = responsesBody(model, structuredClone(choice)); + const out = new OpenCodeExecutor().transformRequest(model, body, true, CREDS); + expect(out.tool_choice).toEqual(choice); + }); + + it("wire: execute gửi choice auto tới /zen/v1/responses", async () => { + proxyAwareFetch.mockClear(); + const ex = new OpenCodeExecutor(); + const body = responsesBody(FREE_13, { type: "function", name: "get_weather" }); + const { url, transformedBody } = await ex.execute({ + model: FREE_13, body, stream: true, credentials: CREDS, + }); + expect(url).toBe("https://opencode.ai/zen/v1/responses"); + expect(transformedBody.tool_choice).toBe("auto"); + expect(proxyAwareFetch).toHaveBeenCalledTimes(1); + const [actualUrl, actualInit] = proxyAwareFetch.mock.calls[0]; + expect(actualUrl).toBe("https://opencode.ai/zen/v1/responses"); + const sent = JSON.parse(actualInit.body); + expect(sent.tool_choice).toBe("auto"); + expect(sent.model).toBe(FREE_13); + expect(sent.tools).toEqual(TOOLS); + expect(sent.input).toEqual(INPUT); + }); +});