Skip to content

fix(settings): preserve default forwarding keyword rewrites - #4

Merged
i1hwan merged 1 commit into
mainfrom
fix/native-claude-forwarding
Apr 11, 2026
Merged

i1hwan merged 1 commit into
mainfrom
fix/native-claude-forwarding

Conversation

@i1hwan

@i1hwan i1hwan commented Apr 11, 2026

Copy link
Copy Markdown
Owner

Summary

  • change forwarding keyword settings semantics so saved config overlays the default safety rewrite rules instead of replacing them outright
  • keep default Claude OAuth lexical rewrites active when persisted settings contain empty arrays, invalid entries, or only partial overrides
  • add regression coverage proving saved settings can customize matching rules without silently disabling the remaining defaults

Verification

  • node --import tsx/esm --test tests/unit/translator-openai-to-claude.test.mjs
  • npx eslint open-sse/config/forwardingKeywordRules.ts src/app/api/settings/tests/forwarding-keywords.test.ts tests/unit/translator-openai-to-claude.test.mjs
  • npm run typecheck:core
  • manual API QA confirmed empty saved settings still rewrite background_cancel -> background_stop and background_output <directories>src/</directories> -> background_result directories:\nsrc/

Copilot AI review requested due to automatic review settings April 11, 2026 11:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adjusts forwarding keyword settings so persisted configurations overlay the default safety/Claude OAuth rewrite rules instead of replacing them, preventing defaults from being silently disabled by empty/partial saved configs.

Changes:

  • Added merge logic so lane rules keep defaults unless a saved rule explicitly overrides a matching default.
  • Updated normalization behavior and expanded unit coverage around empty arrays, partial overrides, and invalid entries.
  • Updated /api/settings/forwarding-keywords tests to assert the effective (normalized) config retains defaults.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
tests/unit/translator-openai-to-claude.test.mjs Adds regression tests ensuring normalization and runtime rewriting keep default rules active with empty/partial saved config.
src/app/api/settings/tests/forwarding-keywords.test.ts Updates API tests to validate effective config preserves defaults and supports partial overrides.
open-sse/config/forwardingKeywordRules.ts Implements rule-merging in normalization so saved settings overlay (not replace) default forwarding keyword rules.
Comments suppressed due to low confidence (1)

open-sse/config/forwardingKeywordRules.ts:144

  • Per repo PR gate, please run npm run test:coverage for this change (it modifies production code under open-sse/) and ensure coverage meets the minimum thresholds; consider adding the resulting coverage summary to the PR description/check output so reviewers can verify the gate passed.
export function normalizeForwardingKeywordConfig(value: unknown): ForwardingKeywordConfig {
  const normalized = cloneForwardingKeywordConfig(DEFAULT_FORWARDING_KEYWORD_CONFIG);
  if (!value || typeof value !== "object") return normalized;

  const rawConfig = value as Record<string, unknown>;
  for (const lane of Object.keys(DEFAULT_FORWARDING_KEYWORD_CONFIG) as ForwardingKeywordLane[]) {
    const rawLane = rawConfig[lane];
    if (!rawLane || typeof rawLane !== "object") continue;

    const rawLaneConfig = rawLane as Record<string, unknown>;
    const toolNameOverrides = Array.isArray(rawLaneConfig.toolNames)
      ? rawLaneConfig.toolNames.map(normalizeKeywordRule).filter(Boolean)
      : [];
    const textOverrides = Array.isArray(rawLaneConfig.text)
      ? rawLaneConfig.text.map(normalizeKeywordRule).filter(Boolean)
      : [];
    const tagOverrides = Array.isArray(rawLaneConfig.tags)
      ? rawLaneConfig.tags.map(normalizeTagRule).filter(Boolean)
      : [];

    normalized[lane] = {
      toolNames: mergeKeywordRules(normalized[lane].toolNames, toolNameOverrides),
      text: mergeKeywordRules(normalized[lane].text, textOverrides),
      tags: mergeTagRules(normalized[lane].tags, tagOverrides),
    };
  }

  return normalized;
}

@i1hwan
i1hwan merged commit 98fc781 into main Apr 11, 2026
50 of 51 checks passed
i1hwan added a commit that referenced this pull request Apr 29, 2026
…ype strictness)

Address 3 HIGH inline comments from Copilot re-review (94cd28d) and 2 pre-existing TS errors uncovered while fixing them.

## Copilot inline comments addressed

- (#2) route.ts:95-98 — Removed unnecessary type casts `as never` and
  `as Parameters<typeof isTerminalConnectionStatus>[0]` from
  `checkEligibility`. The callees accept compatible structural types
  (isAccountUnavailable is untyped, isTerminalConnectionStatus only needs
  { testStatus?: string | null }).

- (#3, #4) RoutingBadge.tsx:107-122 — Replaced `!== null` checks with
  `Number.isFinite()` to fix tooltip rendering bug. Old check
  `entry.breakdown?.X !== null` evaluated to `true` when breakdown was
  undefined, leading formatNum(undefined) to return '—' which then had
  '%' appended → '—%'. Number.isFinite correctly rejects undefined,
  null, NaN, and Infinity in one expression.

## Pre-existing TS errors fixed (introduced in PR #25 first commit, missed by CI)

- route.ts:128 (TS2345) — `scoreAccount(c)` failed because
  `ConnectionRow.isActive: boolean | number | null` was incompatible
  with `ConnectionLike.isActive?: boolean`. Now normalize via:
  `isActive: c.isActive === false || c.isActive === 0 ? false : undefined`
  preserving 'inactive' signal without manufacturing positive 'true'.
  Also normalize rateLimitedUntil with finite-number guard around
  toISOString to prevent RangeError on NaN/Infinity input.

- route.ts:193 (TS2352) — `as ConnectionRow[]` cast on
  `getProviderConnections()` (returns JsonRecord[]) replaced with
  explicit `as unknown as ConnectionRow[]` to acknowledge the
  dynamic-shape boundary.

## Guardrail

- tsconfig.typecheck-core.json — Added route.ts to `files` whitelist
  so future regressions are caught by CI typecheck:core.

Verified by Oracle (3 review rounds: identified Number.isFinite gap and
toISOString throw risk; final APPROVED_WITH_NOTES).
LSP diagnostics: clean on all 3 modified files.

Closes Copilot inline comments PRRC #2/#3/#4 (94cd28d review).
i1hwan added a commit that referenced this pull request May 7, 2026
…ransparency banner, dual quota bars + routing v7 burn-pressure max (#25)

* feat(strategy): add normalizeConfiguredStrategy helper

Returns the input value if it appears in SETTINGS_FALLBACK_STRATEGY_VALUES,
otherwise "fill-first". Mirrors auth.ts:571's runtime fall-through behavior
for unrecognized configured values, but in a single shared helper that the
upcoming dashboard routing-preview API can consume without importing from
auth.ts.

Naming intent: this surfaces the user's configured fallback strategy, NOT
the strategy auth actually executes for any given request. Auth's per-
provider dispatch only explicitly branches on round-robin / p2c / random /
least-used / cost-optimized / strict-random / earliest-reset-first; the
other six valid configured values fall through to fill-first behavior.
The dashboard banner displays the configured value with that disclaimer.

7 new tests cover all 13 valid values + null + undefined + empty + garbage
+ case sensitivity. Tests pass; lint and typecheck:core/noimplicit:core
clean.

First commit of feat/provider-limits-priority-v3.1 (see
.sisyphus/plans/provider-limits-priority-and-autorefresh.md).

* chore(i18n): add usage-page keys for routing badge, banner, and auto-refresh (32 locales)

30 new keys under usage namespace covering:
- routing priority badge labels (Next, P{rank}, excluded reasons)
- routing score breakdown tooltip rows
- transparency banner strings + disclaimer about model-locked routes
- auto-refresh toggle + interval selector

en and ko hand-translated; 30 other locales seeded with English
placeholder values via a one-shot Node script. next-intl 4.x default
behavior renders namespace.key string for missing keys (no crash), so
the placeholders only prevent users from seeing raw keys until proper
translations arrive.

Lint and prettier clean. settings-i18n-keys test still passes.

Lands before commit 3 (API exposure) and commits 4-7 (UI components)
so that no UI commit references a key that doesn't yet exist.

Second commit of feat/provider-limits-priority-v3.1.

* feat(api): expose routing preview + configuredRoutingStrategy on /api/usage/provider-limits

GET and POST /api/usage/provider-limits now additionally return:
- configuredRoutingStrategy: normalized via the new
  normalizeConfiguredStrategy helper (always one of the 13 known values).
- routing: a Record<connectionId, RoutingPreviewEntry> map.

Routing preview construction (inline computeRouting helper, ~110 LOC):
- Group connections by provider.
- For earliest-reset-first: pre-filter inactive / rate-limited / terminal
  rows with the SAME predicates auth.ts:431-442 uses (isAccountUnavailable
  from open-sse/services/accountFallback, isTerminalConnectionStatus from
  src/sse/services/accountTerminalStatus). The pre-filter prevents the
  dashboard from labeling production-ineligible rows as "Next". Surviving
  rows go through the existing scoreAccount(); ranks are dense 1..N
  per provider over non-excluded entries; scoring-excluded entries get
  rank:null with the precise reason from scoreAccount.
- For other strategies: every entry has rank:null. Inactive / rate-limited
  / terminal still surface as excluded so the user sees red rows; we do
  NOT compute a numeric rank because auth's dispatch for these strategies
  is not score-based.

Per Oracle v3 review:
- Field name configuredRoutingStrategy (not "routingStrategy") to be
  honest about what is exposed: this is what the user configured, not
  what auth executes (auth's dispatch only branches on 6 + ERF; the
  other 6 valid configured values fall through to fill-first behavior).
- Per-request model context (auth.ts:439 isModelLocked) is deliberately
  NOT replicated. The dashboard has no request model. This makes the
  preview a quota-priority preview, not a full request-level prediction.
- isAccountUnavailable imported from @omniroute/open-sse/services/
  accountFallback (verified against auth.ts:11), not @/sse/services/auth.
- rateLimitedUntil accepts string | number (epoch); coerced via
  rateLimitedSentinel before isAccountUnavailable.

Touched files:
- src/app/api/usage/provider-limits/route.ts: 44 → 234 LOC.
- docs/openapi.yaml: new path entry under /api/usage/provider-limits
  documenting GET and POST shapes.
- tests/unit/api-usage-provider-limits.test.mjs: new, 10 tests covering
  ERF dense ranks, inactive (boolean + numeric 0), rate_limited (string +
  epoch number), terminal, multi-provider grouping, breakdown shape,
  non-ERF excluded surfacing, defensive entry skipping.

Verification: prettier, eslint, typecheck:core, typecheck:noimplicit:core,
docs:sync, full test:unit 2811/2811 (was 2794) — all green.

Third commit of feat/provider-limits-priority-v3.1.

* feat(dashboard): RoutingBadge component on Provider Limits rows

Renders next to the existing tier badge in each account row:
- ERF rank == 1 → solid "Next" chip (primary tint).
- ERF rank > 1 → ghost "P{rank}" chip.
- Excluded → ghost chip with diagonal stripe overlay + localized reason
  (inactive / rate_limited / terminal / quota_exhausted_unknown_reset
  / unavailable).
- Non-ERF eligible row → renders nothing (no rank to surface).

Hover/focus reveals a self-contained tooltip with the full routing score
breakdown (session %, weekly %, session/weekly points, base score,
penalties, final score). Built without the shared Tooltip primitive
because that primitive uses whitespace-nowrap and can't render a
multi-line table; the badge owns its own positioned popover with
whitespace-pre-line and a min-width.

Wiring in ProviderLimits/index.tsx:
- New state: routingByConnection (RoutingPreviewMap) + configuredRoutingStrategy.
- fetchCachedProviderLimits parses both new fields from GET response.
- refreshAll parses both from POST response.
- New refreshRoutingOnly() helper does a lightweight GET after every
  per-row /api/usage/[connectionId] success, so the badge stays fresh
  without changing the per-connection endpoint shape.

Verification: prettier, eslint, typecheck:core, typecheck:noimplicit:core
clean. (UI behavior is best verified manually after deployment.)

Fourth commit of feat/provider-limits-priority-v3.1.

* feat(dashboard): RoutingTransparencyBanner above account list

Compact info row between the page header and the tier-filter chips:

  Configured: <strategy> · Next per provider: Claude (acct-A), GLM (acct-B)
                                                                          ℹ

For ERF strategy, lists each provider that has 2+ accounts with the next
account it would pick. The provider/account pair becomes a clickable
button that scrolls to that provider's first row via data-connection-id.
For all-excluded providers it renders "(all excluded)". For non-ERF
strategies it shows only the configured strategy.

The trailing info icon hovers to the disclaimer that this is a
quota-priority preview, not a full prediction (per-request model lockout
isn't reproduced).

Account names truncated to 16 chars with ellipsis. Uses pickMaskedDisplayValue
for the same masked email/name presentation the row already uses.

Wiring in index.tsx:
- New import + new state value (configuredRoutingStrategy was previously
  setter-only).
- Banner rendered after the header div, before tier filters.
- Each row gets data-connection-id={conn.id} so the banner's scroll
  target finds it.

Verification: prettier, eslint, typecheck:core, typecheck:noimplicit:core
clean.

Fifth commit of feat/provider-limits-priority-v3.1.

* feat(dashboard): AutoRefreshControl with race-safe visibility-aware polling

User-configurable Refresh All cadence next to the existing manual button.

UI:
- Checkbox toggle "Auto refresh" + select dropdown 1m / 2m / 5m / 10m.
- Persisted to localStorage:
    omniroute:limits:autoRefresh:enabled
    omniroute:limits:autoRefresh:intervalMs
- Dropdown disabled when toggle is off.

Behavior:
- Polls only when document.visibilityState === "visible".
- visibilitychange listener triggers an immediate refresh on tab focus
  (hidden -> visible transition), then resumes the interval cadence.
- inFlightRef prevents overlap with manual Refresh All or another auto
  refresh that hasn't returned yet.
- lastTriggerAtRef enforces a 1-second same-tick guard against the race
  where setInterval and visibilitychange fire near-simultaneously.
- onTriggerRef captures the latest onTrigger callback identity without
  re-installing the interval on every parent re-render.

SSR-safe: typeof window / typeof document guards on every browser API.
Initial state read happens in useEffect (post-mount) to avoid hydration
mismatch.

Verification: prettier, eslint, typecheck:core, typecheck:noimplicit:core
clean.

Sixth commit of feat/provider-limits-priority-v3.1.

* feat(dashboard): dual session/weekly QuotaVisualization on each row

New compact mini-bar block rendered before the existing per-model bars:

  Session remaining ████████░░░░░░░░░░░░░░  78%
  Weekly  remaining ████░░░░░░░░░░░░░░░░░░  35%

Picks the matching window from quota.quotas by name match against
"session" / "weekly" (with tolerance for "session(...)", "weekly N day",
etc. labels). Renders nothing if neither window is present in the data
(non-OAuth providers, custom self-hosted, etc.).

Color buckets identical to the existing per-model bar (>50% green,
>20% yellow, ≤20% red).

Burning rate intentionally not in this commit per v3.1 plan §1; the
quota_snapshots history exists but plumbing it requires a new GET
endpoint and a sample-noise gate that doesn't fit this PR scope.

Inserted as the first child of the quota-bar branch using a fragment
wrapper; preserves the existing per-model bar markup verbatim.

Verification: prettier, eslint, typecheck:core, typecheck:noimplicit:core
clean.

Seventh commit of feat/provider-limits-priority-v3.1.

* chore(release): bump 3.7.1 → 3.8.0 and CHANGELOG

Minor feature release for the Provider Limits routing-priority badge,
configurable auto-refresh, transparency banner, and dual session/weekly
quota bars. Verification across the stack:

- prettier ✅
- eslint ✅
- typecheck:core ✅
- typecheck:noimplicit:core ✅
- docs:sync ✅ (3.8.0 across package.json, openapi.yaml, CHANGELOG)
- full test:unit 2811/2811 ✅ (was 2794, added 7 normalize +
  10 routing-preview)

Tag after merge: apex-v2.1.0.

Eighth and final commit of feat/provider-limits-priority-v3.1.

* fix(api): preserve full caches map in /api/usage/provider-limits POST response

Copilot review of PR #25 caught a regression introduced by the original
buildResponseBody implementation: spreading `...extra` LAST allowed the
partial-failure result from syncAllProviderLimits (which only contains
successfully refreshed connections in result.caches) to override the full
disk cache map returned by getCachedProviderLimitsMap(). After Refresh All
where some upstream calls fail, the dashboard's applyCachedQuotaState
received a partial map and the failed connections' quota rows would
disappear from the UI.

Pre-PR baseline put `caches: getCachedProviderLimitsMap()` last, so the
full map always won. PR #25's buildResponseBody accidentally inverted that.

Fix: extract a pure mergeIntoResponseBody(extra, base) helper. It
explicitly drops `caches`, `routing`, and `configuredRoutingStrategy`
from `extra` via destructuring, then spreads `base` (authoritative
fields) LAST. Symmetrically protects routing/configuredRoutingStrategy
in case a future caller passes those in `extra` too.

Two new unit tests on the pure helper:
1. partial-sync result preserves both A and B in caches; errors map
   passes through restExtra
2. defensive: even an adversarial extra with phantom caches/routing/
   strategy keys cannot leak into the response

Test approach uses only the pure helper — no DB, no settings, no
connections, no syncAllProviderLimits invocation.

Closes Copilot PR #25 review comment PRRC_kwDOR_WnW868DIIW.

Plan: .sisyphus/plans/pr25-fix-plan.md commit 9 (Issue 1).

* fix(dashboard): RoutingTransparencyBanner clicks scroll to nextConn, not first row

Copilot review of PR #25 caught: the banner displayed `accountName`
derived from `nextConn` (the connection with `isNext: true`) but its
onClick scrolled to `firstConnId` which was `group[0]?.id` — the first
connection in the source-order list, which may differ from nextConn.
User clicks the banner expecting to jump to the displayed account, but
lands on a different one.

Renamed NextEntryByProvider.firstConnId → nextConnId, set it to
nextConn?.id ?? null. JSX now renders the clickable button only when
nextConnId is non-null; when it's null (unusual edge case where no
isNext was found in the routing map), the entry renders as a plain
non-clickable span instead of scrolling to a stale `group[0]`.

Closes Copilot PR #25 review comment PRRC_kwDOR_WnW868DIKK.

Plan: .sisyphus/plans/pr25-fix-plan.md commit 10 (Issue 2).

* feat(dashboard): RoutingTransparencyBanner shows single-account providers + fixes cold-cache misclassification

Copilot review of PR #25 caught: the banner's collectNextPerProvider
guard `if (group.length < 2) continue;` excluded providers with exactly
one account, even though that one account IS the next pick. The user's
original framing was "여러 계정 있을 때" but transparency consistency
benefits from showing all providers — when only one account exists for
a provider, it's still useful to confirm "Next per provider: Claude
(only-acct-X)".

Self-review of PR #25 also surfaced an edge case in the same loop
(Issue 9): when the routing map has no entry for any connection in a
provider group (e.g., right after server boot before quotaCache is
populated), nonExcludedCount stayed 0 and allExcluded was set to true —
falsely claiming "(all excluded)" when the truth is "no data yet".

Fix:
1. Lower the guard to `group.length === 0` (always-skip empty groups
   only).
2. Track both nonExcludedCount AND excludedCount during the per-group
   scan, plus a derived hasAnyEntry boolean.
3. allExcluded becomes `hasAnyEntry && nonExcludedCount === 0`, so:
   - has entries, all excluded  → "(all excluded)"
   - has entries, some eligible → render account name
   - no entries (cold cache)    → render "(—)" via existing
                                  `accountName ?? "—"` fallback

Closes Copilot PR #25 review comment PRRC_kwDOR_WnW868DIJu.

Plan: .sisyphus/plans/pr25-fix-plan.md commit 11 (Issues 4 + 9).

* refactor(api): extract shared RoutingPreview types + collapse duplicate eligibility check

Self-review of PR #25 surfaced three coupled cleanup opportunities:

Issue 3 — Type drift risk: RoutingPreviewEntry and RoutingPreviewBreakdown
were declared in TWO places: route.ts and RoutingBadge.tsx, with
RoutingTransparencyBanner.tsx importing from RoutingBadge. Future change
to backend shape would silently pass frontend type-check because frontend
read its own (stale) declaration.

Issue 7 — rateLimitedSentinel was unnecessary: the helper converted epoch
number → ISO string before passing to isAccountUnavailable, but the function
already does `new Date(unavailableUntil).getTime()` (accountFallback.ts:660-663),
which accepts strings AND numbers natively.

Issue 8 — computeRouting's ERF and non-ERF branches duplicated the
inactive/rate_limited/terminal cascade verbatim.

Fix:
1. New canonical contract `src/shared/contracts/routingPreview.ts`
   exports RoutingPreviewBreakdown / RoutingPreviewEntry / RoutingPreviewMap.
2. route.ts imports from contracts + re-exports for backwards compat with
   any existing importer.
3. RoutingBadge.tsx imports from contracts + re-exports both types so
   index.tsx's `import RoutingBadge, { type RoutingPreviewEntry } from
   "./RoutingBadge"` continues to compile unchanged.
4. RoutingTransparencyBanner.tsx imports directly from contracts (was
   importing from RoutingBadge).
5. rateLimitedSentinel deleted; checkEligibility calls
   isAccountUnavailable(c.rateLimitedUntil as never) directly.
6. checkEligibility(c, strategy) extracted as the single source of truth
   for the inactive/rate_limited/terminal cascade. Both ERF and non-ERF
   branches in computeRouting now call it.

Plan: .sisyphus/plans/pr25-fix-plan.md commit 12 (Issues 3, 7, 8).

* test(api): assert baseScore equals avg of known track scores (Copilot PR #25 review)

Copilot review of PR #25 caught: the existing test computed
`expectedBase` but never asserted it against the actual
`entry.breakdown.baseScore`. As written it only checked that baseScore
is a number, so a regression in scoreAccount's averaging math (line 331
of earliestResetFirst.ts) would not be caught by the test.

Additionally, the old `expectedBase` formula was wrong — it weighted
points by remainingPct, but the actual baseScore in earliestResetFirst.ts:331
is `trackScores.reduce((a,b) => a+b, 0) / trackScores.length` where each
trackScore is the points value (NOT pct-weighted).

Fix: replace the type-only assertion with a math assertion using the
correct averaging formula, with floating-point tolerance (1e-9). Also
handles the edge case where no tracks have known scores (baseScore = 0).
A code comment cites earliestResetFirst.ts:331 as the formula source.

Closes Copilot PR #25 review comment PRRC_kwDOR_WnW868DIHb.

Plan: .sisyphus/plans/pr25-fix-plan.md commit 13 (Issue 5).

* refactor(dashboard): extract shared getBarColor + name TOOLTIP_CLOSE_DELAY_MS constant

Self-review of PR #25 surfaced two small DRY violations:

Issue 10 — getBarColor was duplicated identically in:
  - src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx
  - src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaVisualization.tsx
  with the same >50/>20 thresholds and same hex colors.

Issue 11 — RoutingBadge.tsx tooltip close delay was a magic number
`setTimeout(... , 80)` with no inline rationale. Plan v3.1 §11 required
naming the constant with a 1-line intent comment.

Fix:
1. New `quotaColors.ts` exports QUOTA_BAR_GREEN_THRESHOLD,
   QUOTA_BAR_YELLOW_THRESHOLD, QuotaBarColors, getBarColor.
2. index.tsx and QuotaVisualization.tsx both import from quotaColors.ts
   and remove their local copies. The dashboard's runtime behavior is
   identical — same thresholds, same hex colors.
3. RoutingBadge.tsx: extracted `const TOOLTIP_CLOSE_DELAY_MS = 80` at
   module top with a 2-line comment explaining the brief grace period
   for cursor traversal between badge and tooltip. Value preserved
   exactly from PR #25.

Plan: .sisyphus/plans/pr25-fix-plan.md commit 14 (Issues 10, 11).

* refactor(api): filter computeRouting inputs to USAGE_SUPPORTED_PROVIDERS

Self-review of PR #25 surfaced a small efficiency / consistency issue:
auth.ts:325 and syncAllProviderLimits:325 both call
getProviderConnections({ isActive: true }), but the routing-preview
route used getProviderConnections({}) and passed every row (including
free providers like Qoder/Pollinations) into computeRouting.

The dashboard's frontend already filters to USAGE_SUPPORTED_PROVIDERS
in filteredConnections, so any routing entry for unsupported providers
was silently discarded. Wasted backend work + potential schema
mismatches if free providers later add testStatus/rateLimitedUntil
semantics that the routing logic doesn't handle.

Fix: at module top, build a Set<string> from USAGE_SUPPORTED_PROVIDERS
(cast as readonly string[]). At the start of computeRouting's group-by
loop, skip any connection whose provider is not in the set. O(1) lookup
per connection.

Test: new "skips connections whose provider is not in
USAGE_SUPPORTED_PROVIDERS" test exercises 4 unsupported providers
(qoder, pollinations, totally-fake-provider) plus 1 supported (claude)
and asserts the routing map only contains the supported entry.

Plan: .sisyphus/plans/pr25-fix-plan.md commit 15 (Issue 12).

* fix: Copilot re-review (cast cleanup, tooltip null safety, route.ts type strictness)

Address 3 HIGH inline comments from Copilot re-review (94cd28d) and 2 pre-existing TS errors uncovered while fixing them.

## Copilot inline comments addressed

- (#2) route.ts:95-98 — Removed unnecessary type casts `as never` and
  `as Parameters<typeof isTerminalConnectionStatus>[0]` from
  `checkEligibility`. The callees accept compatible structural types
  (isAccountUnavailable is untyped, isTerminalConnectionStatus only needs
  { testStatus?: string | null }).

- (#3, #4) RoutingBadge.tsx:107-122 — Replaced `!== null` checks with
  `Number.isFinite()` to fix tooltip rendering bug. Old check
  `entry.breakdown?.X !== null` evaluated to `true` when breakdown was
  undefined, leading formatNum(undefined) to return '—' which then had
  '%' appended → '—%'. Number.isFinite correctly rejects undefined,
  null, NaN, and Infinity in one expression.

## Pre-existing TS errors fixed (introduced in PR #25 first commit, missed by CI)

- route.ts:128 (TS2345) — `scoreAccount(c)` failed because
  `ConnectionRow.isActive: boolean | number | null` was incompatible
  with `ConnectionLike.isActive?: boolean`. Now normalize via:
  `isActive: c.isActive === false || c.isActive === 0 ? false : undefined`
  preserving 'inactive' signal without manufacturing positive 'true'.
  Also normalize rateLimitedUntil with finite-number guard around
  toISOString to prevent RangeError on NaN/Infinity input.

- route.ts:193 (TS2352) — `as ConnectionRow[]` cast on
  `getProviderConnections()` (returns JsonRecord[]) replaced with
  explicit `as unknown as ConnectionRow[]` to acknowledge the
  dynamic-shape boundary.

## Guardrail

- tsconfig.typecheck-core.json — Added route.ts to `files` whitelist
  so future regressions are caught by CI typecheck:core.

Verified by Oracle (3 review rounds: identified Number.isFinite gap and
toISOString throw risk; final APPROVED_WITH_NOTES).
LSP diagnostics: clean on all 3 modified files.

Closes Copilot inline comments PRRC #2/#3/#4 (94cd28d review).

* revert: drop provider-limits route.ts from typecheck-core whitelist

Adding provider-limits/route.ts to tsconfig.typecheck-core.json's files
whitelist (commit f4e3125) caused tsc to follow the transitive import
graph through @omniroute/open-sse/services/accountFallback into the
entire open-sse/* tree, exposing 10+ pre-existing TS errors in
open-sse/executors/{antigravity,base,cliproxyapi,cloudflare-ai}.ts and
open-sse/config/forwardingKeywordRules.ts. CI Lint job failed.

These errors predate this PR and are out of scope. Reverting only the
whitelist entry; the route.ts type strictness fixes (scoreAccount
normalize, getProviderConnections cast) and the Copilot re-review fixes
remain.

Follow-up: dedicated PR to fix open-sse/* TS errors and add this file
to the whitelist as a guardrail.

* feat(routing): v7 burn-rate pressure max — fixes mean-dilution defect

v6 used arithmetic mean across session and weekly tracks. In production
this dilutes the more-urgent track with the calmer track, picking accounts
where one track is hot but the dominant burn target is on the OTHER account.

User scenario (2026-05-07 dashboard):
  APEXATGNU: session 33%/0h34m, weekly 91%/6d17h
  GNUMAX:    session 29%/1h54m, weekly 63%/1d1h
v6 picked APEXATGNU (mean 1982 > 1840) instead of GNUMAX (the account
about to waste 63% weekly in 1 day).

v7 changes the cross-track combination from arithmetic mean to max():
  pressure(Q, t, W) = Q × clamp(W/t, 1, URGENCY_CAP)
  trackScore = pressure(...)
  baseScore  = max(sessionPressure, weeklyPressure)

The two windows are independent ledgers (verified via anthropics/claude-code
issues #54750, #52135, #40513). Treating them as independent burn-pressure
signals and routing to the more perishable one matches user intent
"burn what's about to expire".

Penalty rebalance: PENALTY_BACKOFF_WEIGHT 100 -> 101 so a fully-backed-off
paid account is strictly below self-hosted score=0 (was tie at 0).

All v6 invariants preserved:
  - F1 self-hosted score=0 fallback
  - F2 per-model weekly windows ignored
  - F3 Q=100 + null resetAt -> max urgency
  - F4 multiplicative scoring + penalty layer

sessionTimePoints/weeklyTimePoints retained as @deprecated for diagnostic
UIs and external callers; v7 scoring path uses pressure() directly.

Tests updated:
  - 7 new V7 RED tests (V7-2 through V7-8 plus V7-direct)
  - 17 existing assertions recalculated using returned track.secondsToReset
    (not seeded value) to eliminate deltaSec() floor flakiness
  - api-usage-provider-limits.test.mjs:132 mean -> max assertion

Verify chain: prettier / eslint / typecheck:core / typecheck:noimplicit:core /
test:unit (2821/2821 PASS) / docs:sync. Version 3.8.0 -> 3.8.1.

Plan: .sisyphus/plans/routing-strategy-v7.md
Oracle review: APPROVED with revisions (bg_8fb7b507)
Momus review: v1->v4 with all blockers fixed (bg_8729c574, bg_d075f71b,
              bg_4c59db7e, bg_21ed0a44)

* fix: address Copilot review on v7 (PR #25 review round 4)

4 valid blockers from Copilot's 2026-05-07 review on commit 8483e8a:

1. RoutingBadge.getExcludedI18nKey: scoreAccount can exclude with
   reasons "session<5%" / "weekly<5%" but the i18n switch only mapped
   "quota_exhausted_unknown_reset" to routingPriorityExcludedQuota,
   so quota-low rows fell through to "Unknown". Now any reason
   containing "<5%" maps to the quota-localized label.

2. i18n routingScoreBase label "Base score (avg)" was misleading after
   v7 changed baseScore from arithmetic mean to Math.max(...).
   Updated all 32 locales (en + ko hand-translated, 30 placeholders
   updated to "Base score (max)"; ko: "기본 점수 (최댓값)").

3. QuotaVisualization.pickWindow: the previous "<key> " prefix match
   could collide with model-specific windows like "weekly Sonnet (7d)"
   depending on object iteration order. Now does an exact-match /
   parenthesised match in pass 1 ("weekly (7d)" / "session (5h)") and
   only falls back to the unparenthesised prefix in pass 2 — so the
   per-row Weekly mini-bar always reflects the overall window when
   present in the cache.

4. ProviderLimits row was double-rendering session/weekly windows:
   QuotaVisualization (mini-bars) AND quota.quotas.map (per-model bars)
   both included them. Added isOverallWindowName helper and filter the
   map() iteration so per-model bars exclude session/weekly entries.

Outdated comments (auto-flagged by Copilot but already fixed in earlier
commits, verified by direct code inspection):
  - 70625f7 expectedBase assertion (fixed in this PR's earlier
    api-usage-provider-limits update; line 152 has the assertion)
  - 70625f7 caches override (fixed via _ignoredExtraCaches destructure
    in route.ts:190)
  - 70625f7 single-account banner (fixed: group.length === 0)
  - 94cd28d unsafe casts (already removed in route.ts:95-100)
  - 8483e8a diegosouzapw#126 banner scroll-to (already fixed by 25a52df)
  - 8483e8a RoutingBadge null-check uses Number.isFinite (correct)

Non-blocking performance/accessibility comments deferred:
  - refreshRoutingOnly debounce (perf nit, separate PR if needed)
  - disclaimer info icon focusable (a11y nit, separate PR)

Verify: prettier ✓ eslint ✓ typecheck:core ✓ typecheck:noimplicit:core ✓
test:unit 2821/2821 PASS ✓

* test(routing): tolerate deltaSec() floor drift in v7 score assertions

CI on linux node 20/22 hit "github-style account with no session window
scores from weekly only" assertion fail at 1e-9 tolerance because:

  - test calls scoreWeeklyTrack(...) → captures w.secondsToReset = N
  - test computes expectedW = pressure(Q, N, W)
  - test calls scoreAccount(...) which internally calls
    scoreWeeklyTrack(...) again → may see secondsToReset = N-1
    (Date.now drifted by ≥1ms across calls, deltaSec floors)
  - assertion `Math.abs(result.score - expectedW) < 1e-9` fails because
    pressure(Q, N-1, W) − pressure(Q, N, W) ≈ Q × W/(N(N-1)) ≫ 1e-9

Fix: introduce withinPressureMaxBand() / pressureDriftBand() helpers
that build inclusive [lo, hi] band tolerating one floor() step in either
direction, then assert result.score ∈ band. Applied to the 3 affected
tests (single-track ghacct, F2-2 max-not-mean, F4-3 idle abundance).

Same machine-local test passes at 1e-9 because Date.now resolution +
test ordering rarely cross floor boundary; CI hosts with slower clocks
do cross. CI assertion now stable.

Verify: prettier ✓ eslint ✓ typecheck:core ✓ typecheck:noimplicit:core ✓
test:unit 2821/2821 PASS ✓

* test(routing): widen deltaSec drift band to +/-1 second

Earlier fix only checked sec vs sec-1 but the second call can see
sec+1 too (depends on Date.now() millisecond rollover ordering).
F2-2 hit this on linux node 20: result.score=220.588 fell just below
the lo=220.59 boundary because the band was built one-directional.

Now pressureDriftBand checks {sec-1, sec, sec+1} and takes the full
[min, max] envelope. All 56 strategy tests still pass locally.

* fix: address Copilot review round 5 (PR #25 review on fa4d02f)

3 valid findings from Copilot's 2026-05-07 14:39 review:

1. RoutingTransparencyBanner.scrollToConnection: querySelector built a
   CSS attribute selector with raw connectionId. Most ids today are
   safe UUIDs, but custom OAuth/openai-compat ids could contain quotes
   or brackets that throw at parse. Now CSS.escape()s the value with a
   regex fallback for older browsers, and try/catch on the call so an
   unexpected throw silently no-ops instead of breaking the click
   handler.

2. RoutingBadge tooltip a11y: tooltip used role="tooltip" but had no
   programmatic association with the focusable badge, so screen readers
   may not announce the breakdown on focus. Added useId() + matching
   id on the tooltip span and aria-describedby on the focus target
   (only while open=true, so the description disappears with the
   tooltip itself).

3. earliestResetFirst.pressure() doc mismatch: code returns Q×CAP for
   any non-finite t (including Number.POSITIVE_INFINITY emitted by the
   F3 fresh-quota branch), but the doc described t>W → multiplier
   floored at 1, which contradicted the Infinity case. Doc now lists
   t=null OR non-finite as the same max-urgency branch and clarifies
   that t>W finite is the multiplier=1 floor case. Code unchanged
   (preserved as more conservative fallback).

Verify: prettier ✓ eslint ✓ typecheck:core ✓ typecheck:noimplicit:core ✓
test:unit 2821/2821 PASS ✓
i1hwan added a commit that referenced this pull request May 7, 2026
Security: /api/sessions enrichment now uses listProviderConnectionMetadata()
which selects only id/provider/name/display_name/email. Decryption of apiKey,
accessToken, refreshToken, idToken via getProviderConnections() is no longer
on this hot path. Fixes Copilot review #11.

Routing memory: heuristicBreakHistory map now bounded by BOTH age (10min) AND
hard size cap (1000). Re-set on insert moves the entry to the end of the
insertion-ordered Map; size-cap eviction loop removes oldest entries when
size exceeds 1000. >1000 distinct sessions inside the cooldown window can no
longer grow the map without bound. Fixes Copilot review #1/#6.

Routing perf: isAffinityValid heuristic-break #2 now finds the bound entry
in scoredAlternatives and reuses its score instead of calling scoreAccount()
again. Matches the 'score upfront once' contract of selectByEarliestResetFirst.
Fixes Copilot review #2/#7.

UI tooltip: RoutingBadge now flips below the badge when there isn't enough
space above (TOOLTIP_HEIGHT_ESTIMATE_PX + gap + viewport padding). top anchors
to r.bottom + gap on flip; transform switches from translateY(-100%) to
translateY(0). Fixes Copilot review #3/#8.

UI width: TOOLTIP_WIDTH_ESTIMATE_PX raised 240 -> 244 to match min-w-[220px]
plus px-3 padding. Eliminates 4px overflow on narrow viewports.
Fixes Copilot review #9.

Comments: SSR mounted comment rewritten to describe the actual typeof window
inline check and the eslint react-hooks/set-state-in-effect rule that blocks
the useState+useEffect mounted pattern. Fixes Copilot review #10.

Tests: SA-5/6 description corrected (re-test in same tick is strictly inside
the 60s cooldown window; '30s later' wording removed). Fixes Copilot review
#4. New tests/unit/api-sessions-route.test.mjs covers /api/sessions success
enrichment with explicit secret-leak guard, orphan connectionId fallback,
and simulated DB failure fallback. Fixes Copilot review #5.

Version 3.8.2 -> 3.8.3. test:unit 2833/2833 PASS.
i1hwan added a commit that referenced this pull request May 7, 2026
* feat(dashboard): limits page polish + smart session affinity (4 issues)

PR #25 (v3.1 + v7) merge follow-up. User feedback identified 4 polish
items observed on /dashboard/limits after Docker deploy:

1. AutoRefreshControl raw <input type="checkbox"> + raw <select> rendered
   like unstyled HTML controls — replaced checkbox with Toggle from the
   shared design system, restyled the select wrapper to match the rest of
   the dashboard (rounded bg-surface border, focus ring, custom chevron).

2. RoutingBadge hover tooltip was clipped by the Account Model Quotas
   card's overflow-hidden ancestor (PR #25 introduced the clipping). Now
   uses createPortal(..., document.body) with position:fixed and viewport
   edge clamping, matching the codebase's existing portal pattern in
   providers/[id]/page.tsx.

3. Smart session affinity continuation viability (v8). 5min
   SESSION_AFFINITY_WINDOW_MS unchanged (matches Anthropic's prompt cache
   TTL — librarian-confirmed via docs.anthropic.com prompt-caching). Two
   new heuristic break rules layered on top of existing hard exclusions:
     - affinity_break_low_quota: bound's min known remaining < 15% AND a
       usable alt exists (Oracle: 5% hard-floor × 3 cushion).
     - affinity_break_p1_too_urgent: alt score >= bound × 3 AND >= bound
       + 250 absolute delta. The absolute delta neutralizes near-zero
       misfire (e.g. bound=20, alt=61 trips 3× alone but 41-point gap
       doesn't justify a cache write).
   60s per-session cooldown gates oscillation; hard exclusions bypass
   cooldown. selectByEarliestResetFirst now scores upfront and passes the
   list to isAffinityValid (avoids double scoring).

4. SessionsTab account name. /api/sessions now joins active sessions
   against provider connections via getAccountDisplayName (graceful
   degradation: connection lookup failure returns sessions with
   accountName:null without breaking the API). UI replaces raw
   connectionId.slice(0,10) with the resolved account name + provider
   tag, falling back to "Account #xxxxxx" when name is unavailable. New
   i18n key 'usage.connectionFallback' added across 32 locales (en + ko
   hand-translated, 30 placeholder).

Tests: 10 new SA-1..SA-10 RED tests cover smart affinity (low-Q break,
heavy-gap break, no-alt edge, missing-track edge, cooldown, hard-
exclusion bypass, score<=0 special case, backwards-compat third arg).
Test cooldown reset hook (__resetAffinityHeuristicCooldownForTesting)
prevents cross-test pollution. Full unit suite: 2830/2830 PASS (was
2821; +9 net).

Verify: prettier ✓ eslint (errors 0, warnings unchanged from baseline) ✓
typecheck:core ✓ typecheck:noimplicit:core ✓ test:unit 2830/2830 ✓
docs:sync ✓.

Plan: .sisyphus/plans/limits-dashboard-polish.md
Oracle review: APPROVED with 7 revisions, all applied (bg_79458ad3)
Momus reviews: v1 REJECT (QA executability) → v2 OKAY (bg_999bd379, bg_ebbedf5b)

* fix(dashboard): restore reset countdown on session/weekly mini-bars + scoreAccount terminal guard

QuotaVisualization MiniBar now renders the reset countdown next to the label
(`⏱ 0h 34m` / `6d 11h`), mirroring the per-model bar format. PR #25
introduced the dual mini-bars but accidentally dropped the countdown for the
overall Session/Weekly windows, leaving the row visually flat.

scoreAccount() now excludes connections with terminal testStatus (expired /
banned / credits_exhausted) at scoring time, not only inside isAffinityValid.
Without this guard the fall-through path of selectByEarliestResetFirst could
re-select a terminal connection if its cached quotas still looked healthy.
Mirrors the auth.ts contract via the shared isTerminalConnectionStatus helper.
Fixes a SA-7 CI flake on Linux runners (selected.id === 'bound' instead of
'alt') without changing local behavior.

CHANGELOG documents the OAuth-lane prompt-cache reality: clients can request
ttl: '1h' but the server downgrades to 5m on the OAuth path (observed in
production responses; tracked upstream as anthropics/claude-code#46829), so
SESSION_AFFINITY_WINDOW_MS = 5min is the maximum we can rely on, not a default.

* fix(security,routing,ui): address Copilot PR #26 review (8 issues)

Security: /api/sessions enrichment now uses listProviderConnectionMetadata()
which selects only id/provider/name/display_name/email. Decryption of apiKey,
accessToken, refreshToken, idToken via getProviderConnections() is no longer
on this hot path. Fixes Copilot review #11.

Routing memory: heuristicBreakHistory map now bounded by BOTH age (10min) AND
hard size cap (1000). Re-set on insert moves the entry to the end of the
insertion-ordered Map; size-cap eviction loop removes oldest entries when
size exceeds 1000. >1000 distinct sessions inside the cooldown window can no
longer grow the map without bound. Fixes Copilot review #1/#6.

Routing perf: isAffinityValid heuristic-break #2 now finds the bound entry
in scoredAlternatives and reuses its score instead of calling scoreAccount()
again. Matches the 'score upfront once' contract of selectByEarliestResetFirst.
Fixes Copilot review #2/#7.

UI tooltip: RoutingBadge now flips below the badge when there isn't enough
space above (TOOLTIP_HEIGHT_ESTIMATE_PX + gap + viewport padding). top anchors
to r.bottom + gap on flip; transform switches from translateY(-100%) to
translateY(0). Fixes Copilot review #3/#8.

UI width: TOOLTIP_WIDTH_ESTIMATE_PX raised 240 -> 244 to match min-w-[220px]
plus px-3 padding. Eliminates 4px overflow on narrow viewports.
Fixes Copilot review #9.

Comments: SSR mounted comment rewritten to describe the actual typeof window
inline check and the eslint react-hooks/set-state-in-effect rule that blocks
the useState+useEffect mounted pattern. Fixes Copilot review #10.

Tests: SA-5/6 description corrected (re-test in same tick is strictly inside
the 60s cooldown window; '30s later' wording removed). Fixes Copilot review
#4. New tests/unit/api-sessions-route.test.mjs covers /api/sessions success
enrichment with explicit secret-leak guard, orphan connectionId fallback,
and simulated DB failure fallback. Fixes Copilot review #5.

Version 3.8.2 -> 3.8.3. test:unit 2833/2833 PASS.

* fix(routing,ui,api): address Copilot PR #26 round 3 review (3 issues, Oracle-verified)

/api/sessions now fetches metadata only for active session connectionIds:
listProviderConnectionMetadata(ids?) accepts an optional id filter, the route
collects distinct connectionIds from getActiveSessions(), short-circuits to
no DB call on empty, and otherwise issues a single WHERE id IN (?, ?, ...)
with bound parameters. Endpoint work is now proportional to active sessions,
not total connections. Fixes Copilot review #NEW-3.

RoutingBadge useLayoutEffect cleanup no longer calls setCoords(null). The
tooltip is already gated by 'open && coords' so the cleanup state update
was unnecessary and risked extra renders / strict-mode noise. Oracle
verified no stale-frame race (useLayoutEffect runs synchronously before
paint, updateCoords sets fresh coords on reopen). Fixes Copilot review
#NEW-1.

formatCountdown extracted to ProviderLimits/utils.tsx and reused from both
index.tsx (per-model bars) and QuotaVisualization.tsx (Session/Weekly mini
bars). Behavior preserved: <24h => h h m m, >=24h => d d h h, invalid =>
null. Eliminates the drift risk between the two countdown call sites.
Fixes Copilot review #NEW-2.

Tests: +2 in tests/unit/api-sessions-route.test.mjs covering 'no active
sessions skips DB query' and 'distinct connectionIds collapse to single
bound parameter, unrelated secrets never leak'. 2835/2835 unit tests pass.

Oracle pre-commit verification (ses_1fc62b147ffeUtgRRb2uvBKS4x): APPROVED
all 3 fixes with high confidence.

Version 3.8.3 -> 3.8.4.

* fix(routing,ui): address Oracle audit + Copilot R4 review (2 defects + 2 nits)

QuotaVisualization.pickWindow no longer absorbs per-model quotas (D1).
The previous Pass 2 fallback matched any name starting with 'weekly ' or
'session ', so a connection that only had 'weekly Sonnet (7d)' (no
canonical 'weekly' row) populated the overall mini-bar AND rendered as
its own per-model bar simultaneously. Pass 2 removed; only canonical
'session'/'weekly' (with or without parenthesised window) match. New
regression suite tests/unit/quota-visualization-pickwindow.test.mjs.

RoutingBadge tooltip is now visible on viewports shorter than the
tooltip estimate (D2). Previous fix flipped vertically when space-above
ran out but never clamped into the viewport, so very short viewports or
tooltips taller than viewH-2*pad still rendered partially off-screen.
The new placement (1) prefers the side with more room (mirrors
providers/[id]/page.tsx overlay rule), (2) clamps on-screen top with
transform-aware math (above-anchored uses translateY(-100%), so we
require top >= tooltipHeight + padding), (3) caps the rendered element
with maxHeight: calc(100vh - 16px) + overflow: hidden so tooltips
larger than the viewport degrade to a clipped frame, never off-screen.
Fixes Copilot R4-1.

isAffinityValid now guards against scoredAlternatives missing the bound
entry (N1). Previously fell through to boundScore=0, letting any
positive alt trip the urgent-break rule. Now returns { valid: true }
on missing-bound. Production selectByEarliestResetFirst always includes
bound, so this is a future-caller footgun guard. New SA-11 test.

/api/sessions metadata-lookup catch now logs a sanitized warning instead
of swallowing the error silently (N2). Format: '[sessions] connection
metadata lookup failed for N ids: <message>'. Includes only the count
and the error message text — no connectionIds, no SQL, no secrets.

Oracle pre-commit verification (ses_1fc4cce1bffePsARTIZ6x3AxlY)
returned NEEDS_REVISION on D1 and D2; both fixes applied per the
audit's concrete revision instructions, plus the two NITs.

Test count: 2841/2841 PASS (was 2835; +6 net). Version 3.8.4 -> 3.8.5.
i1hwan added a commit that referenced this pull request May 11, 2026
… validation + sub-1% display

Resolves CI Lint failure and four Copilot review comments on PR #30.

1. src/shared/constants/providers.ts (CI blocker)
   - Add ProviderDefinition type
   - Annotate helper signatures: supportsApiKeyOnFreeProvider, isOpenAI/Anthropic/ClaudeCodeCompatibleProvider, getProviderByAlias, resolveProviderId, getProviderAlias
   - reduce<Record<string, string>> on ALIAS_TO_ID / ID_TO_ALIAS
   - Removes 9 implicit-any errors that block typecheck:noimplicit:core

2. open-sse/handlers/chatCore.ts (Copilot review #1 + #2)
   - Streaming translation hot path now reads settings via getCachedSettings()
     (5s TTL, invalidated on updateSettings) instead of a fresh SQLite read
     per request.
   - sseDiagnostics config is no longer a raw type-cast: merged with
     SSE_DIAGNOSTICS_DEFAULT and validated by sseDiagnosticsSettingsSchema
     before being passed to the stream factory. Falls back to defaults on
     malformed DB rows.

3. open-sse/utils/sseDiagnosticsBundle.ts (Copilot review #3)
   - tryCreateBundle() now defensively normalizes incoming numeric config
     (finite integer, in-range) before applying caps. NaN size cap,
     negative keepLastN, non-integer maxActive, and non-boolean truthy
     capture flags all produce null instead of silently disabling caps.

4. src/app/(dashboard)/dashboard/usage/components/ProviderLimits/RoutingBadge.tsx (Copilot review #4)
   - New formatPct helper renders 0 < value < 1 as '<1' instead of rounding
     to '0', preventing visual collision with the hard-excluded <=0% case
     in both the badge percent display and the near-depletion tooltip.

Tests:
- 2 new bundle-validation cases (malformed numeric + non-boolean coerce)
- 1 new formatPct test (sub-1%, NaN, integers)
- existing maxDebugBundleSizeMB-enforcement test rewritten to use 1MB cap
  + oversized line (the old 0MB cap is now correctly rejected by
  normalizeBundleConfig)

Verification:
- typecheck:noimplicit:core: clean (was failing with 9 errors)
- typecheck:core: clean
- npm test: 3015/3015 pass (PR baseline 3011 + 4 new)
- npm run lint: 0 errors, 75 baseline warnings (unchanged)

Out of scope: the two Unit Tests (22) failures (chat-context-relay /
sse-auth) on previous CI runs were ENOTEMPTY directory-cleanup races in
resetStorage(), unrelated to this PR. Local 'npm test' passes cleanly.
i1hwan added a commit that referenced this pull request May 11, 2026
…stics, LowQuota bypass (#30)

* feat(settings): add Claude Compatibility tab skeleton (step 1/7)

Step 1 of the Claude Compatibility tab work — UI-only skeleton, no
behavior change. Default routing/tool-arg/streaming paths byte-identical
to pre-PR.

What this adds
- New 'Compatibility' tab in /dashboard/settings between Resilience and
  Advanced, registered in tabs array with material-symbol icon 'tune'.
- CompatibilityTab.tsx mounting 4 subsection cards in a vertical column:
  - ToolArgumentModeSection — default mode radio + per-provider/per-lane
    override tables. Local React state only; persistence wired in step 2.
  - LowQuotaBypassSection — default policy radio + per-provider/per-lane
    override tables. Local React state only.
  - SSEDiagnosticsSection — 3 capture checkboxes + 3 numeric retention
    inputs + Clear button (disabled until step 4).
  - TerminalRecoverySection — UI-only placeholder per plan §13. Marked
    'Coming soon'; no settings key, no API route.
- Shared OverrideTable<V> generic component used by both ToolArgs and
  LowQuotaBypass sections: editable rows with key dropdown sourced from
  USAGE_SUPPORTED_PROVIDERS (or claude-oauth-prefixed lane), value
  dropdown, remove button, '+ Add override' control. Duplicate keys
  prevented by remainingKeys filter.
- 39 new settings.* i18n keys in en.json + ko.json (hand-translated).
  Other 30 locales fall back to English per the PR #29 pattern.
- compatibility-tab-skeleton.test.mjs — verifies tab registration in
  page.tsx, 4 subsection mounts, and i18n key presence in both locales.

Plan references
- .sisyphus/plans/tool-arg-buffered-final-mode.md (rev3.2.2) §14 step 1
- reviewer final approval (rev3.2.1) — no scope expansion vs. plan

Verification
- prettier --write: all 10 touched paths unchanged after format
- npm run lint on touched paths: 0 errors
- npm run typecheck:core: clean
- node --import tsx/esm --test on the new + 5 related regression suites:
  49/49 pass

Files
  M src/app/(dashboard)/dashboard/settings/page.tsx
  A src/app/(dashboard)/dashboard/settings/components/CompatibilityTab.tsx
  A .../CompatibilityTab/ToolArgumentModeSection.tsx
  A .../CompatibilityTab/LowQuotaBypassSection.tsx
  A .../CompatibilityTab/SSEDiagnosticsSection.tsx
  A .../CompatibilityTab/TerminalRecoverySection.tsx
  A .../CompatibilityTab/OverrideTable.tsx
  M src/i18n/messages/en.json (+39 keys)
  M src/i18n/messages/ko.json (+39 keys)
  A tests/unit/compatibility-tab-skeleton.test.mjs (6 cases)

* feat(settings): wire Claude Compatibility settings layer (step 2/7)

Step 2 of the Claude Compatibility tab work — backend settings layer +
WebUI API connection. Routing/streaming behavior still byte-identical
to pre-PR; only operator preferences are now persisted.

What this adds
- Migration 021_compatibility_settings.sql with 3 settings keys
  (INSERT OR IGNORE — safe on re-run / existing installs):
    toolArgumentMode   = {default:'stream-normalized', byProvider:{}, byLane:{}}
    lowQuotaBypass     = {default:false, byProvider:{}, byLane:{}}
    sseDiagnostics     = {captureProviderRawSSELines:false, ...,
                          keepLastNDebugRequests:20, maxDebugBundleSizeMB:100,
                          maxActiveDebugBundles:5}
  Per plan rev3.2 fix #5, no terminalRecovery key — UI placeholder only.
- Zod schemas in src/shared/validation/settingsSchemas.ts with canonical
  provider-id whitelist (USAGE_SUPPORTED_PROVIDERS) per plan rev3.1 fix #4:
    toolArgumentModeSettingsSchema / lowQuotaBypassSettingsSchema /
    sseDiagnosticsSettingsSchema, plus matching DEFAULT exports.
- src/lib/db/settings.ts getSettings() now seeds the 3 new keys with
  imported defaults so older databases (pre-021 migration) fall back
  consistently.
- 4 API routes:
    GET/PUT /api/settings/tool-argument-mode
    GET/PUT /api/settings/low-quota-bypass
    GET/PUT /api/settings/sse-diagnostics
    POST    /api/settings/sse-diagnostics/clear
  All PUT handlers validate via validateBody(zod) and persist via
  updateSettings. The clear handler removes files from
  getSseDiagnosticsDir() — ENOENT is treated as success (idempotent).
- src/lib/logEnv.ts exports resolveDefaultDataDir + getSseDiagnosticsDir
  (shared between the clear route and the future step 4 capture write
  path so DATA_DIR resolution stays in one place).
- Subsection components now hydrate via GET on mount and persist via
  PUT on every change (optimistic update with rollback on failure,
  matching RoutingTab.tsx pattern). Local-state placeholders from
  step 1 are gone.
- compat-settings-api.test.mjs — static-analysis tests verifying
  migration shape, schema exports, route exports, validateBody usage,
  clear-route data-dir resolution, and section endpoint wiring.

Plan references
- .sisyphus/plans/tool-arg-buffered-final-mode.md (rev3.2.2) §14 step 2
- reviewer final approval (rev3.2.1) — no scope expansion vs. plan
- Verification commands deferred to a single end-of-PR pass per
  user policy (no per-step npm test / typecheck runs).

Files
  A src/lib/db/migrations/021_compatibility_settings.sql
  M src/shared/validation/settingsSchemas.ts (+3 schemas, +3 defaults)
  M src/lib/db/settings.ts (+3 default keys)
  M src/lib/logEnv.ts (export 2 path helpers)
  A src/app/api/settings/tool-argument-mode/route.ts
  A src/app/api/settings/low-quota-bypass/route.ts
  A src/app/api/settings/sse-diagnostics/route.ts
  A src/app/api/settings/sse-diagnostics/clear/route.ts
  M src/app/(dashboard)/.../CompatibilityTab/ToolArgumentModeSection.tsx
  M src/app/(dashboard)/.../CompatibilityTab/LowQuotaBypassSection.tsx
  M src/app/(dashboard)/.../CompatibilityTab/SSEDiagnosticsSection.tsx
  A tests/unit/compat-settings-api.test.mjs

* feat(translator): add buffered-final tool argument mode (step 3/7)

Step 3 of the Claude Compatibility tab work — implements the
buffered-final tool argument streaming mode at the Claude→OpenAI
translator layer. Default behavior (stream-normalized) byte-identical
to pre-PR; new mode kicks in only when the operator opts in via
WebUI (step 2 already wired).

What this adds
- open-sse/translator/helpers/toolArgumentMode.ts:
  - resolveToolArgumentMode(settings, provider, forwardingLane) →
    'stream-normalized' | 'buffered-final'
  - Precedence: byLane > byProvider > default > 'stream-normalized'
  - Resolver is total (never throws); malformed/missing → safe default
  - Whitelisted mode values via VALID_MODES set — unknown strings reject
- open-sse/translator/response/claude-to-openai.ts (3 case branches):
  - content_block_start (tool_use): buffered-final defers the placeholder
    chunk emit and skips normalizer init; stream-normalized unchanged.
  - content_block_delta (input_json_delta): buffered-final concatenates
    raw partial_json onto state.toolCalls[idx].function.arguments
    without emitting; stream-normalized unchanged (still routes through
    jsonUnicodeNormalizer).
  - content_block_stop (tool_use slot): buffered-final emits exactly
    one chunk with id + type + name + the entire accumulated arguments;
    stream-normalized continues to flush the normalizer tail.
- open-sse/handlers/chatCore.ts:
  - applyClaudeOAuthLexicalRewrite call sites (L1027 + L1072) now also
    stash _forwardingLane = 'claude-oauth-prefixed' alongside _toolNameMap
    (reviewer rev3.1 fix #1 — explicit lane marker, never inferred from
    toolNameMap presence).
  - Extraction block at L1234 also unstashes _forwardingLane and forwards
    it to the stream factory.
  - On the translate path, resolveToolArgumentMode is called once per
    request via dynamic import of @/lib/localDb (same cross-package
    pattern used by volumeDetector + rateLimitManager) — settings read
    is cached so cost is minimal.
- open-sse/utils/stream.ts:
  - StreamOptions and TranslateState extended with toolArgumentMode +
    forwardingLane fields.
  - State object construction propagates both into translator scope.
  - createSSETransformStreamWithLogger wrapper accepts two new positional
    args (defaults preserve old behavior; non-translate callers untouched).
- tests/unit/translator-resp-claude-to-openai-buffered-final.test.mjs:
  - 10 cases covering §7.2.1-7.2.8 from plan rev3.2:
    - single chunk emit at stop with full args
    - stream-normalized regression guard (mode absent / default)
    - multiple tool_use blocks independent
    - mid-escape stop preserves verbatim partial buffer
    - mixed text+tool flow
    - no internal-state key leak
    - T_FINAL < T_FINISH in both modes (reviewer rev3.1 fix #3)
    - client-style accumulator parity buffered vs normalized
      (reviewer rev3.1 fix #4 carry-over)
    - resolver precedence (lane > provider > default + malformed → safe)

Plan references
- .sisyphus/plans/tool-arg-buffered-final-mode.md (rev3.2.2) §5.2 + §5B
- reviewer final approval (rev3.2.1)
- Verification commands deferred to a single end-of-PR pass per
  user policy (no per-step npm test / typecheck runs).

Files
  A open-sse/translator/helpers/toolArgumentMode.ts
  M open-sse/translator/response/claude-to-openai.ts (mode branch in 3 cases)
  M open-sse/utils/stream.ts (options/state + wrapper)
  M open-sse/handlers/chatCore.ts (_forwardingLane stash + extract + resolve)
  A tests/unit/translator-resp-claude-to-openai-buffered-final.test.mjs (10 cases)

* feat(stream): add SSE diagnostics capture (step 4/7)

Step 4 of the Claude Compatibility tab work — captures provider SSE
streams into per-request debug bundles so the next incident yields
the wire-level evidence we currently lack. All capture toggles default
OFF; zero overhead when disabled.

What this adds
- open-sse/utils/sseDiagnosticsBundle.ts:
  - tryCreateBundle: returns null when no capture toggle is on OR when
    maxActiveDebugBundles concurrent cap is reached.
  - appendRawLine / appendParsedEvent / appendTranslatedChunk: gated by
    per-capture booleans; overflow guard short-circuits further appends
    when bundle._bytes would exceed maxDebugBundleSizeMB.
  - finalizeBundle: writes <DATA_DIR>/logs/sse-diagnostics/<TS>_<UUID>.json,
    then prunes to keepLastNDebugRequests by mtime. Termination marker
    is one of 'flush' | 'upstream_error' | 'client_abort' with optional
    detail string.
  - Module-level activeBundleCount + _testOnlyResetActiveCount helper
    for the test harness.
- open-sse/utils/stream.ts:
  - StreamOptions extended with sseDiagnosticsConfig.
  - createSSEStream wires capture hooks at 3 points:
    - raw line: in the line-split loop, before parseSSELine
    - parsed event: after parseSSELine succeeds, in translate path
    - translated chunk: after formatSSE + sanitize, before
      controller.enqueue
  - finalizeBundle called in 3 termination paths:
    - flush()'s finally block (normal completion or streamTimedOut)
    - upstream-error controller.error() in the translate fast-fail block
    - idle-timeout controller.error()
  - createSSETransformStreamWithLogger wrapper accepts 13th positional
    arg (sseDiagnosticsConfig).
- open-sse/handlers/chatCore.ts:
  - The existing single getSettings() read on the translate path now
    extracts sseDiagnostics alongside toolArgumentMode and forwards the
    config to createSSETransformStreamWithLogger.
- tests/unit/sse-diagnostics-capture.test.mjs:
  - 11 cases covering plan §5.3 + §7.3:
    - toggles OFF → no bundle file written
    - each capture type independently stores its data
    - parsed events stored without truncation
    - keepLastNDebugRequests prunes oldest by mtime
    - maxDebugBundleSizeMB overflow guard sets _capture_overflow
    - maxActiveDebugBundles concurrent cap (reviewer rev3.1 fix #7)
    - upstream_error termination marker + detail preserved
    - client_abort termination marker
    - null-bundle safety (all append* are no-ops)
    - clear API route deletes all bundle files

Plan references
- .sisyphus/plans/tool-arg-buffered-final-mode.md (rev3.2.2) §5.3
- reviewer final approval (rev3.2.1) — diagnostic boundary + abort path
  + concurrent cap all reflected.
- Verification commands deferred to a single end-of-PR pass per
  user policy (no per-step npm test / typecheck runs).

Files
  A open-sse/utils/sseDiagnosticsBundle.ts (~190 lines)
  M open-sse/utils/stream.ts (3 capture hooks + 3 termination paths + wrapper)
  M open-sse/handlers/chatCore.ts (forward sseDiagnostics config)
  A tests/unit/sse-diagnostics-capture.test.mjs (11 cases)

* feat(routing): add LowQuota bypass + Q<=0 hard-exclude (step 5/7)

Step 5 of the Claude Compatibility tab work — implements operator-toggleable
LowQuota routing bypass at the earliestResetFirst strategy. Bypass scope is
provider-only (rev3.3 page directive); the Q<=0 case is hard-excluded
regardless of bypass (page rev3.2 blocker #2). Default behavior
byte-identical to pre-PR.

Two-tier exclusion guard
- Tier 1 (rev3.2): Q<=0 → excluded with reason 'session<=0%' / 'weekly<=0%'.
  Hard-excluded regardless of bypass — Q=0 means actually exhausted, NOT
  'low quota'. Aligns with quotaCache.ts:65 isExhausted semantics.
  UI maps the new reasons to existing 'routingPriorityExcludedExhausted'
  label.
- Tier 2 (existing): 0<Q<5 → excluded with reason 'session<5%' / 'weekly<5%'
  unless lowQuotaBypass=true. When bypass is on, score falls through to
  pressure() with bypassMinUsable:true so 1%, 3%, 4.99% all produce
  distinct finite scores.

What this adds (page directive in rev3.3)
- open-sse/services/routing/lowQuotaBypass.ts:
  - resolveLowQuotaBypass(settings, provider) → boolean.
  - NO byLane parameter. auth.ts:auth() runs BEFORE chatCore +
    applyClaudeOAuthLexicalRewrite, so forwardingLane is not known at
    connection select time. byLane on LowQuota would persist to storage
    with no runtime effect — exactly the UX trap rev2.1 warned about.
    'byProvider:{claude:true}' covers every Claude connection (OAuth +
    API-key) which matches the operator goal '5% 미만이어도 계속 써라'.
  - toolArgumentMode.byLane is unaffected — its consumer (stream factory)
    IS lane-aware, so its byLane stays.

- src/sse/services/strategies/earliestResetFirst.ts:
  - pressure() gains optional PressureOptions { bypassMinUsable } so the
    Q<5 guard can be skipped without changing the default contract.
  - scoreSessionTrack(connId, lowQuotaBypass=false) — Tier 1 Q<=0 guard
    + Tier 2 Q<5 guard gated by bypass.
  - scoreWeeklyTrack(connId, lowQuotaBypass=false) — same.
  - scoreAccount(conn, lowQuotaBypass=false) — forwards to both tracks.
  - isAffinityValid(conn, sessionId, scored, provider, lowQuotaBypass=false)
    — same bypass decision the per-attempt scoring uses.
  - selectByEarliestResetFirst(candidates, sessionId, provider,
    lowQuotaBypass=false) — single boolean (NOT a Map<connId, bool>) since
    auth.ts always passes provider-filtered orderedConnections.

- src/sse/services/auth.ts (auth() at L709):
  - Resolves bypass once via resolveLowQuotaBypass(settings.lowQuotaBypass,
    provider) using the existing getSettings() read at L570 and forwards
    to selectByEarliestResetFirst.

- src/app/api/usage/provider-limits/route.ts (dashboard preview):
  - buildResponseBody resolves bypass per connection (Map<connId, bool>)
    because computeRouting mixes providers across the response map.
    computeRouting forwards each connection's resolved bypass into
    scoreAccount.

- src/shared/validation/settingsSchemas.ts:
  - lowQuotaBypassSettingsSchema is now z.strict() with { default, byProvider }
    only. PUT with a byLane field returns 400.
  - LOW_QUOTA_BYPASS_DEFAULT drops byLane.

- src/lib/db/migrations/021_compatibility_settings.sql:
  - lowQuotaBypass default value is '{"default":false,"byProvider":{}}'
    (no byLane field).

- src/app/(dashboard)/dashboard/settings/components/CompatibilityTab/
  LowQuotaBypassSection.tsx:
  - Per-lane override table REMOVED.
  - New hint text under per-provider table explains why lane scope is not
    applicable and instructs operator to use provider='claude'=true to
    cover the OAuth lane.

- src/i18n/messages/en.json + ko.json:
  - New key compatibilityLowQuotaProviderScopeOnlyHint with the explanation
    above (hand-translated en/ko).

Tests added
- tests/unit/low-quota-bypass-resolver.test.mjs (9 cases):
  - empty / null / undefined → false
  - default + byProvider precedence
  - per-provider override beats default both directions
  - provider=null → only default applies
  - malformed settings → safe fallback
  - rev3.3 — resolver signature is 2-arg (no byLane param)

- tests/unit/score-track-low-quota-bypass.test.mjs (10 cases):
  - pressure() default vs bypassMinUsable opts
  - pressure() Q=0 with bypass returns 0 (finite, lowest)
  - pressure() preserves ordering (1% < 3% < 4.99%)
  - scoreSessionTrack Q=3 bypass=false → excluded session<5%
  - scoreSessionTrack Q=3 bypass=true → known finite
  - scoreSessionTrack Q=0 → excluded session<=0% REGARDLESS of bypass
  - scoreWeeklyTrack Q=0 → excluded weekly<=0% REGARDLESS of bypass
  - scoreSessionTrack signature accepts optional bypass param

- tests/unit/compat-settings-api.test.mjs:
  - New case: lowQuotaBypassSettingsSchema PUT with byLane field rejects 400.

- tests/unit/compatibility-tab-skeleton.test.mjs:
  - New i18n key compatibilityLowQuotaProviderScopeOnlyHint added to
    REQUIRED_KEYS so en/ko coverage is enforced.

Plan references
- .sisyphus/plans/tool-arg-buffered-final-mode.md (rev3.3) §5B
- page rev3.3 mid-step-5 directive — option B (remove byLane on LowQuota)
- Verification commands deferred to a single end-of-PR pass per user policy.

Files
  A open-sse/services/routing/lowQuotaBypass.ts (~17 lines)
  M src/sse/services/strategies/earliestResetFirst.ts (Q<=0 + bypass plumbing)
  M src/sse/services/auth.ts (resolveLowQuotaBypass once per request)
  M src/app/api/usage/provider-limits/route.ts (dashboard map builder)
  M src/shared/validation/settingsSchemas.ts (strict schema, no byLane)
  M src/lib/db/migrations/021_compatibility_settings.sql (default shape)
  M src/app/(dashboard)/.../LowQuotaBypassSection.tsx (no lane override)
  M src/i18n/messages/en.json (+1 hint key)
  M src/i18n/messages/ko.json (+1 hint key)
  M tests/unit/compatibility-tab-skeleton.test.mjs
  M tests/unit/compat-settings-api.test.mjs
  A tests/unit/low-quota-bypass-resolver.test.mjs
  A tests/unit/score-track-low-quota-bypass.test.mjs

* feat(ui): RoutingBadge composite + Compatibility deep-link (step 6/7)

Step 6 of the Claude Compatibility tab work — UI polish. Adds the
'Rank N · LowQuota' composite badge for near-depletion accounts whose
LowQuota bypass is active, the matching near-depletion subtitle in the
RoutingBadge tooltip, and a cross-link from ProviderLimits to the new
Compatibility settings tab.

What this adds
- src/app/(dashboard)/.../ProviderLimits/RoutingBadge.tsx:
  - getExcludedI18nKey: added 'session<=0%' and 'weekly<=0%' cases
    (mapped to existing 'routingPriorityExcludedExhausted' label per
    plan rev3.2 — Q<=0 is functionally Exhausted from the UI side).
  - New nearDepletion derived: entry.excluded === false AND
    min(sessionRemainingPct, weeklyRemainingPct) > 0 AND < 5. This
    matches the bypass-active runtime state (LowQuota bypass kept the
    candidate in the routing pool; UI now surfaces the warning).
  - Composite label: 'Rank N · LowQuota' via new i18n key
    routingPriorityRankLowQuota. Falls back to plain 'Rank N' /
    'Next' when nearDepletion is false.
  - variant flips from 'default' to 'warning' (amber styling) when
    nearDepletion is true.
  - Tooltip: appends a near-depletion subtitle with both session/weekly
    percentages, separated from the breakdown rows by a faint divider.

- src/app/(dashboard)/.../ProviderLimits/index.tsx:
  - Header toolbar (right-side action group) gains a Compatibility
    deep-link button that goes to /dashboard/settings?tab=compatibility.
    Sits between 'Refresh All' and AutoRefreshControl, matching the
    visual weight of the existing buttons.

- src/i18n/messages/en.json + ko.json (usage namespace):
  - compatibilitySettingsLink           — button label
  - compatibilitySettingsLinkTooltip    — title= tooltip
  - routingPriorityRankLowQuota         — '{n} · LowQuota'
  - routingPriorityNearDepletionTooltip — '⚠ Near depletion: session={s}%, weekly={w}% (bypass active)'

Tests added
- tests/unit/routing-badge-composite.test.mjs (8 cases):
  - session<=0% / weekly<=0% map to existing Exhausted label
  - session<5% / weekly<5% mapping unchanged (regression guard)
  - nearDepletion branch + i18n keys present in source
  - condition uses min(s,w) < 5 with positive floor (rev3.3 §5B.4)
  - variant flips to 'warning' on nearDepletion
  - ProviderLimits/index has the Compatibility deep-link

- tests/unit/compatibility-tab-skeleton.test.mjs:
  - USAGE_NS_KEYS array + 2 new assertions confirming en/ko have the
    4 new usage namespace keys (deep-link + composite badge i18n).

Plan references
- .sisyphus/plans/tool-arg-buffered-final-mode.md (rev3.3) §4A.5 + §5B.4
- reviewer final approval (rev3.2.1) — composite badge policy locked in
- Verification commands deferred to a single end-of-PR pass per user policy.

Files
  M src/app/(dashboard)/.../ProviderLimits/RoutingBadge.tsx
  M src/app/(dashboard)/.../ProviderLimits/index.tsx (Compatibility link)
  M src/i18n/messages/en.json (+4 usage keys)
  M src/i18n/messages/ko.json (+4 usage keys)
  M tests/unit/compatibility-tab-skeleton.test.mjs
  A tests/unit/routing-badge-composite.test.mjs

* fix(tests,schemas,diagnostics): verification-pass cleanup (step 7/7)

Step 7 of the Claude Compatibility tab work — final verification pass
plus reviewer-driven hardening pulled into the same commit.

Verification fixes (3008/3008 → 3009/3009 pass)

1) settingsSchemas.ts — providerOverrideRecord / laneOverrideRecord
   were using z.record(z.enum([...]), ...) which under Zod v4 requires
   *every* enum value to appear in the record (an empty {} is rejected
   as 'expected boolean, received undefined' for each missing key).
   Switched to z.record(z.string(), valueSchema).refine(...) with an
   explicit whitelist check + a Zod v4 compatible refine() options
   shape ({ message: '...' }). Existing tests for valid-keys + strict()
   unknown field rejection continue to pass.

2) translator-resp-claude-to-openai-buffered-final.test.mjs — the
   §7.2.8 client-style accumulator parity test was reading the first
   emitted tool_calls chunk for the stream-normalized mode by reference;
   subsequent input_json_delta events mutated the same object via
   state.toolCalls.get(idx).function.arguments += delta. By the time
   the test ran, every captured chunk reflected the final-state
   arguments, not the emit-time snapshot the OpenAI client would
   actually see on the wire. Added a feedAndSnapshot() wrapper that
   JSON.stringify+parse-clones each feed result immediately so the
   parity comparison sees honest emit-time captures.

Reviewer-driven hardening (post-self-verify)

3) sseDiagnosticsBundle.finalizeBundle now sets bundle._finalized=true
   on first call and short-circuits any subsequent call. Defends
   against the (currently unreachable but theoretically possible)
   double-finalize path where flush() runs after controller.error()
   already fired the bundle write. Added a regression test that
   finalizes the same bundle twice with different termination
   reasons and asserts (a) only one bundle file is written, (b)
   activeBundleCount returns to 0 exactly once, (c) the first
   termination marker is preserved.

4) sseDiagnosticsBundle.approxByteLen now delegates to
   Buffer.byteLength(value, 'utf8') instead of estimating per-char
   UTF-8 size manually. Same result for ASCII/BMP/3-byte CJK; lifts
   the maxDebugBundleSizeMB cap to the actual disk byte cost without
   any per-call overhead worth mentioning.

Verification (full end-of-PR sweep — after all 4 fixes)
- npm run test:unit                → 3009/3009 pass (+1 idempotency)
- npm run typecheck:core           → clean
- npm run lint                     → 0 errors, 75 baseline warnings
                                     (identical to pre-PR baseline)
- prettier --write on changed files → unchanged after format
- LSP diagnostics on changed files  → clean
- docs:sync                         → script not present in this repo;
                                      no version bump emitted here.
                                      Operator may bump 3.8.8 → 3.9.0
                                      at PR merge time if desired.

Files
  M src/shared/validation/settingsSchemas.ts
  M tests/unit/translator-resp-claude-to-openai-buffered-final.test.mjs
  M open-sse/utils/sseDiagnosticsBundle.ts
  M tests/unit/sse-diagnostics-capture.test.mjs

* fix(diagnostics): connect client_abort path + flush remaining buffer capture

Addresses two review change requests on PR #30 SSE diagnostics.

Issue 1 — client_abort was not actually wired to the stream cancel path

Before this commit, finalizeBundle(..., 'client_abort') only fired when
helper code called it directly (e.g. tests). The real client-disconnect
flow is:
  - createDisconnectAwareStream.cancel(reason)
  - → streamController.handleDisconnect()
  - → reader.cancel()
  - → writer.abort()
The diagnostics bundle lived in a closure inside createSSEStream() that
this cancel path could not reach. reader.cancel() also short-circuits
TransformStream.flush(), so even the existing flush-side finalize never
ran on a real client disconnect — losing every diagnostics bundle for
the exact incident class the feature was designed to capture.

Fix: a module-level WeakMap<TransformStream, CaptureBundle> registry.
createSSEStream registers its bundle against the transformStream object
it returns. createDisconnectAwareStream looks up the bundle on cancel
and calls finalizeBundle(bundle, 'client_abort', reason). The
bundle._finalized idempotency guard from step 7 ensures a subsequent
flush-side call (which won't actually run, but is defended against
anyway) is a no-op. The WeakMap entry is garbage-collected when the
TransformStream is — no manual cleanup.

Issue 2 — flush() remaining buffer was missing the 3 capture hooks

The transform() loop captures raw lines / parsed events / translated
chunks. The flush() handler reprocesses any remaining buffered text
(provider responses without trailing \n) through parseSSELine and
translateResponse, but it bypassed the 3 capture hooks. A provider
that emits its final usage-bearing event (message_delta, response.completed,
etc.) without a trailing newline would land that event in
provider_response summary but not in the diagnostics bundle — losing
exactly the last event whose evidence the operator most needs.

Fix: added the matching appendRawLine / appendParsedEvent /
appendTranslatedChunk calls inside the flush() translate-mode
remaining-buffer block, mirroring the transform() loop hooks.

Tests
- client_abort path integration test: registers a bundle on a real
  TransformStream, routes through createDisconnectAwareStream, cancels
  the readable, and asserts a bundle file lands with
  metadata.termination === 'client_abort' and metadata.terminationDetail
  carrying the cancel reason.
- flush() remaining buffer static check: greps the translate-mode
  remaining-buffer block in stream.ts and asserts all three append*
  hooks are present.

Verification
- npm run test:unit                → 3011 / 3011 pass (+2 cases)
- npm run typecheck:core           → clean
- npm run lint                     → 0 errors (75 baseline warnings)
- prettier --write on changed files → no diff after format
- LSP diagnostics on changed files  → clean

* fix(diagnostics): bridge bundle registration across pipeWithDisconnect wrapper

createSSEStream() registers the bundle on the original TransformStream, but
pipeWithDisconnect() pipes through that stream and hands a wrapper object to
createDisconnectAwareStream(), which calls lookupBundle(wrapper). The keys
do not match and client_abort finalize silently no-ops in production.

Fix: lookup the bundle on the original transformStream inside
pipeWithDisconnect() and re-register it against the wrapper before passing
it down.

Test: new regression test exercises the full pipeWithDisconnect() path
(not just createDisconnectAwareStream() in isolation) and uses bounded
polling for finalize landing on disk to avoid flake.

Per reviewer change request on PR #30.

* fix(ci+review): noImplicitAny + hot-path settings cache + diagnostics validation + sub-1% display

Resolves CI Lint failure and four Copilot review comments on PR #30.

1. src/shared/constants/providers.ts (CI blocker)
   - Add ProviderDefinition type
   - Annotate helper signatures: supportsApiKeyOnFreeProvider, isOpenAI/Anthropic/ClaudeCodeCompatibleProvider, getProviderByAlias, resolveProviderId, getProviderAlias
   - reduce<Record<string, string>> on ALIAS_TO_ID / ID_TO_ALIAS
   - Removes 9 implicit-any errors that block typecheck:noimplicit:core

2. open-sse/handlers/chatCore.ts (Copilot review #1 + #2)
   - Streaming translation hot path now reads settings via getCachedSettings()
     (5s TTL, invalidated on updateSettings) instead of a fresh SQLite read
     per request.
   - sseDiagnostics config is no longer a raw type-cast: merged with
     SSE_DIAGNOSTICS_DEFAULT and validated by sseDiagnosticsSettingsSchema
     before being passed to the stream factory. Falls back to defaults on
     malformed DB rows.

3. open-sse/utils/sseDiagnosticsBundle.ts (Copilot review #3)
   - tryCreateBundle() now defensively normalizes incoming numeric config
     (finite integer, in-range) before applying caps. NaN size cap,
     negative keepLastN, non-integer maxActive, and non-boolean truthy
     capture flags all produce null instead of silently disabling caps.

4. src/app/(dashboard)/dashboard/usage/components/ProviderLimits/RoutingBadge.tsx (Copilot review #4)
   - New formatPct helper renders 0 < value < 1 as '<1' instead of rounding
     to '0', preventing visual collision with the hard-excluded <=0% case
     in both the badge percent display and the near-depletion tooltip.

Tests:
- 2 new bundle-validation cases (malformed numeric + non-boolean coerce)
- 1 new formatPct test (sub-1%, NaN, integers)
- existing maxDebugBundleSizeMB-enforcement test rewritten to use 1MB cap
  + oversized line (the old 0MB cap is now correctly rejected by
  normalizeBundleConfig)

Verification:
- typecheck:noimplicit:core: clean (was failing with 9 errors)
- typecheck:core: clean
- npm test: 3015/3015 pass (PR baseline 3011 + 4 new)
- npm run lint: 0 errors, 75 baseline warnings (unchanged)

Out of scope: the two Unit Tests (22) failures (chat-context-relay /
sse-auth) on previous CI runs were ENOTEMPTY directory-cleanup races in
resetStorage(), unrelated to this PR. Local 'npm test' passes cleanly.

* fix(security+docs): forwardingLane trust boundary + pressure() doc

Addresses both Copilot review comments on the second pass of PR #30.

1. open-sse/handlers/chatCore.ts — forwardingLane trust boundary
   The previous code read forwardingLane directly from translatedBody._forwardingLane
   whenever it was a string. Because translatedBody is derived from the client
   request (often via { ...body }), a client could inject
   '_forwardingLane: "claude-oauth-prefixed"' to spoof lane-scoped routing
   (e.g. toolArgumentMode byLane overrides) without the Claude OAuth lexical
   rewrite ever running.

   Three defenses, defense-in-depth:
   a) stripInternalMarkers(body) at function entry — drops _forwardingLane,
      _toolNameMap, _disableToolPrefix, _nativeCodexPassthrough from the
      incoming client body before any translation branch runs. Every branch
      now sees a sanitized body, regardless of which path is taken.
   b) Lane is only honored when translatedToolNameMap instanceof Map &&
      size > 0. Maps survive structuredClone but cannot be JSON-injected by
      a client, so this proves the value came from
      applyClaudeOAuthLexicalRewrite, not the client.
   c) isValidForwardingLane() whitelist (new export on toolArgumentMode.ts)
      blocks unknown lane names — future lane additions must explicitly
      register, no silent acceptance.

2. src/sse/services/strategies/earliestResetFirst.ts — pressure() doc
   The doc comment unconditionally said 'Q < MIN_USABLE_REMAINING_PCT returns
   -Infinity'. The new PressureOptions.bypassMinUsable flag explicitly allows
   finite scores for Q<5 (LowQuota bypass). Comment now distinguishes the two
   cases so callers don't misinterpret the contract.

Tests (4 new):
- tests/unit/chat-core-forwarding-lane-trust-boundary.test.mjs (3 cases):
  * stripInternalMarkers helper deletes all 4 client-controllable fields
  * strip happens before the translation if-chain (every branch sees sanitized body)
  * forwardingLane requires both instanceof Map gate AND lane whitelist
- tests/unit/translator-resp-claude-to-openai-buffered-final.test.mjs:
  * isValidForwardingLane whitelist (8 cases: valid, casing, empty, null,
    undefined, number, object with toString)

Verification:
- typecheck:noimplicit:core: clean
- typecheck:core: clean
- npm test: 3019/3019 pass (PR baseline 3015 + 4 new)
- npm run lint: 0 errors, 75 baseline warnings (unchanged)
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