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
6 changes: 5 additions & 1 deletion open-sse/executors/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
isOpencodeUserBlocked,
} from "./opencodeGeoBlock.ts";
import {
FREE_TIER_INFERENCE_BASE_URL,
attemptFor,
isGatedFreeTierRequest,
isPremiumOpencodeModel,
Expand Down Expand Up @@ -1279,7 +1280,10 @@ export class OpencodeExecutor extends BaseExecutor {
void urlIndex;
void credentials;

const base = this.config.baseUrl;
const base =
Boolean(model) && isGatedFreeTierRequest(this._surface(), this.provider, model)
? FREE_TIER_INFERENCE_BASE_URL
: this.config.baseUrl;
switch (this._requestFormat) {
case "claude":
return `${base}/messages`;
Expand Down
48 changes: 42 additions & 6 deletions open-sse/executors/opencodeFreeTierContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@
* host are not gated (a paid model without tools answers 401 CreditsError), which is why
* `requiresFreeTierRequestContract` narrows the contract to free-tier models.
*
* Measured again on 2026-10-14: the free tier moved. The client now takes its base URL
* from the models.dev catalog (`https://opencode.ai/inference/v1`), and the very same
* request is refused on `/zen/v1`; on the new base a declared `read` or `bash` whose
* parameters carry no properties is refused, while an empty schema on a name outside the
* quartet still passes. `FREE_TIER_INFERENCE_BASE_URL` and `placeholderToolParameters`
* carry those two facts.
*
* The module also owns the free-model catalog — the catalog is what decides whether the
* contract applies, so the two belong together and the executor imports them from here —
* and the way back: the forced stream is rebuilt into a JSON body for a caller that asked
Expand Down Expand Up @@ -121,6 +128,14 @@ export type OpencodeSurface = "zen" | "go" | "other";
const ZEN_SURFACE_BASE_URL = "https://opencode.ai/zen/v1";
const GO_SURFACE_BASE_URL = "https://opencode.ai/zen/go/v1";

/**
* Where a gated free-tier request is sent. Since 2026-10-14 the OpenCode client takes its
* base URL from the models.dev catalog (`https://opencode.ai/inference/v1`), and the free
* tier refuses the very same request on `/zen/v1` with 403 FreeTierError. Paid models keep
* the registry base URL, which still serves them.
*/
export const FREE_TIER_INFERENCE_BASE_URL = "https://opencode.ai/inference/v1";

/** Tell the surfaces apart by registry `baseUrl`, so provider ids and aliases stay out. */
export function surfaceFromBaseUrl(baseUrl: string | null | undefined): OpencodeSurface {
if (baseUrl === ZEN_SURFACE_BASE_URL) return "zen";
Expand Down Expand Up @@ -196,6 +211,26 @@ 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;

/**
* Minimal parameter schemas for injected placeholder tools, mirroring the OpenCode client's
* argument names. Since 2026-10-14 the free tier refuses a declared `read` or `bash` whose
* parameter object has no properties (measured on /inference/v1: `read` + `bash` with
* `properties: {}` is 403, the same pair with `{command}` on `bash` is 200, and a request
* with no property-bearing `read` is 403), while an empty schema on a name outside the
* quartet (`webfetch`, `_noop`) still passes.
*/
const PLACEHOLDER_TOOL_PARAMETER_SCHEMAS: Readonly<Record<string, Record<string, unknown>>> = {
bash: { type: "object", properties: { command: { type: "string" } }, required: ["command"] },
glob: { type: "object", properties: { pattern: { type: "string" } }, required: ["pattern"] },
grep: { type: "object", properties: { pattern: { type: "string" } }, required: ["pattern"] },
read: { type: "object", properties: { filePath: { type: "string" } }, required: ["filePath"] },
};

/** Parameter schema for an injected placeholder tool named `name`. */
export function placeholderToolParameters(name: string): Record<string, unknown> {
return PLACEHOLDER_TOOL_PARAMETER_SCHEMAS[name] ?? PLACEHOLDER_TOOL_PARAMETERS;
}

/**
* 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
Expand Down Expand Up @@ -236,7 +271,7 @@ export function applyFreeTierRequestContract<T>(
type: "function",
name,
description: PLACEHOLDER_TOOL_DESCRIPTION,
parameters: PLACEHOLDER_TOOL_PARAMETERS,
parameters: placeholderToolParameters(name),
})),
];
return next as T;
Expand All @@ -250,7 +285,7 @@ export function applyFreeTierRequestContract<T>(
function: {
name,
description: PLACEHOLDER_TOOL_DESCRIPTION,
parameters: PLACEHOLDER_TOOL_PARAMETERS,
parameters: placeholderToolParameters(name),
},
})),
];
Expand All @@ -270,8 +305,9 @@ export function applyFreeTierRequestContract<T>(
* FreeTierError. When such a refusal comes back, this rebuilds the same body with the
* observed names appended after the client's own entries: the client tools keep their
* full shape (description, parameters) so the model can still call them, while the
* appended names are list entries with an empty parameter object — the same shape the
* placeholder path uses, never a callable tool.
* appended names are list entries carrying the minimal parameter schema
* `placeholderToolParameters` gives them — the same shape the placeholder path uses,
* never a callable tool.
*
* Pure: returns the input body unchanged when no observed names are missing, so the
* caller can skip the retry on identity alone. Resolution order is the store's own:
Expand Down Expand Up @@ -302,7 +338,7 @@ export function mergeClientToolsWithObserved<T>(
type: "function",
name,
description: PLACEHOLDER_TOOL_DESCRIPTION,
parameters: PLACEHOLDER_TOOL_PARAMETERS,
parameters: placeholderToolParameters(name),
})),
];
return next as T;
Expand All @@ -315,7 +351,7 @@ export function mergeClientToolsWithObserved<T>(
function: {
name,
description: PLACEHOLDER_TOOL_DESCRIPTION,
parameters: PLACEHOLDER_TOOL_PARAMETERS,
parameters: placeholderToolParameters(name),
},
})),
];
Expand Down
18 changes: 9 additions & 9 deletions tests/unit/opencode-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,14 +75,14 @@ describe("OpencodeExecutor", () => {
it('resolves "opencode" executor alias to opencode-zen config', async () => {
const aliasExecutor = new OpencodeExecutor("opencode-zen");
const result = await aliasExecutor.execute(createInput("deepseek-v4-flash-free"));
assert.equal(result.url, "https://opencode.ai/zen/v1/chat/completions");
assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/v1/chat/completions");
assert.equal(result.url, "https://opencode.ai/inference/v1/chat/completions");
assert.equal(fetchCalls[0].url, "https://opencode.ai/inference/v1/chat/completions");
});

it("routes deepseek-v4-flash-free to chat completions", async () => {
const result = await zenExecutor.execute(createInput("deepseek-v4-flash-free"));
assert.equal(result.url, "https://opencode.ai/zen/v1/chat/completions");
assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/v1/chat/completions");
assert.equal(result.url, "https://opencode.ai/inference/v1/chat/completions");
assert.equal(fetchCalls[0].url, "https://opencode.ai/inference/v1/chat/completions");
});

it("includes deepseek-v4-flash-free in opencode-zen PROVIDER_MODELS", () => {
Expand Down Expand Up @@ -115,14 +115,14 @@ describe("OpencodeExecutor", () => {
}
});

it("routes opencode zen default models to chat completions", async () => {
it("routes free models to the inference surface and paid ones to zen", async () => {
const minimaxResult = await zenExecutor.execute(createInput("minimax-m2.5-free"));
assert.equal(minimaxResult.url, "https://opencode.ai/zen/v1/chat/completions");
assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/v1/chat/completions");
assert.equal(minimaxResult.url, "https://opencode.ai/inference/v1/chat/completions");
assert.equal(fetchCalls[0].url, "https://opencode.ai/inference/v1/chat/completions");

const pickleResult = await zenExecutor.execute(createInput("big-pickle"));
assert.equal(pickleResult.url, "https://opencode.ai/zen/v1/chat/completions");
assert.equal(fetchCalls[1].url, "https://opencode.ai/zen/v1/chat/completions");
assert.equal(pickleResult.url, "https://opencode.ai/inference/v1/chat/completions");
assert.equal(fetchCalls[1].url, "https://opencode.ai/inference/v1/chat/completions");

const nanoResult = await zenExecutor.execute(createInput("gpt-5-nano"));
assert.equal(nanoResult.url, "https://opencode.ai/zen/v1/chat/completions");
Expand Down
66 changes: 62 additions & 4 deletions tests/unit/opencode-free-tier-request-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@
*
* Paid models on the same host are not gated (one without tools answers 401), so the
* request contract applies to free-tier models only.
*
* Measured again on 2026-10-14: the free tier moved to https://opencode.ai/inference/v1
* (the base the client now takes from the models.dev catalog), and the very same request
* body is refused 403 on /zen/v1. On the new base the tools condition also narrows: a
* declared `read` or `bash` needs a non-empty parameter schema, while an empty schema on
* a name outside the quartet still passes.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
Expand All @@ -27,6 +33,7 @@ import {
configuredPlaceholderToolNames,
mergeClientToolsWithObserved,
noteFreeTierOutcome,
placeholderToolParameters,
prepareFreeTierRequest,
isGatedFreeTierRequest,
requiresFreeTierRequestContract,
Expand Down Expand Up @@ -105,9 +112,36 @@ test("the placeholder carries the names it is given, one entry each", () => {
tools.map((t) => t.function.name),
["glob", "grep", "read"]
);
// Names only: an empty parameter object, so a borrowed name is a list entry rather than
// a tool the model could usefully call.
assert.deepEqual(tools[0].function.parameters, { type: "object", properties: {} });
// Injected quartet members carry the minimal schema the gate accepts: since 2026-10-14 a
// declared `read` with `properties: {}` turns the request into a 403 on /inference/v1.
assert.deepEqual(tools[0].function.parameters, {
type: "object",
properties: { pattern: { type: "string" } },
required: ["pattern"],
});
assert.deepEqual(tools[2].function.parameters, {
type: "object",
properties: { filePath: { type: "string" } },
required: ["filePath"],
});
});

test("placeholderToolParameters: quartet members get a minimal schema, others stay empty", () => {
// Measured 2026-10-14 on /inference/v1: `read` + `bash` with `properties: {}` is refused
// 403, the same pair with `{command}` on `bash` passes, and a request with no
// property-bearing `read` is refused — while an empty schema on a name outside the
// quartet (`webfetch`, `_noop`) still passes.
assert.deepEqual(placeholderToolParameters("bash"), {
type: "object",
properties: { command: { type: "string" } },
required: ["command"],
});
for (const name of ["bash", "glob", "grep", "read"]) {
const schema = placeholderToolParameters(name) as { properties: Record<string, unknown> };
assert.ok(Object.keys(schema.properties).length > 0, name);
}
assert.deepEqual(placeholderToolParameters("_noop"), { type: "object", properties: {} });
assert.deepEqual(placeholderToolParameters("webfetch"), { type: "object", properties: {} });
});

test("responses: several placeholder names keep the flat shape and no tool_choice", () => {
Expand Down Expand Up @@ -823,10 +857,34 @@ test("mergeClientToolsWithObserved: keeps client tools first and appends missing
tools.map((t) => t.function.name),
["glob", "read", "edit"]
);
// Appended entries are list entries, not callable tools.
// Appended entries are list entries, not callable tools; `edit` is outside the quartet,
// so it keeps the empty schema.
assert.deepEqual(tools[2].function.parameters, { type: "object", properties: {} });
});

test("mergeClientToolsWithObserved: an appended quartet member carries a non-empty schema [free-tier-observed-tools]", () => {
_resetToolObservationForTests();
const body = {
...CHAT_BODY(),
tools: [{ type: "function", function: { name: "Read", parameters: { type: "object" } } }],
};
const merged = mergeClientToolsWithObserved(body, "openai", "opencode", "big-pickle", undefined, [
"bash",
]);
const tools = (merged as Record<string, unknown>).tools as Array<{
function: { name: string; parameters: object };
}>;
assert.deepEqual(
tools.map((t) => t.function.name),
["Read", "bash"]
);
assert.deepEqual(tools[1].function.parameters, {
type: "object",
properties: { command: { type: "string" } },
required: ["command"],
});
});

test("mergeClientToolsWithObserved: leaves the body untouched when nothing observed [free-tier-observed-tools]", () => {
_resetToolObservationForTests();
const body = {
Expand Down