Skip to content
2 changes: 1 addition & 1 deletion .github/actions/npm-ci-retry/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ runs:
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: node_modules
key: node-modules-${{ runner.os }}-${{ runner.arch }}-${{ steps.node.outputs.version }}-${{ hashFiles('package-lock.json', '.npmrc', 'scripts/build/postinstall.mjs', 'scripts/build/postinstallSupport.mjs', 'scripts/build/colocateOptionals.mjs', 'scripts/build/wreqJsNative.mjs', 'scripts/build/fixPlaywrightAndroid.mjs', 'scripts/build/native-binary-compat.mjs') }}
key: node-modules-${{ runner.os }}-${{ runner.arch }}-${{ steps.node.outputs.version }}-${{ hashFiles('package-lock.json', '.npmrc', 'scripts/build/postinstall.mjs', 'scripts/build/postinstallSupport.mjs', 'scripts/build/colocateOptionals.mjs', 'scripts/build/wreqJsNative.mjs', 'scripts/build/fixPlaywrightAndroid.mjs', 'scripts/build/native-binary-compat.mjs', 'scripts/build/betterSqlitePrebuildTarget.mjs') }}

- name: npm ci (with retry)
if: steps.node-modules.outputs.cache-hit != 'true'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(release):** clear release/v3.8.51 base-reds — the OpenCode free-tier observed-tools retry no longer re-sends the exact tool shape the upstream just refused, `/v1/audio/transcriptions` answers "Invalid model ID" again for unsafe model ids, the combined chatCore tool-metadata extraction no longer leaves the `_toolNameMap` side channel on the dispatch body, and the node_modules cache key covers every postinstall helper
47 changes: 39 additions & 8 deletions open-sse/executors/opencodeFreeTierRetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
mergeClientToolsWithObserved,
type OpencodeSurface,
} from "./opencodeFreeTierContract.ts";
import { resolvePlaceholderNames } from "./opencodeToolObservation.ts";
import { isOpencodeFreeTierRefusal } from "./opencodeGeoBlock.ts";
import { resolveOpencodeTargetFormat } from "./opencode.ts";

Expand Down Expand Up @@ -33,11 +34,7 @@ type RetryCtx = {
};

/** Scope guards: refusal status, gated surface+model, own (non-borrowed) tools, object body. */
function retryScopeApplies(
ctx: RetryCtx,
input: ExecutorInput,
status: number
): boolean {
function retryScopeApplies(ctx: RetryCtx, input: ExecutorInput, status: number): boolean {
if (status !== 403 && status !== 451) return false;
if (!isGatedFreeTierRequest(ctx.surface, ctx.provider, String(input.model ?? ""))) return false;
// Borrowed tools are the store's own names coming back: the retry would add
Expand All @@ -61,6 +58,37 @@ async function readRefusalBody(
}
}

function toolNamesOf(body: unknown): string[] {
const tools = (body as { tools?: unknown } | null)?.tools;
if (!Array.isArray(tools)) return [];
const names: string[] = [];
for (const tool of tools) {
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;
}

/**
* Since #14156 the contract appends the resolved placeholder names to a client's own
* tools on the FIRST dispatch too. When the merged retry body declares no name beyond
* what that first dispatch already carried (client names + resolved placeholders), the
* retry would re-send the exact shape the upstream just refused — skip it.
*/
function addsNothingBeyondFirstDispatch(
ctx: RetryCtx,
model: string,
configured: readonly string[],
merged: unknown
): boolean {
const sent = new Set<string>([
...ctx.clientToolNames,
...resolvePlaceholderNames(ctx.provider, model, ctx.clientSession, configured),
]);
return toolNamesOf(merged).every((name) => sent.has(name));
}

export async function retryFreeTierRefusalWithObservedTools(
ctx: RetryCtx,
input: ExecutorInput,
Expand All @@ -71,15 +99,18 @@ export async function retryFreeTierRefusalWithObservedTools(
): Promise<{ response: Response } | null> {
const status = first.response.status;
if (!retryScopeApplies(ctx, input, status)) return null;
const model = String(input.model ?? "");
const configured = configuredPlaceholderToolNames();
const merged = mergeClientToolsWithObserved(
input.body,
ctx.requestFormat ?? resolveOpencodeTargetFormat(ctx.provider, String(input.model ?? "")),
ctx.requestFormat ?? resolveOpencodeTargetFormat(ctx.provider, model),
ctx.provider,
String(input.model ?? ""),
model,
ctx.clientSession,
configuredPlaceholderToolNames()
configured
);
if (merged === input.body) return null;
if (addsNothingBeyondFirstDispatch(ctx, model, configured, merged)) return null;
if ((await readRefusalBody(first, status, log)) === null) return null;
log?.warn?.(
"OPENCODE",
Expand Down
6 changes: 6 additions & 0 deletions open-sse/handlers/audioTranscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { buildAuthHeaders } from "../config/registryUtils.ts";
import { kieExecutor } from "../executors/kie.ts";
import { vertexTranscribe } from "../executors/vertexMedia.ts";
import { errorResponse } from "../utils/error.ts";
import { hasUnsafeModelIdSyntax } from "../utils/modelIdSafety.ts";
import { isJsonObject } from "../utils/kieTask.ts";
import { handleOpenRouterTranscription } from "./openrouterTranscription.ts";

Expand Down Expand Up @@ -879,6 +880,11 @@ export async function handleAudioTranscription({
if (typeof model !== "string" || !model) {
return errorResponse(400, "model is required");
}
// #15067 made the registry parser refuse unsafe ids (dot segments, encoded
// delimiters); name the real reason instead of "No transcription provider found".
if (hasUnsafeModelIdSyntax(model)) {
return errorResponse(400, "Invalid model ID");
}

const fileEntry = formData.get("file");
if (!(fileEntry instanceof Blob)) {
Expand Down
6 changes: 6 additions & 0 deletions open-sse/handlers/chatCore/requestToolIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export function extractRequestToolMetadata(translatedBody: Record<string, unknow
translatedBody._toolNameMap instanceof Map ? translatedBody._toolNameMap : null
);
const requestToolIdentityMap = extractRequestToolIdentityMap(translatedBody);
// Both ledgers are captured above, so the side channel is consumed here. The
// standalone extractor keeps a string alias ledger next to namespace identities
// (#14751) for callers that resolve aliases from the body later; this combined
// entry point returns that ledger instead (#12839), so nothing may linger on the
// body that is about to be serialized for dispatch.
delete translatedBody._toolNameMap;
return {
requestToolIdentityMap,
toolNameAliasMap: toolNameAliasMap ?? toToolNameAliasMap(requestToolIdentityMap),
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/alibaba-provider-regions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,13 +310,16 @@ test("Qwen Cloud Token Plan remains a flat-rate provider with chat models only",
assert.equal(isFlatRateProvider("qwen-cloud-token-plan"), true);

const modelIds = REGISTRY["qwen-cloud-token-plan"].models.map((model) => model.id);
// #14273 registered the qwen3.8-flash and deepseek-v4.1-flash vision chat leaves.
assert.deepEqual(modelIds, [
"qwen3.8-max",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.8-flash",
"qwen3.6-flash",
"glm-5.2",
"deepseek-v4-pro",
"deepseek-v4.1-flash",
"deepseek-v4-flash-0731",
]);

Expand Down
9 changes: 7 additions & 2 deletions tests/unit/chatcore-codex-account-pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,13 @@ test("chatCore retains exact quota resets from intermediate rotated Codex 429s",
input: "persist exact reset before rotation",
stream: false,
},
responseFactory(_captured: unknown, calls: unknown[]) {
if (calls.length < 4) {
// Key the 429 on the FIRST account's token, not on a call count: since #14959 a
// 429 carrying Retry-After: 60 skips the same-account intra-retries, so the first
// account answers once (not 3x) before chatCore rotates to the second one.
responseFactory(captured: unknown) {
const headers = (captured as { headers: Record<string, string> }).headers;
const auth = headers.authorization ?? headers.Authorization ?? "";
if (auth.includes("codex-exact-reset-first")) {
return new Response(JSON.stringify({ error: { message: "Codex quota exceeded" } }), {
status: 429,
headers: {
Expand Down
9 changes: 5 additions & 4 deletions tests/unit/executor-default-base.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
CONTEXT_1M_BETA_HEADER,
} from "../../open-sse/services/claudeCodeCompatible.ts";
import { runWithCapture } from "../../open-sse/utils/providerRequestLogging.ts";
import { CLAUDE_CODE_CLIENT_BILLING_VERSION } from "../../src/shared/constants/claudeCodeClient.ts";

class TestExecutor extends BaseExecutor {
constructor(config = {}) {
Expand Down Expand Up @@ -1573,10 +1574,10 @@ test("DefaultExecutor.execute does not produce duplicate anthropic-version heade
assert.equal(capturedHeaders["X-Stainless-Package-Version"], "0.112.1");

const sentBody = JSON.parse(capturedBody) as { system?: Array<{ text?: string }> };
assert.match(
sentBody.system?.[0]?.text ?? "",
/^x-anthropic-billing-header: cc_version=2\.1\.258\.1e2; cc_entrypoint=cli; cch=[0-9a-f]{5};$/
);
const cc = `x-anthropic-billing-header: cc_version=${CLAUDE_CODE_CLIENT_BILLING_VERSION}; `;
const billing = sentBody.system?.[0]?.text ?? "";
assert.equal(billing.slice(0, cc.length), cc, "cc_version tracks the constant (#14627)");
assert.match(billing.slice(cc.length), /^cc_entrypoint=cli; cch=[0-9a-f]{5};$/);
});

test('shouldForceResponsesUpstream respects explicit apiType="chat" even when namespace tools are present', () => {
Expand Down
53 changes: 51 additions & 2 deletions tests/unit/opencode-free-tier-refusal-rotation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,18 @@ describe("OpencodeExecutor free-tier refusal retry with observed tools", () => {

// Single direct account (fast path): first dispatch 403 FreeTier, retry carries
// the union and succeeds — one extra fetch, original tools intact first.
//
// Since #14156 the contract appends the RESOLVED placeholder names to client tools on
// the first dispatch too, and resolution prefers the operator's configured names over
// the un-scoped observed ones. The retry therefore only adds something when the
// observed names differ from the configured ones — so this scenario configures `bash`.
it("retries once with observed names appended and returns the retry success", async () => {
const prevConfigured = process.env.OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS;
process.env.OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS = "bash";
after(() => {
if (prevConfigured === undefined) delete process.env.OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS;
else process.env.OPENCODE_FREE_TIER_PLACEHOLDER_TOOLS = prevConfigured;
});
const exec = new OpencodeExecutor("opencode");
recordAcceptedToolNames("opencode", "muse-spark-1.3-contributor-free", undefined, [
"edit",
Expand Down Expand Up @@ -290,15 +301,53 @@ describe("OpencodeExecutor free-tier refusal retry with observed tools", () => {

assert.strictEqual(result.response.status, 200);
assert.strictEqual(seenTools.length, 2, "exactly one retry dispatch");
assert.deepEqual(seenTools[0], ["glob", "read"], "first dispatch keeps client tools");
assert.deepEqual(
seenTools[0],
["glob", "read", "bash"],
"first dispatch keeps client tools first, then the configured placeholders (#14156)"
);
assert.deepEqual(
seenTools[1],
["glob", "read", "edit", "write"],
["glob", "read", "edit", "write", "bash"],
"retry appends observed names after client tools"
);
await result.response.body?.cancel();
});

// #14156 + #14464 interaction: without configured names the first dispatch already
// carries the observed names, so the merged retry body would be byte-for-byte the shape
// the upstream just refused. It must not be re-sent.
it("does not re-send the refused shape when the first dispatch already carried the observed names", async () => {
const exec = new OpencodeExecutor("opencode");
recordAcceptedToolNames("opencode", "muse-spark-1.3-contributor-free", undefined, [
"edit",
"write",
]);
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
const parsed = JSON.parse(String((init as Record<string, unknown>)?.body ?? "{}")) as {
tools?: unknown[];
};
seenTools.push(Array.isArray(parsed.tools) ? parsed.tools.map(toolNameOf) : null);
return new Response(REFUSAL_BODY, {
status: 403,
headers: { "Content-Type": "application/json" },
});
}) as typeof globalThis.fetch;

const result = await runWithBody(exec, {
model: "muse-spark-1.3-contributor-free",
messages: [{ role: "user", content: "hi" }],
stream: true,
tools: [
{ type: "function", function: { name: "glob", parameters: { type: "object" } } },
{ type: "function", function: { name: "read", parameters: { type: "object" } } },
],
});

assert.strictEqual(result.response.status, 403);
assert.deepEqual(seenTools, [["glob", "read", "edit", "write"]], "one dispatch, no duplicate");
});

// Retry refusal: the ORIGINAL 403 is propagated and the store is untouched.
it("propagates the original refusal when the retry is refused, store untouched", async () => {
const exec = new OpencodeExecutor("opencode");
Expand Down
11 changes: 10 additions & 1 deletion tests/unit/opencode-request-shape-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,16 @@ test("tools declared by the client are never removed, even for a title prompt",
"the upstream refuses this shape and we do not second-guess it"
);
assert.equal(bodies.length, 1, "no replay: the injection was not ours");
assert.equal(toolCount(bodies[0]), 1, "the client's tool list went out untouched");
// Since #14156 the contract appends the required placeholder names AFTER the client's
// own tools (it no longer sends a client tool list verbatim), but it never removes or
// reshapes what the client declared: the client's tool is still first and intact.
const sent = bodies[0].tools as Array<Record<string, unknown>>;
assert.ok(toolCount(bodies[0]) >= 1);
assert.deepEqual(
sent[0],
(body.tools as unknown[])[0],
"the client's own tool went out first, untouched"
);
});

const shapesOf = (from: number): string[] =>
Expand Down
17 changes: 14 additions & 3 deletions tests/unit/token-refresh-race-comprehensive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,19 @@ test("Fix A: getAccessToken accepts an onPersist parameter", async () => {

test("Fix A: getAccessToken invokes onPersist INSIDE the per-connection mutex closure", async () => {
const src = await read("open-sse/services/tokenRefresh.ts");
const closureMatch = src.match(/entry\.promise\s*=\s*\(async\s*\(\)\s*=>\s*\{([\s\S]+?)\}\)\(\)/);
// #15002 (#14970) moved the closure into `const work = (async () => {...})()` and made the
// shared `entry.promise` a bounded race over it; the closure is still the mutex body.
const closureMatch = src.match(
/(?:entry\.promise|const work)\s*=\s*\(async\s*\(\)\s*=>\s*\{([\s\S]+?)\}\)\(\)/
);
assert.ok(closureMatch, "Per-connection mutex closure must use the (async () => {...})() form");
if (/const work\s*=/.test(closureMatch![0])) {
assert.match(
src,
/entry\.promise\s*=\s*Promise\.race\(\[\s*work,/,
"the shared mutex promise must be the raced `work` closure"
);
}
const closureBody = closureMatch![1];
assert.match(
closureBody,
Expand Down Expand Up @@ -177,7 +188,6 @@ test("Imports: base.ts imports runWithOnPersist from open-sse tokenRefresh", asy
assert.match(src, /from\s+"\.\.\/services\/tokenRefresh\.ts"/);
});


test("serialized refresh re-checks rotation inside the lane, not before waiting", async () => {
const src = await read("open-sse/services/tokenRefresh.ts");
const start = src.indexOf("async function _getAccessTokenWithStalenessCheck");
Expand All @@ -186,7 +196,8 @@ test("serialized refresh re-checks rotation inside the lane, not before waiting"
const wrapper = src.slice(start, inner);
assert.match(
wrapper,
/serializeRefresh\(provider,\s*\(\)\s*=>/,
// Whitespace-tolerant: #15002 added a `log` argument, so prettier wraps the call.
/serializeRefresh\(\s*provider,\s*\(\)\s*=>/,
"the network POST must stay behind serializeRefresh"
);
assert.match(wrapper, /_refreshWithFreshCredentials/);
Expand Down
Loading