Conversation
Opens the docs-only cycle for the next release train. The planning note this started from targeted v2.31.1; that baseline is void because v2.32.0 shipped from main on 2026-08-24. This unit re-derives the baseline from live git state and plans the train as v2.32.1, bugfix-only. The first draft got the branch relationship wrong: it read a one-way --is-ancestor result as divergence. An independent audit re-ran both directions and dev turns out to be an ancestor of main, 0 ahead and 27 behind, with a one-line tree delta. wp1 is therefore a fast-forward, not a backmerge, and the correction is recorded in the document rather than quietly fixed. Three audit rounds moved two other things. #2427 was reordered from first to last: changing the test runner before the runtime fixes would make every later failure ambiguous between a real regression and parallel-execution flakiness. And #2472's regression got its own work-phase (wp9) once the audit pointed out the plan had made it a mandatory gate while assigning nobody to write it. Contents: 000 baseline/scope/roadmap, 001 verbatim reviewer-lane evidence, and one diff-level decade doc per implementation phase (010 wp1, 020 wp3/#2483, 030 wp4/#2481, 040 wp5/#2473, 050 wp6/#2477, 060 wp7/#2476, 070 wp2/#2427, 080 wp8 freeze, 090 wp9/#2472). No code changes. No promotion, tag, or publish.
devlog: v2.32.1 hotfix train roadmap unit, and put dev on the v2.32.0 release lineage
…inking (#2483) * fix(anthropic): classify capitalized/dotted Claude ids as adaptive thinking claudeFamilyVersion only matched lowercase `claude-\<family>-\<major>-\<minor>` ids. Vendor ids such as `Claude-Opus-4.8-joybuilder` failed both the case-sensitive prefix match and the dotted minor parse (4.8 -> minor 0), so usesAdaptiveThinking() returned false and the adapter sent the legacy `thinking: {type: "enabled", budget_tokens}` wire shape to models that reject it (Bedrock 400: "thinking.type.enabled is not supported for this model. Use thinking.type.adaptive and output_config.effort"). Make the parser case-insensitive, accept `.` as a minor separator, and lowercase the captured family before table lookup. Date-pinned ids (claude-opus-4-20250514) and legacy families (opus <= 4.6) keep their previous classification. * test(anthropic): cover capitalized/dotted Claude ids in thinking wire-shape matrix Regression for the family parser fix: Claude-Opus-4.8-joybuilder and claude-opus-4.8-joybuilder must pick the adaptive wire shape, while Claude-Opus-4.6-joybuilder (below the adaptive threshold) must stay on the legacy thinking.enabled shape. * fix(anthropic): reject a longer number, not any dot, in the family tail The capitalization and dotted-minor repair is right, but widening the tail from (?!\d) to (?![\d.]) to stop "claude-opus-4.20250514" also rejected "claude-opus-4-8.1": the minor group matches "8", the tail sees the following dot, the match is discarded, and the regex backtracks to a major-only "4.0". That id parsed as Opus 4.8 before this PR, so it would newly take the legacy thinking.enabled wire shape — the exact 400 this change exists to prevent, reintroduced for a different id family. A dotted suffix after a dashed minor is not a dotted minor. The tail's job is to reject a longer NUMBER, which the original (?!\d) already did; the dotted-minor support belongs entirely to the [.-] separator. Keeping (?!\d) and adding only [.-] and /i covers every id the PR intended, preserves every id the old regex classified correctly, and additionally recovers "claude-opus-4.20250514" and "claude-opus-4.8.1", which the wider tail turned into no match at all. Tests: the adaptive matrix gains the dashed-capitalized and end-of-string dotted cells so the capitalization and separator axes are covered independently, plus "claude-opus-4-8.1" as the regression for the above. The legacy matrix gains a capitalized date-pinned id, which previously reached that branch by failing to parse rather than by parsing correctly. The #545 explicit-disable matrix gains "Claude-Sonnet-5" — the only case that exercises claudeFamilyVersion's second caller, where a miss is invisible because the request simply goes out without the disable. Verified red-then-green: with the original tail restored, only the claude-opus-4-8.1 case fails (59 pass, 1 fail); with this correction, 60 pass, 0 fail. tsc --noEmit clean. --------- Co-authored-by: liyongjie.103 <liyongjie.103@jd.com> Co-authored-by: bitkyc08-arch <bitkyc08@gmail.com>
…ches it (#2481) * fix(catalog): match selectedModels the way the canonical resolver matches it `filterCatalogVisibleModels` built the per-provider allowlist as a plain `Set(selectedModels)` and tested it with `allow.has(m.id)` — the native model id, exactly. The canonical resolution of the same list keys it through the slug equivalence: sync.ts:819-821 new Set([...models].map(m => slugEquivalenceKey(routedSlug(provider, m)))) sync.ts:1039 selected === undefined || selected.has(slugEquivalenceKey(slug)) so the two disagree for any provider whose native ids contain a slash: stored "moonshotai/kimi-k3-free" sync accepts=true catalog filter accepts=true stored "moonshotai-kimi-k3-free" sync accepts=true catalog filter accepts=false The second is the Codex-facing slug `routedSlug()` produces and the picker displays, and `ocx models remove` already accepts it (tests/cli-models.test.ts:332, "models remove accepts raw and encoded slash selectors"). An allowlist written from what the user sees therefore blanked the provider's catalog silently, while `routeModel` decoded the same string back and served the model happily. Affects providers with slash-bearing native ids: openrouter, zenmux, nvidia, together, fireworks. The `disabledModels` loop three lines above is already tolerant of both forms via `slugEquals`, and slug-codec.ts:20-21 states the rule this restores: "Config comparisons are tolerant … so legacy raw values keep working regardless of which form was stored." * test(catalog): pin the lossy collision and record the rejected alternative The key comparison is right for the reported bug, but it is lossy in a way worth writing down: "a/b" and "a-b" collapse to one equivalence key, so a provider publishing both spellings has them selected together. That behavior now has tests asserting what the code actually does, rather than being left for someone to discover from a support thread. It also has a rejected alternative recorded next to it. Resolving each selection against the provider's current rows looks stricter and is not: the roster is an incomplete dictionary, so when live discovery omits "a-b" but returns "a/b", an exact "a-b" selection resolves onto "a/b" and reproduces the same over-grant. It would additionally make fresh filtering disagree with the equivalence relation sync.ts applies when merging the persisted catalog — two catalog stages with different rules is the bug class this change removes. The real fix is one selection resolver shared by filtering, persisted sync, CLI removal, and routing, evaluated against a complete known-id set, with a single ambiguity policy. That is an architecture change and does not belong in a bugfix-only release; tracked as #2491. Tests: 242 pass across selected-models, codex-catalog, slug-codec, and cli-models. tsc --noEmit clean. --------- Co-authored-by: bitkyc08-arch <bitkyc08@gmail.com>
* fix(codex): keep oversized Responses turns off the WS transport The Codex backend closes the socket on any inbound message of 16 MiB or more without sending a Responses terminal event, which reached clients as a bare 502 upstream_server_error. Because the wrapper only fell back to SSE when the *upgrade* failed, a thread that crossed the ceiling could never recover: every retry resent the same oversized frame. Measured against the live endpoint on 2026-08-23: 16,777,000 B completed, 16,777,300 B closed the socket in ~1s, reproducibly. The same body still succeeds over HTTP SSE, so the limit belongs to this transport alone. Size the `response.create` frame before dialing and take the SSE path when it does not fit. Deciding before the socket opens is what keeps the resend safe -- after open the caller already holds a streaming Response, and a retry there could double-generate the turn. Two supporting changes: - Carry the WS close code and reason into the stream error. A 1009 was previously indistinguishable from a network drop, and nothing in usage.jsonl or /api/logs recorded the real cause. - Apply the provider's `upstreamHttpVersion` pin to the SSE fallback. The fallback is a routine path now, and serving a turn over HTTP while silently dropping the operator's protocol pin is wrong. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(codex): pin the transport boundary at the adjacent byte The sizing helper already had unit tests, but nothing proved the real serialized frame routes correctly one byte on each side of the limit. That gap matters because the request body is not the frame: `stream` is deleted and `type` is added before sending, so padding sized against the body sits eleven bytes away from what is actually transmitted. An off-by-one would live exactly there and pass every existing test. These two build padding so the serialized frame is exactly limit-1 and exactly limit, then assert the whole path: one socket and one send of the expected byte length under, zero sockets and one SSE call at it. Flipping the gate from >= to > fails the second one, so it catches a real off-by-one at the transport level rather than only in the helper. Two comment corrections while here. The close-code comment claimed the named 1009 message makes the failure diagnosable from the logs; it does not. The eager relay turns any stream error into a generic `upstream_reset` synthetic terminal without feeding it back through the inspector, so `/api/logs` retains only `streamAborted`. The message reaches the client and stops there, and the comment now says so rather than promising observability the code does not deliver. The margin comment described 64 KiB as absorbing a future append. There is no append. It is a conservative cushion, and the useful thing to record is what it actually covers: RFC 6455 framing is 14 bytes at this payload size — an 8-byte extended length plus a 4-byte client mask — so even a backend counting frame headers has ~65.5 KiB of room. Tests: 59 pass, 1 skip across ws-upstream, sse-failed-tail, and upstream-http-version. tsc --noEmit clean. --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: bitkyc08-arch <bitkyc08@gmail.com>
* fix(responses): honor namespace tool choice
* fix(responses): match the tool kind, not just the name, when arming aliases
Narrowing the alias map by tool_choice was right, but the allowed_tools
branch matched entries by name alone. Entries there are typed
{type: z.string()} by the schema, so the accepted set is open-ended, and
a selector naming a different KIND of tool contributed a function name it
has nothing to do with. An upstream answering with that wire name then
had it restored into a namespaced client call the caller never selected.
Restricting the branch to function|custom closes that, and it has to be
an allowlist rather than a denylist: enumerating kinds to reject can
never be complete when the schema accepts any string, and a kind added
next year would arrive pre-authorized.
That left a narrower version of the same mismatch. The alias identity
carried only {namespace, name}, so the declared kind was gone by the time
tool_choice was compared: a tool declared function could be selected by a
custom selector, and vice versa, both schema-valid. A wire name says
which tool, not what kind of call may carry it. The identity now keeps
the kind it was declared with, and both selector branches require it to
agree.
Tests cover fourteen non-function kinds plus an unknown future one, each
asserting the alias map stays empty and an upstream call carrying that
wire name is left unrestored with no namespace injected. Positive
controls declare and select the same kind so the filter cannot pass by
being deny-all, and two cross-kind negatives cover both directions
through both the forced and allowed_tools shapes. The default cases —
absent, auto, required — are pinned as unrestricted, since narrowing
should apply only where the caller narrowed.
Verified red-then-green: reverting only the type filter fails fifteen
cases; reverting only the kind match fails the cross-kind pair.
189 pass across namespace-tool-compat, responses-parser,
openai-responses-passthrough, and responses-opaque-blob-recovery, plus 71
across the undeclared-tool-guard and custom-tool-compat suites.
tsc --noEmit clean.
---------
Co-authored-by: bitkyc08-arch <bitkyc08@gmail.com>
#2476) * fix(responses): stop rewriting an unchanged snapshot every two seconds `responses-state.json` is bounded at 24 MiB and rewritten whole on a fixed 2 s debounce, so under sustained traffic every cycle paid a complete re-serialization plus an atomic replacement of a file nothing reads until the next start. Two narrow measures, both scoped to the write path: - A flush that would reproduce the existing file byte-for-byte is skipped. A mutation does not always change what gets persisted — entries past the per-entry or total bound are dropped from the selection, and spill demotion moves bytes out of it. The comparison is a length plus a Bun.hash digest rather than the retained payload, which at the 24 MiB bound would double the snapshot's memory cost. The skip is conditional on the file still existing, so a snapshot deleted underneath the process is restored. - The debounce scales with the size of the last snapshot written: base 2 s below 1 MiB, linear above it, clamped at 30 s. The write rate is then roughly flat as the cache grows instead of growing with it. Durability is unchanged for a graceful shutdown, which flushes; a longer debounce only widens the window in which a hard kill loses the most recent continuation entries, which are cache. Journal / incremental store deliberately not attempted here. Refs #2460 * docs(troubleshooting): say the debounce follows the last snapshot written The section read as though the cadence tracked the pending snapshot. It tracks the size of the last snapshot actually written, so a cache that has only just grown still takes the short wait once. Review feedback on #2476. * fix(responses): verify the snapshot on disk before skipping a write Skipping a byte-identical rewrite is the right fix for the amplification, but the cached digest describes what this process last wrote, which is not the same claim as what is on disk now. A second proxy sharing the home, or anything that rewrites the file in place, leaves the digest describing bytes that are gone. That matters more than it sounds. Before the skip existed, every flush rewrote the file and so repaired external damage silently. Skipping on the digest alone turns a self-healing snapshot into a permanently corrupt one, and nothing notices until the next restart fails to load the continuation state. Replacing the file with different bytes of the same length reproduces it: the digest still matches, the file still exists, and the flush declines to repair. The skip now verifies identity against the file itself. The cached digest is keyed to the resolved write target, so a config-dir change or a retargeted symlink is a miss rather than a false match, and the contents are compared byte-for-byte before declining to write. Size is checked first so the common mismatch costs a stat, any read failure answers "no" and the caller rewrites, and the read only happens when the digest already agreed. The amplification being fixed is the repeated 24 MiB atomic replace, not the read that avoids it. This also removes the need to trust Bun.hash for correctness. It stays a cheap first filter, but a collision can no longer produce a false skip. Tests: same-length external replacement must be rewritten, proven by reverting only the disk check. The docs line claiming graceful shutdown "always flushes" is corrected too — the flush is a disk write and can fail like any other, and writeBoundedSnapshot swallows that into a "failed" outcome the lifecycle warning cannot see. 124 pass across write-amplification and responses-state. tsc clean. --------- Co-authored-by: bitkyc08-arch <bitkyc08@gmail.com>
Two threads were opened on #2477 and #2476 shortly before each merged, so neither was addressed. Both are real and both are one-line predicates. A selector's namespace is either absent — meaning "unqualified, resolve the bare name" — or a string naming the group. rewriteNamedSelector treated every non-string value as absent, so {type:"function", namespace:1, name:"safe"} took the unqualified path, resolved to a namespace wire name, and authorized an alias the caller never qualified. A wrong-but-valid namespace already failed closed; only malformed ones slipped through. Present-and-invalid now returns the selector untouched. The snapshot fast path compared content but not permissions. This file holds persisted request and response bodies and is written owner-only, and the unconditional rewrite used to restore that on every mutation. Skipping on content alone let a broadened mode persist for the life of the process — a durable privacy regression rather than a slow one. A widened file is now treated as not matching, so the caller rewrites it through the hardening path. POSIX-only check; Windows ACLs are re-applied by that same write path. The docs claim that any byte-identical flush is skipped is also corrected: the skip needs this process to have written the same bytes to the same target, and the file to still match. Tests: five malformed namespace shapes plus an allowed_tools entry, with positive controls proving neither the qualified nor the unqualified path regressed; and a mode-broadening regression. Both mutation-proven — reverting either predicate fails exactly its own cases.
…2501) Refusing to rewrite a malformed selector was not enough. When its name is already the flattened wire name — {type:"function", namespace:1, name:"collaboration__safe"} — returning it unchanged leaves an exact match against the alias map, so the alias arms anyway and an upstream call is restored into the namespace. Both selector shapes reach it. The check belongs at the authorization gate, not only in the rewriter: a malformed namespace makes the whole selector untrustworthy regardless of which name it carries. The unqualified shape is untouched, since an absent namespace is a deliberate, legitimate selector. Also states the POSIX permission precondition in the troubleshooting page, which described only the content half of the skip rule. Tests cover both malformed-with-wire-name shapes and keep a positive control on the unqualified selector so the fix cannot pass by being deny-all. 186 pass across namespace-tool-compat, responses-parser, openai-responses-passthrough, and the write-amplification suite. tsc --noEmit clean.
The readiness report freezes dev at faaa78d with the verdict GO, and records the audit that got it there: the first freeze at 02c302a was rejected, correctly, on three counts. Two of the three unresolved review threads it found were live defects that had been opened minutes before their PRs merged and so were never addressed — a malformed namespace authorizing a tool alias, and the snapshot fast path never restoring broadened permissions on a file that holds request and response bodies. Both are fixed and both needed a second pass, because the first fix for the namespace case was itself incomplete against a pre-flattened wire name. The third finding was the fairest: the full-suite gate was red and the report argued an exception for it. Arguing an exception in the document that reports the result is gate-weakening after the fact. The gate is now decomposed the way CI actually partitions the suite, and passes in that form: 14565 pass across the general batches, storage-policy green in its own job, with the single api-usage failure proven identical on the untouched pre-train baseline. Also records the two units that closed without merging. #2472 is NOT_REPRODUCED: a regression was written, passed, and then deleted once review showed it pinned the pre-execution announcement path rather than the post-execution loss the issue describes. #2427 is deferred on five runs of data — four different tests flaked, each green in isolation — because a runner that fails one run in three would make the freeze gate itself unfalsifiable.
Contributor
|
✅ Deterministic PR hygiene checks passed. |
Contributor
⏳ DRAFT
What to do
Its title has been prefixed with |
Contributor
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (27)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Promote
devintomainfor the v2.32.1 bugfix release.devheadbb89eafbecarries the closed v2.32.1 hotfix train: seven merged work phases plus two post-merge review closures.main(96e2f67c3, v2.32.0) is an ancestor ofdev, so this promotion introduces no divergence.Code changes since v2.32.0:
selectedModelsthe way the canonical resolver matches ittool_choicefor namespace aliasesThe remaining commits are devlog-only.
Verification
faaa78dc0;git diff --name-only faaa78dc0 bb89eafbeoutsidedevlog/is empty.32793104507onfaaa78dc0: success, every job green.devlog/_plan/260824_v2_32_1_hotfix_train/900_go_nogo_readiness_report.md.Checklist