Repository navigation
feat: the Auto mode classifier's two stages can each take their own model - #91
Conversation
… target
`autoModeClassifier.stage1: { provider, model, effort }` sends only the
first stage's yes/no (one stop sequence `</block>`, max_tokens < 8192) to
its own target. The second stage, the server's fast mode, the severity
score and any other shape keep the main target.
Claude Code 2.1.310 refuses Sonnet 5.5, Opus 5.5 and Opus 5 in
CLAUDE_CODE_AUTO_MODE_MODEL because the API's safeguards refuse the
classifier's second stage on them. Measured: six Sonnet 5.5 first stages
answered in 0.8-2.2 s, both second stages came back `refusal` with no
output. So the first stage can run on Sonnet 5.5 while the second stays on
a model that answers it, such as GPT-6 Luna.
A first-stage "no" allows the action exactly as it does natively; a "yes"
always reaches the main target's full second stage; a failed first stage is
a block in the engine.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…inary routing The main target was required, so a first-stage target always came with a target for everything else. Now `provider`/`model` may be left out, and `stage1` may be "native": a classifier request neither side claims goes through ordinary routing, slot routes included, exactly as without the setting. So the first stage alone, the rest alone, or both can move, each to any provider and model. Naming no target at all is a config error rather than a silent no-op. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Clients page gains an "Auto mode classifier" section: a first-check row and a second-check row, each a provider and model or Claude Code's own choice, with an effort. Two pure functions, `classifierRowsOf` and `classifierSettingOf`, are the only translation between the rows and the config's four spellings; one model on both rows is saved without `stage1`. A Claude account-pool provider with no model list is offered with the Claude catalogue, since giving it a list would make it the routing owner of those ids for every request. The models Claude Code refuses as its own classifier are labelled in the second-check row. The rows put their menus on a line of their own: two menus beside a two-line explanation ran over the text. Catalogue text for all six languages by GPT-6 Astra. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PBJ-2
left a comment
There was a problem hiding this comment.
Thank you for this, and for documenting the classifier protocol while keeping the existing single-target setting working. A per-stage target is useful, and the three-way merge with current main is clean and typecheck passes. A few things need fixing before it can go in:
- Overlapping saves can store the previous choice.
saveAutoClassifier()reads the selects only afterawait latestConfig(). If B is chosen while A is still saving, A's completion redraws the controls and B's handler then reads and saves A. Please capture the selected targets before the firstawait, and keep an older save from overwriting a newer choice. - Provider removal does not know about
stage1. The provider usage/removal path onmainonly checksautoModeClassifier.provider. Removing a provider used only bystage1leaves a dangling reference, and the next config save is refused withautoModeClassifier.stage1: unknown provider. Removing the top-level provider drops the whole setting, including an independentstage1. Please handle each side separately and add tests for both. - The two new UI tests fail. In
ui-auto-classifier.test.ts, the source sliced fromapp.jsends in a//comment, so the appended; return { ... }is commented out,loadTranslation()returnsundefined, and both tests throw aTypeError. A newline before thereturnfixes it. - "Provider default" is not what happens. Leaving the effort empty omits
effort, andrewriteBody()then keeps the request's ownoutput_config.effort(ahighrequest stayshigh). Labelling it as following the request's effort would match the behaviour.
One more question: the stage-one check accepts max_tokens < 8192, while the documented shape is 64 + n. A large enough thinking allowance would send a stage-one request to the second target. Could you explain the bound, or cover non-zero allowances in the tests?
Happy to look again once these are in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…removed provider's stage Review on PBJ-2#91: - A choice is read from the rows before anything is awaited, and saves run one at a time; one that a newer choice has overtaken is dropped. Before, a save that landed redrew the rows, and a choice made meanwhile then read and saved the earlier one. - Removing a provider clears each classifier row that named it on its own (`classifierNames`, `classifierWithout`): that row goes back to Claude Code's choice and the other row stays. Before, a provider used only by `stage1` was left behind and the next save was refused, and removing the top-level provider dropped an independent `stage1` with it. - The translation tests sliced app.js up to a line comment, so the appended return was commented out. The slice now ends before the comment, and the return starts on its own line. - The empty effort is labelled as keeping the request's effort, which is what happens: no `effort` is saved and the request's own `output_config.effort` stays. - The first-stage tests run with both thinking allowances 2.1.310 adds (0 and 2048), and the doc says why the `max_tokens < 8192` bound sits where it does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sits between Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thank you for the careful review. All four are fixed in f7a90a0, with the bound explained in e68736f, and the branch now merges current
On the bound: in 2.1.310 the allowance is fixed, not free. The classifier builder adds 2048 for a model that rejects disabled thinking and 0 otherwise, so a first stage asks for 64 or 2112 tokens and a second stage for 8192 or 10240. Fast mode asks for 256 or 2304, with no stop sequence. 8192 is the smallest second stage and lies above every first stage. If a later release grew the allowance past it, its first stages would go to the rest's target, which only costs time. The bound never sends a full verdict to the first-stage target. The routing test now runs every shape at n = 0 and n = 2048 and pins the oversized case. The reasoning is in the On this Windows machine the full suite has 20 failures that clean |
Brings PBJ-2#91's review fixes and current upstream main. The two conflicts kept both sides: upstream's conversation key on the request record and the SSE parser tests, beside this branch's request kind. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the comment on the first-stage bound from PBJ-2#91. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings provider-qualified model ids (PBJ-2#94). The one conflict was upstream deleting a stale doc comment beside which this branch had put isClassifierStage1; the function stays and the comment goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
A note on CI, so the red checks are not taken as coming from this PR.
|
Brings current upstream main through PBJ-2#91: provider-qualified model ids and the fix that keeps a Claude request with a delegation marker passing through (PBJ-2#94). Upstream made that fix in resolve() and unroutableReason() side by side. Here both live in decide(): the qualified-id branches are placed as upstream placed them, and every way of finding no route ends in one noRoute() that applies upstream's refusal rules in their order. Checked against PBJ-2#91's resolve() and unroutableReason() over 41,664 combinations of config, model and body (16,124 routed, 10,545 passed through, the rest refused with all five messages): no difference. The upstream tests that called unroutableReason() now read decide(...).refusal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PBJ-2
left a comment
There was a problem hiding this comment.
Thank you for the targeted fixes and the detailed explanation of the thinking allowance. I rechecked the four original points against the code and tests.
- The original overlapping GET/PUT cases are fixed: both rows are captured before awaiting, saves are serialized, and an older completion no longer redraws over a newer selection.
- Provider removal now handles each stage independently. Both removal directions are covered and preserve the other stage.
- The source-extraction issue is fixed, and the UI tests pass.
- The empty-effort label now correctly describes keeping the request's effort in all six languages. The allowance and oversized-budget tests also address the earlier bound question.
There is one remaining save race before this can go in:
Preserve pending classifier choices across the periodic status refresh. In packages/ui/app.js:1314–1319, renderAutoClassifier() rebuilds both rows from currentConfig unconditionally. The five-second refreshHealth() path calls it through renderClients(), outside the save-generation guard.
I reproduced this with the actual save function: start with Sonnet on the first row, select Haiku, hold that save's GET, let the status redraw run, then change the second row. The redraw resets the first row to Sonnet; the second save captures Sonnet and supersedes the pending Haiku save, so Sonnet is persisted instead of the selected Haiku.
Please keep the pending row draft across status redraws, or avoid rebuilding unchanged classifier controls, and add this refresh-between-edits sequence to the regression tests.
A temporary merge with current main (6158026, 0.12.21) was clean. Typecheck and 172 focused tests passed, including the classifier, provider-removal, worker-failover and thinking-history tests. The 19 Windows failures in this PR's CI also appear on current main, so I don't attribute them to this change.
Since #98 includes this branch, I'd like to land #91 first once this is fixed, keeping the classifier feature separate from the broader refactor.
The five-second status refresh redraws the classifier rows through renderClients(), outside the save generation guard, and drew them from the stored setting. A redraw while a save was waiting put the stored value back in the row, and the next choice on the other row read it and saved it over the pending one. The rows are now drawn from the newest choice until it is stored (or its save fails), then from the config again. The new test runs the reported sequence: pick Haiku on the first row, hold that save's read, redraw, change the second row, and both choices are stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PBJ-2
left a comment
There was a problem hiding this comment.
Thank you, this is the fix I asked for, and it works. I re-ran the exact sequence against the real renderAutoClassifier sliced out of app.js (first row Sonnet, choose Haiku, hold that save's GET, let the five-second status redraw run, then change the second row). At 9107150 the redraw puts Sonnet back and the final PUT stores stage1: Sonnet; at 2148f34 the redraw keeps Haiku and the PUT stores stage1: Haiku. classifierPending clears on both exits, success at app.js:1360 and failure at app.js:1363–1365, and a superseded generation returns without touching it, so a refused save never pins a draft over the stored setting.
One small thing we'll fix on our side right after merging, not a reason to hold: the new regression test doesn't guard the call site it is named for. loadSave() defines its own renderAutoClassifier (ui-auto-classifier.test.ts:120–123), so the production function is never on the path: reverting only app.js:1317 still passes 6/6. We'll slice the real function out of app.js instead, so the test fails when the call site regresses.
The merge with current main (f22c4b6) is clean, typecheck passes, and the router suite passes (1,342 passed, 3 skipped, 0 failed), including all ui-* tests. Merging now, ahead of #98 as discussed. Thanks for the careful work on the pending draft.
…ave test The status-redraw regression test from #91 defined its own renderAutoClassifier, so reverting the fixed call site in app.js still passed. The harness now slices the real function and stubs only the DOM and the row drawing, and it fails when that line regresses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No conflicts. Main now holds PBJ-2#91, so the PR's diff against main is the refactor and the Windows fixes alone. Main's new code repeats none of the target, server-tool or prompt rules this branch moved into targets.ts and identity.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Five deepening candidates with a before/after flow graph each, a top recommendation, and what should be left alone. The router change before this commit is its candidate 1; candidate 3 comes next. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What this is for
Auto mode asks a classifier, in two stages, whether an action can run without asking the user. The first
stage is a quick yes/no on every action. Only a "yes" goes on to the second stage, which reasons it
through and gives the final answer. Today
autoModeClassifiersends both stages to one target.The two stages want different things: the first is paid on every action and wants speed, the second
sees only what was flagged and wants judgement. One model is not always the right pick for both, and
some models cannot answer both (see the background below). This lets the operator choose each side
on its own, including leaving either side exactly as Claude Code sends it. No model is built in or
preferred; every model named here is an example.
Configuration
autoModeClassifiergainsstage1, and its top-level target becomes optional. "The rest" is the secondstage plus every other classifier request: the single-stage mode a server can switch on, and severity
scores.
{provider, model, effort}{provider, model, effort, stage1: {…}}stage1{stage1: {…}}stage1{provider, model, effort, stage1: "native"}Ordinary routing is what the request gets without the setting. A setting that leaves both sides to it
is a config error rather than a silent no-op. Existing configs route exactly as before.
Some settings this allows:
A Claude model is reached through an
anthropicprovider withaccountPool: trueand nomodelslist:without a list it is a target only where a setting names it, so no other request moves to it.
Dashboard
The Clients page gets an "Auto mode classifier" section with one row per side. Each row is a provider and
model, or "Claude Code's own choice", plus an effort; left empty, no effort is saved and the request keeps its own. Two pure functions in
app.js,classifierRowsOfandclassifierSettingOf, are the only translation between the rows and thefour spellings above; the same model on both rows is saved without
stage1. A Claude account-pool providerwith no model list is offered with the Claude catalogue. The models Claude Code itself refuses as its
classifier are labelled in the second row. A choice is read when it is made and saves run one at a time, so the
last choice is the one stored. Removing a provider clears each row that named it on its own: that row goes
back to Claude Code's choice and the other row stays. Text for all six dashboard languages.
How the first stage is recognised
From the Claude Code 2.1.310 binary (
nis the thinking allowance added to every stage: 2048 when theclassifier model rejects disabled thinking, 0 otherwise):
max_tokensstop_sequences["</block>"]yesblocks)["</severity>"]The test is one stop sequence,
</block>, andmax_tokens < 8192. 8192 is the smallest second stage andlies above the largest first stage (2112). Any shape it does not recognise goes to the rest, so an allowance grown past the
bound in a later release would only make first stages slower.
Why splitting the stages does not loosen the check by itself
changes. So the first-stage model is the one deciding which actions are never looked at twice, and it
has to be chosen for its judgement. ARCHITECTURE says so where the setting is described.
allow.
Background: the case that prompted this
Claude Code 2.1.310 honours
CLAUDE_CODE_AUTO_MODE_MODEL, but refusesclaude-sonnet-5-5,claude-opus-5-5andclaude-opus-5there, because "the API's safeguards refuse the classifier'sescalated check on it". I sent both stages to Sonnet 5.5 through an
accountPoolprovider: six firststages answered in 0.8–2.2 s, and both second stages came back
stop_reason: "refusal"with no output,which the engine turns into a block. Sonnet 5.5 can serve the first stage only, and that needs the split.
Verification
The failure is
packages/remote/test/diff.test.ts"an untracked symlink's target is never read…":EPERMcreating a symlink on a Windows account without that right. It fails the same way on main on this
machine.
npm run typecheckandgit diff --checkpass.Live on Windows with Claude Code 2.1.310, with Sonnet 5.5 on the first stage and GPT-6 Luna on the rest:
nine first stages went to Sonnet 5.5 (0.9–6.2 s, all
stop_sequence, no refusal), and two second stageswent to Luna (one allowed, one blocked with a reason). The dashboard section was checked against the live
admin API in a read-only preview at desktop and phone widths. The
stage1-only and"native"forms arecovered by unit tests, not yet by a live run.
🤖 Generated with Claude Code