From 8ea6d25d9148e15d3ee7249e9af34896286db113 Mon Sep 17 00:00:00 2001 From: Safeer Ahmad Date: Tue, 22 Sep 2026 02:10:33 +0500 Subject: [PATCH] fix(opencode): append configured placeholder tools to client tools & protect precedence Cherry-picked from f374fc37d (branch fix/opencode-free-tier-client-tools, 2026-09-19) onto the deployed line fix/purify-notice-cache-stable. The upstream free tier validates the request's declared tool NAMES, and refuses a request that does not match the OpenCode client contract with 403 "free tier can only be used from within OpenCode". `applyFreeTierRequestContract` early returned on `hasTools()`, so the placeholders were only injected when the caller sent NO tools at all -- a client that sends its own tools (DeepSeek Harness sends `run_code`) shipped without them and was refused. - Merge the configured placeholder names (OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS) into the client's tools instead of only filling an empty set. Client tools are preserved and keep their order; only names not already declared are appended, so the operation stays idempotent. - Resolve configured names ahead of un-scoped observed tools in resolvePlaceholderNames, so a foreign caller cannot poison the observation store and change which names a later request borrows. - Cover both with unit tests (multi-placeholder client appending, observation precedence). Measured on the real entry point `prepareFreeTierRequest` with OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS=glob,grep,read,edit,write,bash: in : tools=[run_code] out: tools=[run_code, glob, grep, read, edit, write, bash] Paid models and OPENCODE_FREE_TIER_REQUEST_CONTRACT=off are untouched. --- .../14156-opencode-free-tier-client-tools.md | 1 + .../executors/opencodeFreeTierContract.ts | 60 +++++++++++-------- open-sse/executors/opencodeToolObservation.ts | 4 +- ...pencode-free-tier-request-contract.test.ts | 49 ++++++++++++++- 4 files changed, 85 insertions(+), 29 deletions(-) create mode 100644 changelog.d/fixes/14156-opencode-free-tier-client-tools.md diff --git a/changelog.d/fixes/14156-opencode-free-tier-client-tools.md b/changelog.d/fixes/14156-opencode-free-tier-client-tools.md new file mode 100644 index 00000000000..cb256755945 --- /dev/null +++ b/changelog.d/fixes/14156-opencode-free-tier-client-tools.md @@ -0,0 +1 @@ +- **fix(opencode):** append configured placeholder tools alongside client-supplied tools on free-tier requests and prioritize configured tools over un-scoped generic observations, resolving 403 FreeTierError when using external tool-calling clients ([#14156](https://github.com/diegosouzapw/OmniRoute/pull/14156)) — thanks @adevwithpurpose diff --git a/open-sse/executors/opencodeFreeTierContract.ts b/open-sse/executors/opencodeFreeTierContract.ts index dc8aa4f1b84..39aa84de0e5 100644 --- a/open-sse/executors/opencodeFreeTierContract.ts +++ b/open-sse/executors/opencodeFreeTierContract.ts @@ -52,6 +52,12 @@ export interface FreeTierContractAttempt { readonly clientToolNames: readonly string[]; /** A refusal may still be replayed in the other shape: its outcome is noted afterwards. */ readonly probe?: boolean; + /** + * True when this attempt injected placeholder tools the caller had not sent. Kept as a + * separate flag from `borrowed`: a placeholder this layer added is ours to attribute, while + * `borrowed` means it came from the observation store. + */ + readonly injectedPlaceholders?: boolean; } /** @@ -188,19 +194,11 @@ const PLACEHOLDER_TOOL_DESCRIPTION = "Do not call this tool. It exists only for API compatibility and must never be invoked."; const PLACEHOLDER_TOOL_PARAMETERS = { type: "object", properties: {} } as const; -/** - * An empty `tools` array counts as no tools: it is the exact shape the upstream refuses - * (upstream anomalyco/opencode#49433 reports it from the client's own compaction path), - * so it has to be filled like an absent one rather than passed through. - */ -function hasTools(body: Record): boolean { - return Array.isArray(body.tools) && body.tools.length > 0; -} - /** * Bring a free-tier request up to the upstream contract, without overriding anything the * caller already decided: client tools are kept as they are, and the placeholder tool is - * only added when the caller sent none. Idempotent. + * only added when the caller sent none or when client-supplied tools do not yet carry the + * required placeholder tool. Idempotent. * * The placeholder differs per surface: Chat Completions takes the nested function shape, * the Responses surface takes the flat one. Neither carries a `tool_choice` — the upstream @@ -221,29 +219,39 @@ export function applyFreeTierRequestContract( const record = body as Record; const next: Record = { ...record, stream: true }; - if (hasTools(next)) return next as T; + const existingNames = new Set(clientToolNamesOf(next)); + const baseNames = placeholderNames.length > 0 ? placeholderNames : [PLACEHOLDER_TOOL_NAME]; + const namesToAdd = baseNames.filter((name) => !existingNames.has(name)); - const names = placeholderNames.length > 0 ? placeholderNames : [PLACEHOLDER_TOOL_NAME]; + if (namesToAdd.length === 0) return next as T; + + const existingTools = Array.isArray(next.tools) ? [...next.tools] : []; if (requestFormat === "openai-responses") { - next.tools = names.map((name) => ({ - type: "function", - name, - description: PLACEHOLDER_TOOL_DESCRIPTION, - parameters: PLACEHOLDER_TOOL_PARAMETERS, - })); + next.tools = [ + ...existingTools, + ...namesToAdd.map((name) => ({ + type: "function", + name, + description: PLACEHOLDER_TOOL_DESCRIPTION, + parameters: PLACEHOLDER_TOOL_PARAMETERS, + })), + ]; return next as T; } if (requestFormat === "openai" || requestFormat === null) { - next.tools = names.map((name) => ({ - type: "function", - function: { - name, - description: PLACEHOLDER_TOOL_DESCRIPTION, - parameters: PLACEHOLDER_TOOL_PARAMETERS, - }, - })); + next.tools = [ + ...existingTools, + ...namesToAdd.map((name) => ({ + type: "function", + function: { + name, + description: PLACEHOLDER_TOOL_DESCRIPTION, + parameters: PLACEHOLDER_TOOL_PARAMETERS, + }, + })), + ]; return next as T; } diff --git a/open-sse/executors/opencodeToolObservation.ts b/open-sse/executors/opencodeToolObservation.ts index 2bf561d8c80..0659aec3a39 100644 --- a/open-sse/executors/opencodeToolObservation.ts +++ b/open-sse/executors/opencodeToolObservation.ts @@ -153,7 +153,9 @@ export function resolvePlaceholderNames( configured: readonly string[] ): readonly string[] { const own = session ? getObservedToolNames(provider, model, session) : null; - return own ?? getObservedToolNames(provider, model) ?? configured; + if (own && own.length > 0) return own; + if (configured && configured.length > 0) return configured; + return getObservedToolNames(provider, model) ?? configured; } /** Reserved for tests. */ diff --git a/tests/unit/opencode-free-tier-request-contract.test.ts b/tests/unit/opencode-free-tier-request-contract.test.ts index ae760368315..7ef0abc5643 100644 --- a/tests/unit/opencode-free-tier-request-contract.test.ts +++ b/tests/unit/opencode-free-tier-request-contract.test.ts @@ -35,6 +35,8 @@ import { import { _resetToolObservationForTests, getObservedToolNames, + recordAcceptedToolNames, + resolvePlaceholderNames, } from "../../open-sse/executors/opencodeToolObservation.ts"; import { DEFAULT_OPENCODE_USER_AGENT, @@ -146,7 +148,7 @@ test("responses: the placeholder tool is flat and tool_choice stays absent", () assert.equal("tool_choice" in body, false); }); -test("client-supplied tools are never replaced, and no tool_choice is imposed", () => { +test("client-supplied tools are never replaced, required placeholders are appended, and no tool_choice is imposed", () => { const clientTools = [ { type: "function", function: { name: "search", parameters: { type: "object" } } }, ]; @@ -154,11 +156,47 @@ test("client-supplied tools are never replaced, and no tool_choice is imposed", { ...CHAT_BODY(), tools: clientTools }, "openai" ) as Record; - assert.deepEqual(body.tools, clientTools); + const tools = body.tools as Array<{ type: string; function?: { name: string } }>; + assert.equal(tools.length, 2); + assert.equal(tools[0].function?.name, "search"); + assert.equal(tools[1].function?.name, "_noop"); assert.equal("tool_choice" in body, false); assert.equal(body.stream, true); }); +test("when client-supplied tools already include placeholder tools, nothing extra is added", () => { + const clientTools = [ + { type: "function", function: { name: "search", parameters: { type: "object" } } }, + { type: "function", function: { name: "_noop", parameters: { type: "object" } } }, + ]; + const body = applyFreeTierRequestContract( + { ...CHAT_BODY(), tools: clientTools }, + "openai" + ) as Record; + assert.deepEqual(body.tools, clientTools); +}); + +test("when client-supplied tools are present, multiple configured placeholders are appended", () => { + const clientTools = [ + { type: "function", function: { name: "run_code", parameters: { type: "object" } } }, + ]; + const body = applyFreeTierRequestContract({ ...CHAT_BODY(), tools: clientTools }, "openai", [ + "glob", + "grep", + "read", + "edit", + "write", + "bash", + ]) as Record; + const tools = body.tools as Array<{ type: string; function?: { name: string } }>; + assert.equal(tools.length, 7); + assert.equal(tools[0].function?.name, "run_code"); + assert.deepEqual( + tools.slice(1).map((t) => t.function?.name), + ["glob", "grep", "read", "edit", "write", "bash"] + ); +}); + test("a client tool_choice is preserved", () => { const body = applyFreeTierRequestContract( { ...CHAT_BODY(), tool_choice: "auto" }, @@ -519,6 +557,13 @@ test("an accepted request teaches the names it carried, and a later bare request assert.equal(second.attempt?.borrowed, true); }); +test("configured placeholder names take precedence over un-scoped observed tools for generic clients", () => { + _resetToolObservationForTests(); + recordAcceptedToolNames("opencode", "big-pickle", undefined, ["run_code"]); + const resolved = resolvePlaceholderNames("opencode", "big-pickle", undefined, ["glob", "grep"]); + assert.deepEqual(resolved, ["glob", "grep"]); +}); + test("what one model learns stays with that model", () => { // Asserts an absence, so it stays green if the store is removed entirely — it guards // against cross-model leakage, not against the mechanism disappearing.