Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,46 @@

---

## [3.8.7] — 2026-05-08

### 🔒 Routing — session affinity real fix (D1 + D2 + D3)

Production traffic on 3.8.6 / 3.8.5 showed a `feat/limits-dashboard-polish` smart-affinity violation: an `Account A → Account B` switch fired only 4 seconds after the last successful Account A request, on the same opencode-supplied `x-session-affinity` header, with both accounts at ~100% quota. The cache rebuilt from scratch (147k token cache_write, cache_read=0). Oracle hard-debug (`ses_1fa7165c0ffeFFU8rjU82y0ItO`) identified three independent design defects, all fixed here.

#### D1 — `x-session-affinity` is now a first-class external session header

`extractExternalSessionId` previously matched only `x-session-id` / `x_session_id` / `x-omniroute-session` / `session-id`, so the stable opencode continuity signal was being silently dropped and the session id was being recomputed from a fingerprint hash of the request body. Any system-prompt or first-user-message drift (sub-agent invocation, dynamic context injection) produced a NEW session id with no bound connection, falling through to score-based selection. The header chain now includes `x-session-affinity` between the generic forms and the OmniRoute-specific alias. Each header source is given its own namespace prefix (`ext:` for the legacy generic forms, `ext-xsa:` for `x-session-affinity`, `ext-omr:` for `x-omniroute-session`) using a hyphen separator so that a legacy generic value of e.g. `xsa:foo` cannot collide with a newer `x-session-affinity: foo` request.

#### D2 — `touchSession` no longer overwrites `connectionId`; `bindSessionConnection` is the single mutation site

The previous `touchSession(sessionId, connectionId)` silently rewrote the bound connection on every call, including the post-credential confirmation in `chat.ts`. This made it impossible to tell from `/api/sessions` whether a session had ever been bound to a different account — the previous binding evidence was always erased by the last write. `touchSession(sessionId)` is now a pure activity update (lastActive, requestCount). All binding mutations go through the new `bindSessionConnection(sessionId, connectionId, { source })` which returns a `BindResult` with five reason states (`no_session_id` / `first_bind` / `no_change` / `rebind_within_window` / `rebind_after_window`). A rebind inside the 5-minute affinity window emits a structured `log.warn("SESSION/affinity", "session rebound within affinity window", { ... })` — that single log line now answers "did affinity break?" definitively. The deprecated 2-arg `touchSession` signature still works as a compat shim with a one-shot deprecation warning, so existing tests keep passing.

#### D3 — Cache-sensitive provider gate on `affinity_break_p1_too_urgent`

`selectByEarliestResetFirst` and `isAffinityValid` now accept an optional `provider` argument, plumbed through from `auth.ts` (which already had the provider in scope). For `provider === "claude"` (Anthropic OAuth lane), the urgent-alt heuristic break is suppressed unless `boundScore <= 0` (genuinely unusable via accumulated backoff/error penalty). Rationale: Claude prompt cache write is 1.25x and read is 0.1x of input cost, so a 147k-token cache rewrite is roughly 12x more expensive than a single read; the urgent-alt break would have to amortise over 12+ reads in 5 minutes to pay for itself, which a typical chat workload cannot. Other break paths (terminal, rate_limited, quota_exhausted, low-quota < 15%, session_expired, affinity_window_passed) are unaffected — only the urgent-alt heuristic is gated. Other providers retain the original break behaviour because their cache economics differ.

#### Trace logging

`selectByEarliestResetFirst` now emits a structured log on every routing decision. `log.debug("AUTH/affinity", "kept", ...)` when affinity is preserved. `log.warn("AUTH/affinity", "broken", ...)` ONLY when affinity is broken via the heuristic rules (`affinity_break_low_quota` or `affinity_break_p1_too_urgent`) — those are the diagnostic alarm cases. Affinity invalidations for expected reasons (`session_expired`, `affinity_window_passed`, `rate_limited`, `terminal`, `quota_exhausted_unknown_reset`, `inactive`, track-excluded variants) emit `log.debug("AUTH/affinity", "broken", ...)` instead so production warn-level logs stay focused on real anomalies. `log.debug("AUTH/affinity", "fall-through selected", ...)` when no session was bound. Combined with the `bindSessionConnection` rebind-within-window warning, the previously invisible silent overwrites are now first-class observable events.

### 📚 Internal Notes

- New unit tests in `tests/unit/session-manager-affinity-headers.test.mjs` (9 tests) cover the new namespace-per-source extraction, header collision avoidance (incl. legacy values that look-namespaced like `xsa:foo`), fallback chain order, and edge cases (empty / whitespace / 64+ char values).
- New unit tests in `tests/unit/session-manager-binding.test.mjs` (10 tests) cover the `touchSession` activity-only contract, the deprecated 2-arg compat shim, all five `BindResult.reason` states, the null-`oldConnectionId` first-bind path (Oracle audit defect B), and the `emergency_fallback` rebind suppression (Oracle audit defect D).
- Existing `auth-strategy-earliest-reset-first.test.mjs` extended with a `githubConn` fixture helper and a `seedSingleSessionAccount` seed helper. **SA-2** rewritten: the urgent-alt break test now uses GitHub provider so the new Claude gate doesn't apply (the underlying break logic is still asserted, just for a non-cache-sensitive provider). **SA-12** new: Claude bound + 4× alt → break BLOCKED by gate. **SA-13** new: Claude bound score=0 (no quota cache, hence backoff-style unusable) + positive alt → break ALLOWED (gate's escape condition). **SA-14** new: GitHub bound + 4× alt → break ALLOWED (gate is Claude-only).
- Test count: 2874/2874 PASS (was 2852; +22 net — SA-12 / SA-13 / SA-14 + 9 affinity-header tests (incl. collision-proof) + 10 binding tests (incl. null-first-bind, emergency_fallback)).
- Version bump 3.8.6 → 3.8.7.

### Plan + Oracle two-stage verification

- Plan: `.sisyphus/plans/session-affinity-real-fix.md`
- Oracle plan v1 (`ses_1f85345c8ffebTdtpTaQ5aeN8J`): NEEDS_REVISION on 7 items (A defect, B nit, C defect, D defect, E defect, F nit, G nit) → all 7 applied in plan v2.
- Oracle plan v2 (`ses_1f84ae893ffeTXGq7QBjwfTgzX`): NEEDS_REVISION (narrow) on 5 wording-mismatch items → all 5 applied in plan v3.
- Oracle implementation audit (`ses_1f83ab6edffecggMOuz5EmDhT1`): NEEDS_REVISION on 4 defects + 1 nit. **Defect A** namespace collision (`ext:xsa:foo` vs `ext:` + raw `xsa:foo`) → moved to hyphenated `ext-xsa:` / `ext-omr:`. **Defect B (critical)** `bindSessionConnection` after `touchSession()` (which creates entry with `connectionId: null`) was classifying as `rebind_within_window` → null oldConnectionId is now `first_bind` and emits no warning. **Defect D** chat.ts emergency-fallback retry produced a within-window alarm for legitimate behavior → new `BindSource = "emergency_fallback"` plumbed from the retry path, alarm suppressed for that source. **Defect H** `require()` lazy-loaded inside try/catch could silently swallow logs in ESM/tsx → static `import * as log from "@/sse/utils/logger"` (no cycle). **Nit E** gate-suppressed urgent-alt now logs `gate: "claude_urgent_alt_suppressed"` at debug for observability. All five fixes applied + new tests for each.
- **Copilot PR #28 R3 review** (2 defects). **R3-1**: the deprecated 2-arg `touchSession(sessionId, connectionId)` compat shim previously updated `lastActive` before forwarding to `bindSessionConnection`, which made the forwarded call always see "within affinity window" — a session idle >5 min would mis-fire the within-window alarm on its first compat-shim rebind. The shim now binds first (so the binding decision sees the pre-touch `lastActive`), then preserves the original 1-touch == 1-request semantics by only incrementing `requestCount` if an entry pre-existed (since `bindSessionConnection`'s `first_bind` branch already sets it to 1). **R3-2**: `selectByEarliestResetFirst` previously bound the fall-through selection itself with a hard-coded `source: "fall_through"`. That meant the actual within-window rebind happened in the strategy with the wrong source, so the `emergency_fallback` alarm suppression in `chat.ts:548` could never apply (the strategy's bind beat the post-credential bind). The strategy no longer binds — `chat.ts:548` is now the single binding site, and it picks `source: "emergency_fallback"` vs `"explicit_post_credential"` from `runtimeOptions.emergencyFallbackTried`.

---

## [3.8.6] — 2026-05-08

### 🎨 Design Unification
Expand Down Expand Up @@ -115,7 +155,7 @@

### 🐛 Bug Fixes

- **Routing Strategy v7 — burn-rate pressure max replaces v6 mean:** v6 combined session and weekly track scores via arithmetic mean, which dilutes the more-urgent track's signal with the calmer track. Production observation: `APEXATGNU(33%/0h34m, 91%/6d17h)` was picked over `GNUMAX(29%/1h54m, 63%/1d1h)` even though GNUMAX's weekly was about to waste 63% in ~1 day. v7 introduces `pressure(Q, t, W) = Q × clamp(W/t, 1, URGENCY_CAP)` per track and combines via `Math.max(...)`. Two windows are independent ledgers (verified via anthropics/claude-code issues #54750, #52135, #40513), so routing to the more perishable signal matches the user's "burn what's about to expire" intent.
- **Routing Strategy v7 — burn-rate pressure max replaces v6 mean:** v6 combined session and weekly track scores via arithmetic mean, which dilutes the more-urgent track's signal with the calmer track. Production observation: `Account A(33%/0h34m, 91%/6d17h)` was picked over `Account B(29%/1h54m, 63%/1d1h)` even though Account B's weekly was about to waste 63% in ~1 day. v7 introduces `pressure(Q, t, W) = Q × clamp(W/t, 1, URGENCY_CAP)` per track and combines via `Math.max(...)`. Two windows are independent ledgers (verified via anthropics/claude-code issues #54750, #52135, #40513), so routing to the more perishable signal matches the user's "burn what's about to expire" intent.
- **Penalty rebalance:** `PENALTY_BACKOFF_WEIGHT` 100 → 101 to keep a max-backoff paid account strictly below self-hosted score=0 (previously could tie at 0 and resolve only via lex tie-break).

### 📚 Internal Notes
Expand Down Expand Up @@ -162,7 +202,7 @@
- **F1 (self-hosted fallback):** Accounts with no quota cache (e.g. user-added `openai-compatible` providers like local llama.cpp) returned `excluded: "no_quota_data"` and produced an `all candidates excluded` 503. They now fall back to `score = 0` and remain eligible — paid candidates with positive scores still beat them, but they are usable when no paid candidate exists. `markAccountExhaustedFrom429()` empty caches are still excluded.
- **F2 (per-model windows ignored):** Removed `modelWindowMapping.ts` and the model-specific weekly-window lookup. Routing now reads only the overall `weekly` window — per-model `weekly Sonnet` / `weekly Omelette` buckets no longer split routing across requests for the same conversation. If Anthropic 429s an opus request because per-model quota is exhausted, `accountFallback` retries on another account.
- **F3 (fresh quota max-urgency):** Anthropic omits `resetAt` for accounts where the timer hasn't started yet (always at 100% remaining). v3.7.0 treated that as `missing` and excluded fresh accounts. v3.7.1 detects the strict pair `Q === 100 AND resetAt === null` and scores the track at `100 × 100 = 10000` (max urgency), so the very first request wakes the timer instead of further burning an already-active account.
- **F4 (multiplicative burn-down):** Replaced the additive score with `S = T_pts × Q_remain` (`score ∈ [0, 10000]`). The new formula makes "quota likely lost if not used" the actual scoring objective: `APEX(17%/2h) avg=690` now correctly loses to `GNUMAX(87%/4h) avg=1460` even though APEX's session is closer to reset. Penalties rescaled 100× (`PENALTY_DEGRADED 25→2500`, `PENALTY_BACKOFF_WEIGHT 1→100`, `PENALTY_ERROR_WEIGHT 0.4→40`) to preserve their relative impact across the new score range. `Q_SATURATION_CAP` removed (multiplicative scoring naturally weights remaining quota).
- **F4 (multiplicative burn-down):** Replaced the additive score with `S = T_pts × Q_remain` (`score ∈ [0, 10000]`). The new formula makes "quota likely lost if not used" the actual scoring objective: `Account A(17%/2h) avg=690` now correctly loses to `Account B(87%/4h) avg=1460` even though Account A's session is closer to reset. Penalties rescaled 100× (`PENALTY_DEGRADED 25→2500`, `PENALTY_BACKOFF_WEIGHT 1→100`, `PENALTY_ERROR_WEIGHT 0.4→40`) to preserve their relative impact across the new score range. `Q_SATURATION_CAP` removed (multiplicative scoring naturally weights remaining quota).
- **Plan & Review:** `.sisyphus/plans/routing-strategy-v6.md` documents derivation, hand-math, and Oracle/Momus review history (4 logic blockers + 9 process items resolved). `routing-strategy-v5.md` is marked superseded.

### 🔧 Internal
Expand Down
2 changes: 1 addition & 1 deletion docs/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.8.6
version: 3.8.7
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,
Expand Down
Loading
Loading