From f2472afd3910b76399191d9f2e949265618af854 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:37:16 -0300 Subject: [PATCH 1/6] fix(opencode): skip the observed-tools retry when it would re-send the refused shape Since #14156 the free-tier contract appends the resolved placeholder names to client tools on the first dispatch too, so the #14464 observed-tools retry could re-send a byte-identical body. Skip it when the merged body adds no new name, and align the two tests to the #14156 contract (client tools first and intact, required placeholders appended). --- ...asereds-r3-opencode-retry-audio-toolmap.md | 1 + open-sse/executors/opencodeFreeTierRetry.ts | 47 +++++++++++++--- ...pencode-free-tier-refusal-rotation.test.ts | 53 ++++++++++++++++++- .../unit/opencode-request-shape-retry.test.ts | 11 +++- 4 files changed, 101 insertions(+), 11 deletions(-) create mode 100644 changelog.d/fixes/0000-basereds-r3-opencode-retry-audio-toolmap.md diff --git a/changelog.d/fixes/0000-basereds-r3-opencode-retry-audio-toolmap.md b/changelog.d/fixes/0000-basereds-r3-opencode-retry-audio-toolmap.md new file mode 100644 index 00000000000..ca3c2543463 --- /dev/null +++ b/changelog.d/fixes/0000-basereds-r3-opencode-retry-audio-toolmap.md @@ -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 diff --git a/open-sse/executors/opencodeFreeTierRetry.ts b/open-sse/executors/opencodeFreeTierRetry.ts index efb2e05f6e0..2462ee61eb0 100644 --- a/open-sse/executors/opencodeFreeTierRetry.ts +++ b/open-sse/executors/opencodeFreeTierRetry.ts @@ -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"; @@ -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 @@ -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([ + ...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, @@ -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", diff --git a/tests/unit/opencode-free-tier-refusal-rotation.test.ts b/tests/unit/opencode-free-tier-refusal-rotation.test.ts index e80d978f274..3be93880abb 100644 --- a/tests/unit/opencode-free-tier-refusal-rotation.test.ts +++ b/tests/unit/opencode-free-tier-refusal-rotation.test.ts @@ -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", @@ -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)?.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"); diff --git a/tests/unit/opencode-request-shape-retry.test.ts b/tests/unit/opencode-request-shape-retry.test.ts index 5686d085369..f3ea285079d 100644 --- a/tests/unit/opencode-request-shape-retry.test.ts +++ b/tests/unit/opencode-request-shape-retry.test.ts @@ -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>; + 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[] => From 8709ce589c8ee489361b0d4f6431ae9ca2157af4 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:39:13 -0300 Subject: [PATCH 2/6] fix(sse): consume the _toolNameMap side channel in the combined tool-metadata extractor #14751 made extractRequestToolIdentityMap keep a string alias ledger next to namespace identities; #12839's extractRequestToolMetadata already captures and returns that ledger, so it now strips the side channel itself as its contract (and the roundtrip test) require. --- open-sse/handlers/chatCore/requestToolIdentity.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/open-sse/handlers/chatCore/requestToolIdentity.ts b/open-sse/handlers/chatCore/requestToolIdentity.ts index 3515bd235f6..a6b7772e027 100644 --- a/open-sse/handlers/chatCore/requestToolIdentity.ts +++ b/open-sse/handlers/chatCore/requestToolIdentity.ts @@ -13,6 +13,12 @@ export function extractRequestToolMetadata(translatedBody: Record Date: Tue, 29 Sep 2026 11:41:25 -0300 Subject: [PATCH 3/6] fix(audio): answer "Invalid model ID" for unsafe transcription model ids #15067 made the registry parser refuse unsafe ids, which surfaced as a misleading "No transcription provider found". Reject them up front with the precise message. --- open-sse/handlers/audioTranscription.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/open-sse/handlers/audioTranscription.ts b/open-sse/handlers/audioTranscription.ts index 92db782a01f..1aba5183403 100644 --- a/open-sse/handlers/audioTranscription.ts +++ b/open-sse/handlers/audioTranscription.ts @@ -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"; @@ -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)) { From 8c425448076002fc591ef7bc84020434e96b4bef Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:42:29 -0300 Subject: [PATCH 4/6] fix(ci): key the node_modules cache on betterSqlitePrebuildTarget.mjs too #12961 made postinstall.mjs import the helper; a change to it must invalidate the cached node_modules like every other postinstall helper. --- .github/actions/npm-ci-retry/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/npm-ci-retry/action.yml b/.github/actions/npm-ci-retry/action.yml index 29e2960218b..b83a76bcfd0 100644 --- a/.github/actions/npm-ci-retry/action.yml +++ b/.github/actions/npm-ci-retry/action.yml @@ -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' From d90a05d8f9152de61458321f796fbd56e2a6c338 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:42:52 -0300 Subject: [PATCH 5/6] test: propagate intentional contract changes into stale base-red assertions - executor-default-base: cc_version follows the canonical constant (#14627 -> 2.1.280) - alibaba-provider-regions: qwen3.8-flash / deepseek-v4.1-flash leaves (#14273) - chatcore-codex-account-pool: key the 429 on the first account, since #14959 skips intra-retries for a Retry-After: 60 hint - token-refresh-race-comprehensive: mutex closure is now the raced const work (#15002) --- tests/unit/alibaba-provider-regions.test.ts | 3 +++ tests/unit/chatcore-codex-account-pool.test.ts | 9 +++++++-- tests/unit/executor-default-base.test.ts | 8 +++++++- .../token-refresh-race-comprehensive.test.ts | 17 ++++++++++++++--- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/tests/unit/alibaba-provider-regions.test.ts b/tests/unit/alibaba-provider-regions.test.ts index 474605ff8fc..37590607a3f 100644 --- a/tests/unit/alibaba-provider-regions.test.ts +++ b/tests/unit/alibaba-provider-regions.test.ts @@ -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", ]); diff --git a/tests/unit/chatcore-codex-account-pool.test.ts b/tests/unit/chatcore-codex-account-pool.test.ts index 31439c3a1d0..bbacc2f4f4e 100644 --- a/tests/unit/chatcore-codex-account-pool.test.ts +++ b/tests/unit/chatcore-codex-account-pool.test.ts @@ -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 }).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: { diff --git a/tests/unit/executor-default-base.test.ts b/tests/unit/executor-default-base.test.ts index 25d5a8bda2b..be4fe53fadc 100644 --- a/tests/unit/executor-default-base.test.ts +++ b/tests/unit/executor-default-base.test.ts @@ -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 = {}) { @@ -1575,7 +1576,12 @@ test("DefaultExecutor.execute does not produce duplicate anthropic-version heade 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};$/ + // Pinned to the canonical constant: #14627 bumped the advertised client to 2.1.280, and + // the literal 2.1.258 here went stale. The exact value is pinned in + // claude-codex-identity-version-sync.test.ts. + new RegExp( + `^x-anthropic-billing-header: cc_version=${CLAUDE_CODE_CLIENT_BILLING_VERSION.replace(/\./g, "\\.")}; cc_entrypoint=cli; cch=[0-9a-f]{5};$` + ) ); }); diff --git a/tests/unit/token-refresh-race-comprehensive.test.ts b/tests/unit/token-refresh-race-comprehensive.test.ts index 37df7ba9f4b..21ddf4eade9 100644 --- a/tests/unit/token-refresh-race-comprehensive.test.ts +++ b/tests/unit/token-refresh-race-comprehensive.test.ts @@ -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, @@ -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"); @@ -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/); From ef3ca78bb09d9f0f57824c33f3f2d6006406a0a9 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:48:54 -0300 Subject: [PATCH 6/6] test(executor): keep the cc_version assertion within the frozen file-size budget --- tests/unit/executor-default-base.test.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/unit/executor-default-base.test.ts b/tests/unit/executor-default-base.test.ts index be4fe53fadc..e75520f50eb 100644 --- a/tests/unit/executor-default-base.test.ts +++ b/tests/unit/executor-default-base.test.ts @@ -1574,15 +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 ?? "", - // Pinned to the canonical constant: #14627 bumped the advertised client to 2.1.280, and - // the literal 2.1.258 here went stale. The exact value is pinned in - // claude-codex-identity-version-sync.test.ts. - new RegExp( - `^x-anthropic-billing-header: cc_version=${CLAUDE_CODE_CLIENT_BILLING_VERSION.replace(/\./g, "\\.")}; 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', () => {