mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 13:23:50 +03:00
Match the upstream OpenCode free-tier request contract (issue #13935): canonical ses_/msg_ identity ids, versioned User-Agent, and the measured body requirements (stream:true + non-empty tools) with a learn-and-reuse tool-name cache, so no-auth oc/* requests stop being refused with 403 FreeTierError. Supersedes #13937 (session regex and minimum-version rule kept, credited below). Complements #14011 (refusal classification) and #13819 (stream_options strip), both already merged. Closes #13935 Co-authored-by: AStupidBear <16422976+AStupidBear@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
374 lines
15 KiB
TypeScript
374 lines
15 KiB
TypeScript
/**
|
|
* opencodeFreeTierContract.ts — the request contract OpenCode Zen's free tier enforces.
|
|
*
|
|
* Measured against the live endpoint on 2026-09-17, on three models and both the Chat
|
|
* Completions and Responses surfaces: the upstream answers 403 FreeTierError unless the
|
|
* request carries all four of
|
|
*
|
|
* 1. `stream: true` in the body,
|
|
* 2. a non-empty `tools` array (the content is not inspected),
|
|
* 3. a session header shaped `ses_` + 12 hex + 14 base62 (the shape is checked, the
|
|
* value is not — 12 arbitrary hex digits pass),
|
|
* 4. a `User-Agent` carrying `opencode/<version>` with version >= 1.17 (an older
|
|
* version answers 426 UpgradeRequired rather than 403).
|
|
*
|
|
* Removing any single one of the four turns a 200 into a 403. Paid models on the same
|
|
* host are not gated (a paid model without tools answers 401 CreditsError), which is why
|
|
* `requiresFreeTierRequestContract` narrows the contract to free-tier models.
|
|
*
|
|
* 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
|
|
* for JSON, reusing the shared event-stream parsers.
|
|
*/
|
|
import { parseSSEToOpenAIResponse, parseSSEToResponsesOutput } from "../handlers/sseParser.ts";
|
|
import {
|
|
noteRefusedBorrowedToolNames,
|
|
recordAcceptedToolNames,
|
|
resolvePlaceholderNames,
|
|
} from "./opencodeToolObservation.ts";
|
|
|
|
/**
|
|
* What one gated request declared, kept until its outcome is known.
|
|
*
|
|
* `borrowed` means the placeholder came from the observation store rather than from the
|
|
* caller or the configuration, which is the only case where a refusal says anything about
|
|
* that store.
|
|
*/
|
|
export interface FreeTierContractAttempt {
|
|
readonly provider: string;
|
|
readonly model: string;
|
|
readonly session: string | undefined;
|
|
readonly borrowed: boolean;
|
|
readonly clientToolNames: readonly string[];
|
|
}
|
|
|
|
/**
|
|
* Models that work WITHOUT any API key on the free/noauth opencode tier.
|
|
*
|
|
* The upstream free tier rotates frequently — when a `-free` suffix model is
|
|
* delisted upstream, the upstream returns "Model X is not supported" (a separate
|
|
* issue from this gate). The set is defined by two data sources:
|
|
*
|
|
* 1. **Known free models** — models explicitly listed in the noauth
|
|
* `opencode` provider registry (`open-sse/config/providers/registry/opencode/index.ts`).
|
|
* These are the canonical free models. `deepseek-v4-flash-free` appears in both
|
|
* the noauth AND the zen registry (it is free on both tiers).
|
|
* 2. **`-free` suffix** — any model whose id ends in `-free`. This automatically
|
|
* covers upstream free-tier additions without a code deploy.
|
|
*
|
|
* For `opencode-go`, there is no free tier — ALL models require an API key.
|
|
*/
|
|
const OPENCODE_FREE_MODELS = new Set([
|
|
"big-pickle",
|
|
"deepseek-v4-flash-free",
|
|
"mimo-v2.5-free",
|
|
"hy3-free",
|
|
"nemotron-3-ultra-free",
|
|
"north-mini-code-free",
|
|
]);
|
|
|
|
/**
|
|
* Determine whether a model requires an API key on the given opencode provider.
|
|
*
|
|
* - `opencode-go`: ALL models require a key (no free tier).
|
|
* - `opencode` / `opencode-zen`: premium = any model NOT in the free set (known
|
|
* free models OR ending in `-free`).
|
|
* - Unknown models are assumed premium (fail-safe).
|
|
*/
|
|
export function isPremiumOpencodeModel(model: string, provider: string): boolean {
|
|
// opencode-go has no free tier — every model requires a key.
|
|
if (provider === "opencode-go") return true;
|
|
|
|
// Models ending in `-free` are always free on the noauth/zen tier.
|
|
if (model.endsWith("-free")) return false;
|
|
|
|
// Check the known free model catalog.
|
|
return !OPENCODE_FREE_MODELS.has(model);
|
|
}
|
|
|
|
/**
|
|
* The upstream surface a request is served on, as told apart by the registry `baseUrl`.
|
|
*
|
|
* The gate is a property of the surface, not of billing: `/zen/v1` refuses a request that
|
|
* carries no tools, while `/zen/go/v1` refuses one that carries any (upstream
|
|
* anomalyco/opencode#44300 and #44382, "Endpoint is unavailable" for any request
|
|
* containing tools). Passing the surface in keeps this module free of registry imports and
|
|
* covers every entry of the family — including the `oc` alias, which resolves to
|
|
* `opencode` before an executor is ever picked.
|
|
*/
|
|
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";
|
|
|
|
/** 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";
|
|
if (baseUrl === GO_SURFACE_BASE_URL) return "go";
|
|
return "other";
|
|
}
|
|
|
|
/**
|
|
* Operator opt-out for the body half of the contract.
|
|
*
|
|
* The headers stay in place either way: they are what the upstream validates by shape, and
|
|
* they cost nothing. The body half is the half built on an observation of someone else's
|
|
* service, so it is the half worth being able to switch off without waiting for a release.
|
|
* Read per call, so a change takes effect immediately.
|
|
*/
|
|
function isBodyContractEnabled(): boolean {
|
|
return (process.env.OPENCODE_FREE_TIER_REQUEST_CONTRACT || "").trim().toLowerCase() !== "off";
|
|
}
|
|
|
|
/**
|
|
* Whether the upstream gates this request at all.
|
|
*
|
|
* Scope only — it ignores the opt-out on purpose, because the headers are applied either
|
|
* way: they are what the upstream validates by shape, they cost nothing, and switching
|
|
* them off with the body half would turn an opt-out into a second failure mode.
|
|
*/
|
|
export function isGatedFreeTierRequest(
|
|
surface: OpencodeSurface,
|
|
provider: string,
|
|
model: string
|
|
): boolean {
|
|
if (surface !== "zen") return false;
|
|
return !isPremiumOpencodeModel(model, provider);
|
|
}
|
|
|
|
/** Whether the body half of the contract applies: gated, and not switched off. */
|
|
export function requiresFreeTierRequestContract(
|
|
surface: OpencodeSurface,
|
|
provider: string,
|
|
model: string
|
|
): boolean {
|
|
return isGatedFreeTierRequest(surface, provider, model) && isBodyContractEnabled();
|
|
}
|
|
|
|
/** The placeholder tool name the official client uses for the same purpose. */
|
|
const PLACEHOLDER_TOOL_NAME = "_noop";
|
|
export const DEFAULT_PLACEHOLDER_TOOL_NAME = PLACEHOLDER_TOOL_NAME;
|
|
|
|
/**
|
|
* Operator-supplied placeholder tool names, comma-separated.
|
|
*
|
|
* The upstream inspects which names a request declares, and what it accepts differs by
|
|
* model and moves over time (measured 2026-09-18: one made-up name is accepted on
|
|
* `big-pickle` and refused on two other free models that had accepted it the day before).
|
|
* That is an observation about someone else's service, not a fact about this project, so
|
|
* it belongs in configuration rather than in a constant that needs a release to change.
|
|
*
|
|
* Empty or unset falls back to the built-in name, so an install that sets nothing keeps
|
|
* the previous behaviour. Read per call, so a change takes effect immediately.
|
|
*/
|
|
export function configuredPlaceholderToolNames(): string[] {
|
|
const raw = process.env.OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS || "";
|
|
const kept: string[] = [];
|
|
for (const part of raw.split(",")) {
|
|
const name = part.trim();
|
|
if (kept.length >= 32) break;
|
|
if (!/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(name)) continue;
|
|
if (!kept.includes(name)) kept.push(name);
|
|
}
|
|
return kept;
|
|
}
|
|
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.
|
|
*
|
|
* 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
|
|
* rejects any value but "auto" (measured 2026-09-18: 400 invalid_request_error, `only
|
|
* "auto" is supported for tool_choice`), so a `tool_choice` the caller did not send is
|
|
* never added, and one the caller did send travels unchanged. Any other body format only
|
|
* gets the streaming flag: injecting a tool shape blind would be a guess.
|
|
*
|
|
* Which names go in is resolved by `resolvePlaceholderNames`, because the upstream does
|
|
* inspect them.
|
|
*/
|
|
export function applyFreeTierRequestContract<T>(
|
|
body: T,
|
|
requestFormat: string | null,
|
|
placeholderNames: readonly string[] = [PLACEHOLDER_TOOL_NAME]
|
|
): T {
|
|
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
|
const record = body as Record<string, unknown>;
|
|
const next: Record<string, unknown> = { ...record, stream: true };
|
|
|
|
if (hasTools(next)) return next as T;
|
|
|
|
const names = placeholderNames.length > 0 ? placeholderNames : [PLACEHOLDER_TOOL_NAME];
|
|
|
|
if (requestFormat === "openai-responses") {
|
|
next.tools = names.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,
|
|
},
|
|
}));
|
|
return next as T;
|
|
}
|
|
|
|
return next as T;
|
|
}
|
|
|
|
function clientToolNamesOf(body: unknown): string[] {
|
|
if (!body || typeof body !== "object" || Array.isArray(body)) return [];
|
|
const tools = (body as Record<string, unknown>).tools;
|
|
if (!Array.isArray(tools)) return [];
|
|
const names: string[] = [];
|
|
for (const tool of tools) {
|
|
if (!tool || typeof tool !== "object") continue;
|
|
const entry = tool as { name?: unknown; function?: { name?: unknown } };
|
|
const name = typeof entry.name === "string" ? entry.name : entry.function?.name;
|
|
if (typeof name === "string") names.push(name);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/**
|
|
* Bring one request up to the contract and report what it ended up declaring.
|
|
*
|
|
* Single entry point for the executor: it decides whether the contract applies to this
|
|
* surface and model, resolves the placeholder names, applies the body changes, and hands
|
|
* back the attempt so the outcome can be fed to `noteFreeTierOutcome`.
|
|
*/
|
|
export function prepareFreeTierRequest<T>(
|
|
body: T,
|
|
requestFormat: string | null,
|
|
surface: OpencodeSurface,
|
|
provider: string,
|
|
model: string,
|
|
session?: string
|
|
): { body: T; attempt: FreeTierContractAttempt | null } {
|
|
const clientToolNames = clientToolNamesOf(body);
|
|
if (!requiresFreeTierRequestContract(surface, provider, model)) return { body, attempt: null };
|
|
const names = resolvePlaceholderNames(provider, model, session, configuredPlaceholderToolNames());
|
|
const borrowed = clientToolNames.length === 0 && names.length > 0;
|
|
return {
|
|
body: applyFreeTierRequestContract(body, requestFormat, names),
|
|
attempt: { provider, model, session, borrowed, clientToolNames },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Feed a gated request's outcome back, so the next one borrows a shape that still works.
|
|
*
|
|
* An accepted request teaches which names the upstream takes right now; a refused one only
|
|
* teaches something when the names it carried came from the store.
|
|
*/
|
|
export function noteFreeTierOutcome(attempt: FreeTierContractAttempt | null, ok: boolean): void {
|
|
if (!attempt) return;
|
|
if (ok) {
|
|
if (attempt.clientToolNames.length > 0) {
|
|
recordAcceptedToolNames(
|
|
attempt.provider,
|
|
attempt.model,
|
|
attempt.session,
|
|
attempt.clientToolNames
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (attempt.borrowed) {
|
|
noteRefusedBorrowedToolNames(attempt.provider, attempt.model, attempt.session);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Rebuild a JSON body from the event stream the contract forced.
|
|
*
|
|
* The contract makes the upstream request streamed even when the caller asked for JSON, so
|
|
* the conversion back has to happen here rather than in one caller: `handleChatCore` knows
|
|
* how to buffer an event stream, but the other executor consumers (the compression judge,
|
|
* token counting) call `execute()` and then read `response.json()`. Handing them the raw
|
|
* event stream would throw on a request they explicitly asked not to stream.
|
|
*
|
|
* The conversion is LAZY: the returned body drains and parses the upstream only when the
|
|
* caller reads it, so the executor never blocks on a stream that has not arrived yet — the
|
|
* caller's own read timeout governs, and a caller that cancels pays nothing. Only a
|
|
* successful event-stream response is converted; a streaming caller, a refusal and an
|
|
* already-JSON body pass through untouched.
|
|
*/
|
|
export function rebuildJsonFromForcedStream(
|
|
response: Response,
|
|
requestFormat: string | null,
|
|
model: string
|
|
): Response {
|
|
if (!response.ok || !response.body) return response;
|
|
if (!(response.headers.get("content-type") || "").includes("text/event-stream")) {
|
|
return response;
|
|
}
|
|
const upstream = response;
|
|
let drained = false;
|
|
const body = new ReadableStream<Uint8Array>(
|
|
{
|
|
async pull(controller) {
|
|
if (drained) {
|
|
controller.close();
|
|
return;
|
|
}
|
|
drained = true;
|
|
try {
|
|
const rawSse = await upstream.text();
|
|
const parsed =
|
|
requestFormat === "openai-responses"
|
|
? parseSSEToResponsesOutput(rawSse, model)
|
|
: parseSSEToOpenAIResponse(rawSse, model);
|
|
const out = parsed && typeof parsed === "object" ? JSON.stringify(parsed) : rawSse;
|
|
controller.enqueue(new TextEncoder().encode(out));
|
|
} catch (err) {
|
|
controller.error(err);
|
|
return;
|
|
}
|
|
controller.close();
|
|
},
|
|
cancel(reason) {
|
|
// Only when nothing started draining: once `text()` holds the reader, cancelling the
|
|
// upstream body directly throws "ReadableStream is locked".
|
|
if (!drained && !upstream.bodyUsed && upstream.body && !upstream.body.locked) {
|
|
void upstream.body.cancel(reason);
|
|
}
|
|
},
|
|
},
|
|
// No pull before the caller reads: a caller that never reads (or cancels) must not make
|
|
// the executor wait on an upstream stream that may never arrive.
|
|
{ highWaterMark: 0 }
|
|
);
|
|
const headers = new Headers(response.headers);
|
|
headers.set("content-type", "application/json");
|
|
headers.delete("content-length");
|
|
return new Response(body, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers,
|
|
});
|
|
}
|