Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
60 changes: 34 additions & 26 deletions open-sse/executors/opencodeFreeTierContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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<string, unknown>): 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
Expand All @@ -221,29 +219,39 @@ export function applyFreeTierRequestContract<T>(
const record = body as Record<string, unknown>;
const next: Record<string, unknown> = { ...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;
}

Expand Down
4 changes: 3 additions & 1 deletion open-sse/executors/opencodeToolObservation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
49 changes: 47 additions & 2 deletions tests/unit/opencode-free-tier-request-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import {
import {
_resetToolObservationForTests,
getObservedToolNames,
recordAcceptedToolNames,
resolvePlaceholderNames,
} from "../../open-sse/executors/opencodeToolObservation.ts";
import {
DEFAULT_OPENCODE_USER_AGENT,
Expand Down Expand Up @@ -146,19 +148,55 @@ 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" } } },
];
const body = applyFreeTierRequestContract(
{ ...CHAT_BODY(), tools: clientTools },
"openai"
) as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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" },
Expand Down Expand Up @@ -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.
Expand Down
Loading