fix(routing): complete unknown cost-cap operator contract (#1181) - #1404
Conversation
…nown `limits.maxEstimatedCostUsd` is documented as a hard per-request ceiling, but it never fires on the live routing path. `evaluatePolicyProfile` only excludes a candidate when the estimate is a finite number (evaluator.ts), while `routeModel` assembles cost evidence without usage (router.ts), so `estimatedUsd` is always `undefined` live and any candidate silently passes a cap the operator configured as hard. The existing coverage passed only because it supplied `usage` directly, exercising a path production does not take. Fail-closed unconditionally is not safe either: with usage unwired, it would reject every live candidate whenever a cap is set, and it would change the documented dry-run contract. This adds an explicit, opt-in policy instead: limits.onUnknownCost: "allow" | "exclude" (default "allow") - "allow" preserves today's behavior and the documented contract exactly. - "exclude" makes the ceiling genuinely hard: a candidate whose cost cannot be proven under the cap is ineligible. Unknown-cost exclusions emit a distinct `cost-limit-unknown` code so a trace distinguishes "known above the cap" from "cost is unknown", which is the operator-facing distinction #1181 asks for. Kept separate from `unknownEvidence.cost`, which governs how an unknown-cost candidate is *scored* rather than whether the *ceiling* applies. The two mechanisms now emit distinct codes and are covered by a test asserting they stay distinguishable. Fixes #1181 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Default onUnknownCost stays fail-open, but stamp cost.capOutcome so dry-run and live traces distinguish known-under-cap from unknown-allowed; document and expose the policy in the profile editor.
|
✅ Deterministic PR hygiene checks passed. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe PR adds configurable handling for unknown routing cost estimates. The default ChangesUnknown cost-cap policy
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant RoutingProfile
participant routeModel
participant RoutingEvaluator
participant RouteTrace
RoutingProfile->>routeModel: provide maxEstimatedCostUsd and onUnknownCost
routeModel->>RoutingEvaluator: evaluate candidate cost evidence
RoutingEvaluator->>RoutingEvaluator: classify known or unknown cost
RoutingEvaluator->>RouteTrace: record capOutcome
RoutingEvaluator-->>routeModel: return eligible or excluded candidate
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 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 |
✅ READY
UI screenshot waived by the Hygiene✅ Deterministic PR hygiene checks passed. |
Keep locale Records complete so GUI typecheck/CI does not fail on the new dry-run cost-cap column label.
Align docs with unknownEvidence.cost eligibility, stamp the applied profile cap, drop orphaned capOutcome without limitUsd, and localize dry-run labels.
abhisheksharma2411
left a comment
There was a problem hiding this comment.
Thanks for carrying this forward, and for the credit — makes sense given the fork head wasn't fast-forward writable after the rebase. Confirming #1345 can be closed once this lands; no need for me to cherry-pick.
The capOutcome approach is a better answer to the operator-contract requirement than what I had. Keeping the non-excluding outcomes out of exclusions and whitelisting the values in parseCost both look right, and the four-state enum makes satisfied vs unknown-allowed legible in a way my version couldn't.
Two observations on the stamping in evaluatePolicyProfile:
1. Stamping limitUsd can change costScore()'s denominator for callers that didn't supply it.
costForCandidate now sets limitUsd: costLimit whenever a cap is configured, and cost is reassigned to it before scoring. costScore() picks its reference as:
const reference = typeof evidence.limitUsd === "number" && evidence.limitUsd > 0
? evidence.limitUsd
: COST_SCORE_REFERENCE_USD;So for any caller that passes cost evidence with a known estimatedUsd but no limitUsd, the reference silently moves from COST_SCORE_REFERENCE_USD (1.0) to the configured cap — which changes the score and therefore candidate ranking. Every in-tree caller (router.ts, routing-profile-routes.ts) already sets limitUsd via costEvidenceForCandidate, so I don't think this is reachable today. Flagging it as intent rather than a defect: if the normalization is deliberate, it might be worth a line in the comment saying so, since it's a scoring change hiding inside a trace-stamping change.
2. Synthesized cost evidence has no incomplete marker.
When a cap is set and the caller supplied no cost evidence at all, the candidate now gets cost: { limitUsd, capOutcome: "unknown-allowed" }. That block reads as present-but-unknown without incomplete: true, which costEvidenceForCandidate otherwise always sets for missing usage or price. Consider spreading in incomplete: true when synthesizing from nothing, so an operator (or anything consuming the trace) can't mistake it for evidence that simply lacks a price.
Neither blocks merge from my side. Happy to review again after the exact-head CI run, and thanks for the thorough review on #1345 — the fail-open ambiguity was a real gap.
|
Supersedes #1345. Continues @abhisheksharma2411's #1181 cost-cap work on current |
abhisheksharma2411
left a comment
There was a problem hiding this comment.
Re-reviewed at b944703. The docs alignment, the i18n mapping, and normalizeRouteDecisionTrace dropping capOutcome without a finite limitUsd all look right, and the dry-run/live parity tests answer the integration-coverage point cleanly.
One thing got sharper rather than resolved after c248690, and I think it needs a decision before merge.
Normalizing limitUsd to costLimit also moves costScore()'s denominator.
costForCandidate is used for two different purposes: it's stamped into the trace, and it's what gets passed to costScore() at the scoring step. CodeRabbit's finding was about the first (the trace must report the cap actually applied — agreed, and the new test at line 201 pins it). But the same object now feeds scoring, and costScore() picks its reference as:
const reference = typeof evidence.limitUsd === "number" && evidence.limitUsd > 0
? evidence.limitUsd
: COST_SCORE_REFERENCE_USD;
return Math.max(0, Math.min(1, 1 - evidence.estimatedUsd / reference));So for any caller whose evidence carries a limitUsd different from the profile cap, the cost score changes silently. Concretely, with maxEstimatedCostUsd: 0.5 and incoming evidence { estimatedUsd: 0.4, limitUsd: 1 }:
| reference | cost score | |
|---|---|---|
| before this PR | 1 (caller's) |
1 - 0.4/1 = 0.60 |
at b944703 |
0.5 (profile cap) |
1 - 0.4/0.5 = 0.20 |
The candidate stays eligible and correctly stamps capOutcome: "satisfied" in both cases — but its cost component drops by two thirds, and with optimize.cost weighted that can reorder candidates. The existing line-201 test doesn't catch it because 0.01 > 0.000001 makes that candidate ineligible anyway, so the score is never consulted.
I don't think this is reachable from router.ts or routing-profile-routes.ts today, since both build evidence via costEvidenceForCandidate({ limitUsd: profile.limits.maxEstimatedCostUsd }) and the two values already agree. But line 201 establishes divergent limitUsd as a supported input shape, which makes the scoring path reachable for anyone constructing evidence directly.
Two ways to close it, either fine by me:
- Separate the concerns — keep the trace copy as-is, and score against the caller's original
evidence.cost, so a trace-reporting fix can't move ranking. - Declare the normalization intentional — state in the comment that the profile cap is the authoritative scoring reference and a caller-supplied
limitUsdis advisory, then add a test asserting the score for an eligible candidate whose incominglimitUsddiffers from the cap (e.g. the 0.4/0.5/1 case above), so the behaviour is pinned rather than incidental.
The second is probably the better contract — the profile is the authority — but right now it's implied by a change made for a different reason, and nothing pins it.
Nothing else blocking from me; the rest of the contract work reads well.
Trace/dry-run still report the profile limit and incomplete synthesis, but scoring uses the caller's original cost evidence so ranking cannot drift.
Qualify onUnknownCost docs with the required cap, fix DE/TR labels, and localize dry-run exclusion codes including cost-limit-unknown.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/routing/evaluator.ts`:
- Around line 341-345: Update the cost normalization around costForCandidate and
the cap calculation so non-finite estimatedUsd values are treated as unknown,
not preserved as valid trace metadata. Apply the same finite-estimate rule used
near the cap logic, mark such evidence incomplete, and avoid emitting Infinity
or other non-finite estimates; add coverage for an evidence object containing a
non-finite estimatedUsd with incomplete false.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 7328e902-e196-4fa4-a8a6-87dfcd16f806
📒 Files selected for processing (2)
src/routing/evaluator.tstests/cost-cap-unknown-evidence.test.ts
|
Just saw Looks good to me. Thanks for being so quick on all of this — it's been genuinely fun to follow, and I appreciate you taking the time to explain the reasoning on #1345 rather than just closing it. Looking forward to seeing it land 🙂 |
Treat missing and non-finite estimatedUsd the same on the stamped copy: mark incomplete and omit Infinity/NaN so unknown-allowed traces stay honest.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs-site/src/content/docs/guides/routing-profile-editor.md`:
- Around line 33-34: Update the routing profile editor guide’s
`limits.onUnknownCost: "exclude"` documentation to state that evaluation emits
both `cost-limit-unknown` and `cost.capOutcome: "unknown-excluded"` in dry-run
and live traces, while preserving the existing exclusion behavior and related
unknown-cost guidance.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 9fbede65-0e59-4d81-889b-ee2426da4952
📒 Files selected for processing (13)
docs-site/src/content/docs/guides/routing-profile-editor.mddocs-site/src/content/docs/reference/configuration/routing.mdgui/src/i18n/de.tsgui/src/i18n/en.tsgui/src/i18n/ja.tsgui/src/i18n/ko.tsgui/src/i18n/ru.tsgui/src/i18n/tr.tsgui/src/i18n/zh.tsgui/src/pages/RoutingProfiles.tsxgui/tests/routing-profiles.test.tsxsrc/routing/evaluator.tstests/cost-cap-unknown-evidence.test.ts
abhisheksharma2411
left a comment
There was a problem hiding this comment.
Fixes look good to me — the scoring split in 6be09595 is cleaner than either option I suggested, and threading incomplete through the synthesized case ties it off nicely.
Happy with this one. Thanks for the quick turnaround 🙂
|
Thanks @abhisheksharma2411 — both points were right. Separated scoring from the stamped trace copy (so |
|
Thanks @abhisheksharma2411 — merging this. Why it helps: your #1345 work correctly diagnosed that live routing builds cost evidence without usage, added the opt-in Ship it. |
Summary
dev).onUnknownCost: \"allow\"fail-open eligibility, but stampscost.capOutcome: \"unknown-allowed\"on dry-run/live traces so operators can distinguish known-under-cap from unknown cost.excludestill emitscost-limit-unknownwithcapOutcome: \"unknown-excluded\"; documents + exposesonUnknownCostin routing docs and the Routing Profile Editor.Addresses review on #1345
capOutcomeon the allow path (not an exclusion).dev.Credits the original #1345 implementation by @abhisheksharma2411.
Fixes #1181.
Test plan
bun test tests/cost-cap-unknown-evidence.test.ts tests/cost-scoring.test.tsbun x tsc --noEmitbun test ./gui/tests/routing-profiles.test.tsxSummary by CodeRabbit
New Features
Documentation
Bug Fixes