mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-10-23 23:00:19 +00:00
fix(release): clear 12 base-reds of release/v3.8.51 (opencode retry, tool-map side channel, audio id, CI cache key, stale contracts) (#15112)
Release-captain base-red fix (v3.8.51 release PR #11442, unit shards 5-6): three production defects (OpenCode free-tier retry could resend the exact refused body; _toolNameMap side channel left in the Gemini/Antigravity body; unsafe audio model ids reported as 'no provider found' instead of 400) plus contract propagations (#12961 cache key, #14627, #14156, #14273, #14959, #15002). All twelve reds reproduced on the tip and pass; typecheck clean.
This commit is contained in:
parent
a8e97963bf
commit
ed664fc8b8
11 changed files with 143 additions and 21 deletions
2
.github/actions/npm-ci-retry/action.yml
vendored
2
.github/actions/npm-ci-retry/action.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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<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,
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 = {}) {
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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<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");
|
||||
|
|
|
|||
|
|
@ -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[] =>
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue