Repository navigation
fix(claude-code): record the Bedrock route model as model_used - #249
NopeCharon wants to merge 2 commits into
Conversation
On a Bedrock route the Claude SDK reports a friendly alias on AssistantMessage.model (for example `claude-sonnet-5`) even when the run was pinned to a fully qualified inference profile such as `eu.anthropic.claude-sonnet-5`. That alias became TurnRecord.model_used and then task.json model_used, so runs on distinct Bedrock routes grouped under one non-route-specific key, and a campaign that pins qualified ids could not prove the pin from model_used alone. The turn state now reports the resolved route model (`_resolve_effective_model`, already used for cost backfill and ANTHROPIC_MODEL) as model_used on a Bedrock route. Other routes keep the SDK-reported id, which there is the precise dated model. If no model was resolved on Bedrock, the SDK value is still used. The raw SDK alias stays on each messages[].model. Pricing is unaffected: both pricing.py and the evalboard normalize the region and vendor prefixes away before the rate lookup. Fixes UiPath#99
uipreliga
left a comment
There was a problem hiding this comment.
Review: fix(claude-code): record the Bedrock route model as model_used
PR #249 by @NopeCharon · fix/claude-bedrock-model-used → main · OPEN · reviewed against 6e9db40 · 2026-11-05T10:26Z · workflow variant
Change class: complex — changes the value semantics of the persisted model_used run-record field on Bedrock routes (a cross-repo contract), not just a mechanical edit
PR 249 is a correct, narrowly scoped fix: on a BedrockRoute only, model_used now records the resolved inference-profile id, and direct and LiteLLM routes keep the SDK-reported model. No finding can change a task's score or final_status for identical agent output, and there are no critical, high or medium findings. The remaining risks are all low: the persisted model_used changes meaning by route but the schema SSOTs do not say so, and the new test does not cover the persisted TurnRecord, the crash path, the LiteLLM side of the guard, or real alias qualification. Bottom line: safe to merge after the small doc and test fixes below.
Summary
| Axis | Score | 🔴 | 🟠 | 🟡 | 🔵 | Top Issue |
|---|---|---|---|---|---|---|
| 1. Code Quality & Style | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 2. Type Safety | 9.9 / 10 | 0 | 0 | 0 | 1 | New parametrized test leaves its parameters unannotated, unlike neighboring parametrized tests |
| 3. Test Health | 9.7 / 10 | 0 | 0 | 0 | 3 | New model_used test asserts only the AgentEndEvent, not the persisted TurnRecord or the crashed=True partial-turn path |
| 4. Security | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 5. Architecture & Design | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 6. Error Handling & Resilience | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 7. API Surface & Maintainability | 9.8 / 10 | 0 | 0 | 0 | 2 | Per-route meaning of persisted model_used changed, but only the agent page documents it. The schema SSOTs and the downstream-impact note were not updated. |
| 8. Evaluation Harness Quality | 10 / 10 | 0 | 0 | 0 | 0 | — |
Overall Score: 9.9 / 10 · Weakest Axis: Test Health at 9.7 / 10
Totals: 🔴 0 · 🟠 0 · 🟡 0 · 🔵 6 across 8 axes.
Blockers
None.
Non-blocking, but please consider before merge
None.
Nits
- [Axis 2] New parametrized test leaves its parameters unannotated, unlike neighboring parametrized tests (
tests/test_agent.py:1965) —def test_model_used_on_agent_end(self, route, effective_model, expected):has no annotations on its parametrized arguments. Nearby parametrized tests in the same file do annotate them (e.g. line 438async def test_blank_system_prompt_is_treated_as_unset(blank: str):, line 532...(blank: str):). Annotate them asroute: ApiRoute, effective_model: str | None, expected: strso the test's input shape is self-documenting, theNonecase is explicit, and the test matches file convention. This is a cosmetic, cold-path issue: pyright excludes tests, and production typing is correct. The new_ClaudeTurnState.model_used(self) -> str | None(claude_code_agent.py:703) matchesAgentEndEvent.model_used: str | None(streaming/events.py:167) and botheffective_model/sdk_model_usedfields, which are typedstr | None. - [Axis 3] New model_used test asserts only the AgentEndEvent, not the persisted TurnRecord or the crashed=True partial-turn path (
tests/test_agent.py:1976-1977) — The test checks(end,) = [e for e in events if isinstance(e, AgentEndEvent)]/assert end.model_used == expected, and drives onlystate.finalize(AgentEndStatus.COMPLETED). The docs promise more than that: docs/agents/CLAUDE_CODE.md:67 says "model_used(per iteration and on the task) records that resolved inference-profile id". The per-iteration value is theTurnRecordthatEventCollector.build_turn_recordrebuilds asmodel_used=end.model_used or self._model(streaming/collector.py:221), and that record is what goes into task.json. A realEventCollectoris already wired into_state()(line 1890), so the fix is cheap. Addassert state.collector.build_turn_record().model_used == expected. Also add onecrashed=Truecase,state.finalize(AgentEndStatus.TIMEOUT, crashed=True, crash_reason="x")followed byassert agent.pending_turn.model_used == expected, so the timeout and crash rows that get persisted are covered too. Severity is Low because the collector only passes the value through and every finalize path calls the sameself.model_used(). Still, as written, a collector change that preferredself._model(the SDK alias from TurnStartEvent) would pass this test while silently putting the alias back into task.json. - [Axis 3] No LiteLLMRoute case for the reverse side of the isinstance(BedrockRoute) guard (
tests/test_agent.py:1962) — The PR contract says direct and LiteLLM routes keep the SDK-reported model. Only the direct route is tested:pytest.param(DirectRoute(), "sonnet", "claude-sonnet-5", id="direct-records-sdk-model"). LiteLLM is the sibling route most likely to be broken by an over-broad future change._resolve_effective_modelresolves a non-Noneeffective_modelfor LiteLLM and syncsANTHROPIC_MODELthe same way it does for Bedrock. Separately, test_litellm_route.py:642 depends on the SDK-reportedconverse/...model_used for pricing. If someone widenedisinstance(self._agent.route, BedrockRoute)to any route with a resolved model, no test would fail. Addpytest.param(LiteLLMRoute(...), "converse/zai.glm-5", "claude-sonnet-5", id="litellm-records-sdk-model"), using the constructor already used in tests/test_litellm_route.py, so the guard's reverse side is pinned for both non-Bedrock routes. - [Axis 3] Bedrock model_used test sets effective_model directly, so the BedrockRoute(model=...) argument is inert and route.model and alias qualification are not tested (
tests/test_agent.py:1951-1954) — The case buildsBedrockRoute(region="eu-north-1", model="eu.anthropic.claude-sonnet-5")but then writesstate.effective_model = effective_modelitself (line 1971). The route'smodelis never read, so the idbedrock-records-route-modelclaims more than the test checks. The usual user path is a bareagent.model: claude-sonnet-5that_resolve_effective_modelqualifies toeu.anthropic.claude-sonnet-5. That path is not tested together with the newmodel_used(). Fix: either dropmodel=from the route and rename the case (e.g.bedrock-records-effective-model), or get the value from the real resolver withstate.effective_model = agent._resolve_effective_model("claude-sonnet-5", {}, None)and asserteu.anthropic.claude-sonnet-5. The second option tests the documented 'alias auto-qualified, then recorded' contract directly. - [Axis 7] Per-route meaning of persisted
model_usedchanged, but only the agent page documents it. The schema SSOTs and the downstream-impact note were not updated. (docs/agents/CLAUDE_CODE.md:67) — The PR adds this text at docs/agents/CLAUDE_CODE.md:67-69: "On Bedrock,model_used(per iteration and on the task) records that resolved inference-profile id, e.g.eu.anthropic.claude-sonnet-5." From this change on, the value depends on the route. On a direct route it is the id the SDK reports. On a Bedrock route it is the resolved route id (claude_code_agent.py:710-712:if isinstance(self._agent.route, BedrockRoute) and self.effective_model: return self.effective_model). The Pydantic field descriptions are the documented SSOT, and they still describe one meaning.TurnRecord.model_usedin models/results.py:345-346 reads "Model identifier used for this turn (e.g., 'claude-sonnet-4-5-20250514')", andEvaluationResult.model_usedat models/results.py:538-539 is similar. docs/REPORT_SCHEMA.md:82/144/232 lists the field without the per-route meaning. A consumer that reads task.json/run.json, such as the coder-eval-uipath nightly, will see Bedrock rows change fromclaude-sonnet-5toeu.anthropic.claude-sonnet-5. If it groups on the raw value, the history splits into two series. In this repo the evalboard and pricing normalize the id, so they are not affected. The PR body does not say what happens to the downstream nightly. Recommended fix: put the per-route meaning into themodel_usedField descriptions (TurnRecord and EvaluationResult) and add one line to REPORT_SCHEMA.md. Then the CLAUDE_CODE.md paragraph can link to them instead of being the only place that states the contract. Also state in the PR whether the downstream grouping keys on the rawmodel_used. The agent page is the only file in scope, so the finding is anchored there. - [Axis 7] _ClaudeTurnState.model_used() is a single-call-site method that does not match the parallel turn-state attribute API (
src/coder_eval/agents/claude_code_agent.py:703) — The PR addsdef model_used(self) -> str | None:(line 703) next to theself.sdk_model_usedattribute (line 339). It is called only once, asmodel_used=self.model_used(),at line 771. The parallel turn state in delegate_agent.py (class _TurnState, line 418) exposes the same concept as a plain attribute:self.model_used: str | None = model(line 446). Sostate.model_usedis a value in one agent and a bound method in the other, and passing it without the call parentheses would not cause an immediate error. Make it a@property(or inline it at line 771) so that every agent's turn state has the same shape. Also change the docstring phrase "comparison-facing model" to a plain statement of the contract, for example "the model recorded on AgentEndEvent.model_used".
What's Missing
Parallel paths:
- 🟡 The task-level fallback in orchestrator.py:1040-1041 (
self.result.model_used = self.task.agent.model) was not updated. It still records the unresolved configured model. A Bedrock row that errors before any turn finalizes therefore records the bare alias (claude-sonnet-5), or None when only BEDROCK_MODEL supplied the model. Rows that complete now recordeu.anthropic.claude-sonnet-5. Before this PR both kinds of row agreed on the bare alias, so the PR creates a split inside one run. The evalboard's tallyModels comment (runs.ts:970-975) already describes this exact split. Either resolve the fallback through the same route-aware logic, or document the difference. (trigger: src/coder_eval/agents/claude_code_agent.py) - 🔵 Only AgentEndEvent.model_used changed. TurnStartEvent.model (claude_code_agent.py:420) still carries
sdk_model_used, and so does the mid-stream snapshot path in EventCollector.build_turn_record (collector.py:187,model_used=self._modelwhen no AgentEndEvent has arrived yet). An in-flight or stop_early snapshot TurnRecord on Bedrock therefore reports the SDK alias, and the finalized record reports the profile id. State that the snapshot path is intentionally the raw alias, or route it through the samemodel_used()logic. (trigger: src/coder_eval/agents/claude_code_agent.py)
Tests:
- 🔵 No test runs the docs claim "
model_used... on the task" from end to end. The new test stops at AgentEndEvent. It does not check that the orchestrator's last-turn-wins resolution (orchestrator.py:1035-1039) gives EvaluationResult.model_used ==eu.anthropic.claude-sonnet-5in task.json/run.json. It also does not cover a Bedrock row with no iterations. No Bedrock-route golden stream exists under tests/_fixtures/golden_streams/expected/ (claude_a..claude_i are all direct-route). Add one orchestrator-level or golden case. (trigger: tests/test_agent.py) (restates: Axis 3: New model_used test asserts only the AgentEndEvent, not the persisted TurnRecord or the crashed=True partial-turn path)
Downstream consumers:
- 🔵 The Python report layer uses the raw
model_usedstring without normalization. markdown.py:432 builds the header Model(s) line fromsorted({t["model_used"] ...}), html.py:345 and junit.py:286 print it directly, and Harbor ATIF export copies it intomodel_name(atif_emit.py:216/329). The evalboard groups through normalizeModel() and pricing goes through _normalize_model(), so neither has this problem. The Python reports do not. A Bedrock run that mixes completed rows (profile id) and pre-turn errored rows (bare alias, see the parallel-path item) now lists two models in the markdown header. Cross-run comparisons that key on the raw value also split. Check these consumers or normalize at the header. (trigger: src/coder_eval/agents/claude_code_agent.py)
Nightly pipeline:
- 🔵 The PR changes the value that the cross-repo contract (task.json / run.json
model_used) carries for every Bedrock row, fromclaude-sonnet-5toeu.anthropic.claude-sonnet-5. The PR body does not say whether the coder-eval-uipath nightly or eval-runner groups, filters or dashboards on the rawmodel_used. If it does, the history splits into two series at this merge. State the nightly impact. Also update the schema SSOTs (TurnRecord/EvaluationResult Field descriptions and REPORT_SCHEMA.md), which still describe one meaning for the field. (trigger: docs/agents/CLAUDE_CODE.md) (restates: Axis 7: Per-route meaning of persistedmodel_usedchanged, but only the agent page documents it. The schema SSOTs and the downstream-impact note were not updated.)
Harness & Lint Improvements
Static checks (lint / type):
- [ce-lint] CE069 route-matrix-completeness: in tests/, a
@pytest.mark.parametrizedecorator whose argvalues construct two or more distinct members ofApiRoute(DirectRoute(...),BedrockRoute(...),LiteLLMRoute(...)) must construct all of them. The rule takes the member set fromtyping.get_args(coder_eval.models.routing.ApiRoute)and does not hardcode it, so a fourth route automatically widens every route-matrix test. Add it as a@pytest.mark.lintclassTestCE069RouteMatrixCompletein tests/test_custom_lint.py, because it is a whole-tree test-file rule and not a src AST rule. CE067 was retired (see .claude/harness-candidates.md:950) and CE068 is taken, so CE069 is the next free number. An AST scan of the PR-249 tree finds exactly one violation: tests/test_agent.py:1965test_model_used_on_agent_end(only Bedrock and Direct). The baseline is therefore zero after the fix, and the rule needs no exemption list. Prevents: Axis 3 'No LiteLLMRoute case for the reverse side of the isinstance(BedrockRoute) guard' (tests/test_agent.py:1962). The rule would have failedmake verifyon the PR's two-of-three route parametrization. In future it also catches a widenedisinstance(self._agent.route, BedrockRoute)guard that leaks the resolved model onto LiteLLM, because the LiteLLM case is then required to exist. - [pyright] No new rule. Record a deliberate decision about the 'method vs attribute' hazard.
src/is already covered:AgentEndEvent/TurnRecordare Pydantic v2 models (dataclass_transform), so in standard mode pyright rejectsmodel_used=self.model_usedwithout the parentheses. A bound method() -> str | Noneis not assignable tostr | None. The remaining work for the finding is only consistency (@propertyor inline at claude_code_agent.py:771), and a reviewer settles that. A rule that forces every_*TurnStateto share one attribute shape would be machinery for a single call site. Prevents: Axis 7 '_ClaudeTurnState.model_used() is a single-call-site method that does not match the parallel turn-state attribute API' (claude_code_agent.py:703). The silent-misuse failure mode in that finding is already a pyright error. The shape inconsistency itself is out of reach of static checks by design. - [ruff] Do not add a rule. Record the decision: the PR-249 tree has 154 parametrized test functions with unannotated parameters. Pyright excludes tests/. Enabling ruff ANN001 for tests/ (or a CE cap rule) would create a large backlog for a cosmetic issue on a cold path. A cap rule would have to start below 154 and ratchet down, which costs more than the finding is worth. Fix the one new test by hand. Prevents: Axis 2 'New parametrized test leaves its parameters unannotated' (tests/test_agent.py:1965). This is not worth a gate. The entry is here so that the boundary is recorded as a decision and not missed by accident.
Harness improvements (not statically reachable):
- Add a Bedrock-route scenario to the Claude golden-master suite (tests/test_agent_golden_master.py + tests/_fixtures/golden_streams/claude_fixtures.py). Today
run_claude_scenariobuilds the agent with no route argument, so every committed snapshot is the direct route. Let a scenario passroute=BedrockRoute(region=...)and a bareagent.model: claude-sonnet-5, and snapshot the full scrubbedTurnRecord/pending_turn. Include one timeout/crash scenario so that thecrashed=Truepartial turn is snapshotted too. With this, a per-route change to a persisted field shows up as a reviewed JSON snapshot diff (GOLDEN_REGEN=1). It is not hidden behind a hand-pickedAgentEndEventassertion. Why not static: The value depends on how a live event stream is replayed through communicate(),_resolve_effective_model's alias qualification and EventCollector.build_turn_record. Only executing the turn loop can observe the persisted TurnRecord. Prevents: Axis 3 'asserts only the AgentEndEvent, not the persisted TurnRecord or the crashed=True partial-turn path' (tests/test_agent.py:1976-1977). Axis 1/3 'BedrockRoute(model=...) argument is inert; alias qualification untested' (tests/test_agent.py:1951-1954): the golden scenario goes through the real resolver and does not setstate.effective_modeldirectly. - Add a route-dependent-semantics checklist line to the PR template and to the Axis 8 reviewer prompt: 'If a persisted run-record field (TurnRecord / EvaluationResult / run.json) changes meaning per route or per backend, update its Pydantic
Field(description=...)(the SSOT), the matching row in docs/REPORT_SCHEMA.md, and state the effect on the downstream coder-eval-uipath nightly grouping keys.' With the golden-master Bedrock scenario above, the snapshot diff makes such a change visible. The checklist makes sure that someone writes down what the change means. Why not static: The value's type (str | None) and field name do not change. Only the meaning changes, and no AST or doc-parity rule can detect that a description is now incomplete. CE030 checks that a field is present, not that its semantics are current. Prevents: Axis 7 'Per-route meaning of persistedmodel_usedchanged, but only the agent page documents it' (docs/agents/CLAUDE_CODE.md:67): models/results.py:345-346 and :538-539 and REPORT_SCHEMA.md:82/144/232 still describe one meaning, and the PR did not say what happens to the nightly. - Add a shared
ALL_ROUTEStest fixture in tests/_fixtures, with one representative instance pertyping.get_args(ApiRoute)member and LiteLLM built the way tests/test_litellm_route.py builds it. Route-conditional tests parametrize over this fixture and do not hand-build route lists. This is the low-friction way to satisfy the proposed CE069. Most of the work is putting a validLiteLLMRoute(...)constructor in one place, which is the step authors skip. Why not static: This is a test-authoring convenience, not a detectable defect. CE069 enforces completeness, and the fixture makes compliance cheap. Prevents: Axis 3 'No LiteLLMRoute case for the reverse side of the isinstance(BedrockRoute) guard' (tests/test_agent.py:1962).
Top 5 Priority Actions
- Document the per-route meaning of
model_used(resolved Bedrock profile id, e.g.eu.anthropic.claude-sonnet-5, vs. the SDK-reported id) in theTurnRecord.model_usedandEvaluationResult.model_usedField descriptions (models/results.py:345, :538) and in docs/REPORT_SCHEMA.md, link to them from docs/agents/CLAUDE_CODE.md:67, and state in the PR body whether the downstream nightly groups on the raw value, because Bedrock rows in task.json/run.json change fromclaude-sonnet-5toeu.anthropic.claude-sonnet-5. - Extend the new test at tests/test_agent.py:1976-1977 so that it also asserts
state.collector.build_turn_record().model_used == expectedand adds acrashed=Truecase withfinalize(AgentEndStatus.TIMEOUT, crashed=True, crash_reason=...), which checksagent.pending_turn.model_used, so that the persisted value is tested and not only the AgentEndEvent. - Add a
LiteLLMRoutecase next to the direct-route case at tests/test_agent.py:1962 that expects the SDK-reported model, so that a later widening of theisinstance(self._agent.route, BedrockRoute)guard (src/coder_eval/agents/claude_code_agent.py:710) fails a test and does not silently change LiteLLM pricing input. - Make the Bedrock case at tests/test_agent.py:1951-1954 test real behavior: set
state.effective_modelfromagent._resolve_effective_model("claude-sonnet-5", {}, None)and asserteu.anthropic.claude-sonnet-5, or remove the unusedmodel=argument from the route and rename the case, because the route model is not used in the test now. - Change
_ClaudeTurnState.model_used()(src/coder_eval/agents/claude_code_agent.py:703) to a@property, or inline it at its single call site (line 771), so that it has the same shape as_TurnState.model_usedin delegate_agent.py:446, and annotate the parametrized test arguments at tests/test_agent.py:1965 (route: ApiRoute, effective_model: str | None, expected: str) to follow the file convention.
Change class: complex — changes the value semantics of the persisted model_used run-record field on Bedrock routes (a cross-repo contract), not just a mechanical edit
Stats: 0 🔴 · 0 🟠 · 0 🟡 · 6 🔵 across 8 axes reviewed.
Make _ClaudeTurnState.model_used a property so it has the same shape as the delegate agent's turn state. Resolve the Bedrock case through _resolve_effective_model so the test covers alias qualification, add a LiteLLM case for the other side of the BedrockRoute guard, assert the collector's TurnRecord, and cover the crashed partial turn. Annotate the parametrized arguments. Document the per-route meaning of model_used in the TurnRecord and EvaluationResult field descriptions and in REPORT_SCHEMA.md, including that the agent-config fallback is not route-resolved.
|
@uipreliga addressed the six nits in 5b2b2c7. |
Bottom line
On a Bedrock route, the Claude Code harness now writes the resolved inference-profile id (for example
eu.anthropic.claude-sonnet-5) tomodel_used, per iteration and on the task. Before this, it wrote the SDK's friendly alias (claude-sonnet-5). The raw alias stays on eachmessages[].model.Fixes #99.
What was wrong
_ClaudeTurnState.on_assistant_messagecopiesAssistantMessage.modelintosdk_model_used, andfinalizeput that value onAgentEndEvent.model_used. On Bedrock, the SDK reports the alias, not the route model. So:eu./us./global.) grouped under one key;model_usedalone, and had to cross-checkenvironment_info.bedrock_model.The agent already knows the route model:
_resolve_effective_modelresolves it (config model wins, bare aliases are region-qualified), writes it toANTHROPIC_MODEL, and stores it onstate.effective_modelfor cost backfill.The fix
_ClaudeTurnState.model_used(): on aBedrockRoutewith a resolved model, it returnseffective_model; otherwise it returns the SDK-reported model.finalizeuses it forAgentEndEvent.model_used.AssistantMessage.modelandTurnStartEvent.modelkeep the raw SDK value.docs/agents/CLAUDE_CODE.md§ AWS Bedrock states the behaviour.Pricing does not change:
pricing._normalize_modeland the evalboard'snormalizeModelboth strip the region andanthropic.prefixes before the rate lookup, and the run-header model tally groups on that same normalized key.Tests
New parametrized
TestClaudeTurnState::test_model_used_on_agent_endintests/test_agent.py. It drives the turn state with anAssistantMessagewhosemodelis the aliasclaude-sonnet-5:eu.anthropic.claude-sonnet-5→model_usedis the qualified id (fails without the fix);sonnet→ keeps the SDK value.Each case also checks that
messages[0].modelkeeps the raw alias.Local checks:
ruff format --checkandruff checkare clean, the custom lint suite (tests/test_custom_lint.py, 741 tests) andtests.lint.prose_budgetpass, andpyrightreports no new errors (the remaining ones are imports of optional extras not installed locally).pytest -m "not live and not lint" tests/: 6413 passed. The 12 failures are local-environment only (thelitellmextra is not installed, and anode_modulesdirectory sits above the checkout) and happen without this change too.