Skip to content

fix(routing): 5 correctness bugs surfaced by adversarial code audit - #18

Merged
mirainya merged 5 commits into
mirainya:mainfrom
ShuYingJiYu:upstream-audit-batch
Sep 11, 2026
Merged

mirainya merged 5 commits into
mirainya:mainfrom
ShuYingJiYu:upstream-audit-batch

Conversation

@SakuraPuare

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #17. An adversarial 12-lens code audit surfaced 5 additional correctness bugs in the routing/store layers. This PR bundles the upstream-relevant fixes.

Bugs fixed (each is a separate commit for reviewability)

  1. fix(forward): normalize OpenAI/Gemini prompt_tokens to uncached semantics (d3de0ce)

    • Anthropic input_tokens excludes cached; OpenAI prompt_tokens and Gemini promptTokenCount include cached. parseUsageObject stored whichever was max, mixing conventions in the same column and causing any downstream SQL that sums the three token fields to double-count cached for OpenAI/Gemini.
    • Fix normalizes at ingest: subtract cached from the inclusive value.
  2. fix(routing): exploration ceiling uses EffectiveCost + rate=1.0 always fires (79760b9)

    • chooseExploration compared Price.Multiplier * 2 as the cost ceiling, but Multiplier is a billing scalar that's ~1 across all channels in production. A 100x-more-expensive fallback could silently receive exploration traffic. Fix: use evaluations[winner].EffectiveCost.
    • Bonus: uint64(rate * float64(^uint64(0))) on x86 rounds 2^64-1 up to 2^64 and casts back to 2^63, silently degrading rate=1.0 to ~50% fire rate. Fix: short-circuit rate>=1 to ^uint64(0).
  3. fix(store): align session-cache TTL fallback with selectAdaptiveTTL (1ca15d8)

    • getSessionCacheStats stamped ExpiresAt = latestCache + 5min unless the session had run >10min AND CreateCount>=2. That missed two branches from routing.selectAdaptiveTTL: Gemini (always 1h) and sparse conversations (avgInterval>4min + any rebuild). In those cases the state machine flipped CacheHot → CacheExpired up to 55 minutes early.
    • Fix: extract policy into store.AssumedCacheTTL (single source of truth, avoids store→routing import cycle).
  4. fix(store): symmetric success filter + surface fallback error (8582603)

    • The in-window query counted all observations while the lifetime widen fallback filtered success=TRUE. A single failed retry inside the window suppressed the widen path — same underlying history produced different verdicts depending on where the failure landed.
    • Fix: apply success=TRUE to both queries so widen is a strict superset.
    • Also fixes shadowed-variable bug that swallowed real fallback errors under the primary ErrNoRows.
  5. fix(routing): record actual_upstream_id for failover attempts (c06cede)

    • When the initial pick fails and a failover attempt succeeds, selected_upstream_id kept pointing at the failed upstream while actual_cost / actual_*_tokens described the successful attempt. Any join on selected_upstream_id misattributed the failover request.
    • Fix: add actual_upstream_id column (default 0 for unknown), populated from result.FinalUpstreamID in RouteDecisionOutcome. Migration 20260911_030000_add_route_decisions_actual_upstream.sql.

Testing

go vet ./... clean, go test ./... all pass. Each commit adds its own regression tests:

  • TestUsageAuditNormalizesOpenAIPromptTokensToUncached
  • TestUsageAuditNormalizesGeminiPromptTokensToUncached
  • TestUsageAuditClampsNegativeUncached
  • TestChooseExplorationRejectsExpensiveCandidates
  • TestChooseExplorationRateOneAlwaysFires
  • TestAssumedCacheTTL

Context

Two audit findings (#4 in-window/lifetime filter asymmetry, #5 shadowed error) share commit 8582603 because they touch the same function.

Two audit findings (#6 exploration ceiling, #7 rate=1.0 saturation) share commit 79760b9 because they're in the same function and fix together.

This builds on #17. #17 must land first (or be co-merged) since the TokenInflationFactor issue it fixes shares the same root symptom.

…s fires

Two audit findings, one function:

1. (mirainya#6) chooseExploration compared Price.Multiplier * 2 as the cost ceiling,
   but Multiplier is a billing scalar that is ~1 across all channels in
   production, so the guard filtered nothing. A 100x-more-expensive fallback
   could receive exploration traffic silently. Fix: use the evaluation's
   EffectiveCost (SelectedTotal / success_rate) so the '2x winner' filter
   means what the comment says.

2. (mirainya#7) threshold := uint64(rate * float64(^uint64(0))) — float64 rounds
   ^uint64(0) up to 2^64, and casting 2^64 back to uint64 is implementation-
   defined (x86 lands on 2^63), degrading rate=1.0 to ~50% fire rate. Fix:
   short-circuit rate>=1 to always fire.

Also added a permissive fallback when winnerCost==0 (zero-token forecast,
early warm-up) so the 2x ceiling doesn't collapse to zero and forbid all
exploration.

Tests:
- TestChooseExplorationRejectsExpensiveCandidates: 100x-priced fallback must
  never win at rate=1.0.
- TestChooseExplorationRateOneAlwaysFires: 20 distinct cache_keys must ALL
  explore at rate=1.0.
getSessionCacheStats stamped stats.ExpiresAt = latestCache + 5min unless the
session had run >10min AND had CreateCount>=2. That missed two branches from
routing.selectAdaptiveTTL: Gemini (always 1h) and any protocol with sparse
avgInterval>4min+CreateCount>=1. In those cases the state machine at
cachestate.go:131 flipped CacheHot → CacheExpired up to 55 minutes early:
profile.Existing.Valid went false, cacheLifetimes lost the guaranteed initial
hit credit, and the candidate over-forecasted a full extra cache write.

Fix: extract the TTL policy into store.AssumedCacheTTL and call it from
getSessionCacheStats. Same rules, single source of truth, kept in the store
package to avoid a store → routing import cycle (routing.selectAdaptiveTTL
retains its own copy — see doc-comment cross-reference).

Signature change: GetPrefixCacheStats / getSessionCacheStats now take a
protocol string. Callers updated (scheduler + tests).

Fixes audit finding #1.
…ix cache stats

Two related audit findings in the same fallback path:

mirainya#4: getSessionCacheStats' in-window query counted all observations while its
lifetime widen fallback filtered success=TRUE. A single failed retry inside
the 15-min window suppressed the widen path — the same underlying history
produced different verdicts depending on where the failure landed relative
to the window boundary. Fix: apply success=TRUE to both queries (the widen
path is now a strict superset of the window path), matching the comment's
intent ('healthy history should survive circuit-breaker recoveries').

mirainya#5: GetPrefixCacheStats's fallback used ':=' inside the error block,
shadowing 'stats' and 'fallbackErr'. On fallback failure it returned the
primary err (typically ErrNoRows) and the shadowed partial stats, swallowing
any real DB error from the fallback. Fix: propagate fallbackErr and return
the outer initialised stats on the error path so callers/logs see the true
failure and consistent field content.
…ectly attributed

route_decisions previously stored only SelectedUpstreamID — the initial pick.
When that pick failed and a failover attempt succeeded, the row's
selected_upstream_id kept pointing at the failed upstream while
actual_cost / actual_input_tokens / actual_cached_tokens described the
successful failover attempt's usage. Any join on selected_upstream_id
misattributed the request to the upstream that DID NOT serve it.

Fix: add actual_upstream_id column (populated from result.FinalUpstreamID)
alongside the existing actual_* usage columns. selected_upstream_id retains
its meaning (initial pick) so historical decision reasoning stays intact.
Downstream analytics that care about 'which upstream served this' should
COALESCE(NULLIF(actual_upstream_id, 0), selected_upstream_id).

Migration 20260911_030000 adds the column with default 0 (unknown).

Fixes audit finding mirainya#8.
…ched semantics

Anthropic responses report usage.input_tokens as EXCLUSIVE of cache_read /
cache_creation. OpenAI (prompt_tokens) and Gemini (promptTokenCount) report
their input as INCLUSIVE of cached. Previously parseUsageObject stored
whichever was largest — mixing conventions in the same column.

For any downstream consumer that sums the three fields to reconstruct the
billed prompt total (e.g. usage attribution, cache-coverage analytics), this
double-counts cached for OpenAI/Gemini rows by roughly cached/total.

Fix at the ingest layer: parseUsageObject subtracts cached from the inclusive
prompt_tokens/promptTokenCount, matching Anthropic semantics. Every column
downstream (routing_observations.input_tokens, request_attempts.input_tokens)
now has a single well-defined meaning.

Tests:
- TestUsageAuditNormalizesOpenAIPromptTokensToUncached
- TestUsageAuditNormalizesGeminiPromptTokensToUncached
- TestUsageAuditClampsNegativeUncached
- Updated TestRelayResponseCapturesUsageBytesAndRequestID for new contract.
@mirainya
mirainya merged commit 021bdad into mirainya:main Sep 11, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants