Files
OmniRoute/tests/unit/claude-low-priority-executor.test.ts
Davide Baraldo 1b2349de22 feat(sse): Claude OAuth lower-priority lane + weekly session-limit reset (#13074)
* feat(sse): Claude OAuth lower-priority lane + weekly session-limit reset

Mirror Claude Code's /low-priority and /limit-reset for OmniRoute-managed
Claude subscription accounts (wire contract captured from Claude Code 2.1.263).

Both are opt-in per connection (providerSpecificData.lowPriorityMode /
autoLimitReset, Edit connection -> Claude section, default off) and only act
on the 5-hour usage wall: a 429 carrying
anthropic-ratelimit-unified-status: rejected and, when eligible,
anthropic-ratelimit-unified-slow-offer: treatment. Nothing is sent before
that first wall 429.

- Lower-priority lane: on the wall the executor retries the SAME account
  with `anthropic-usage-limit: slow` and keeps the header on every request
  until anthropic-ratelimit-unified-reset (+60s). The intercepted 429 never
  reaches chatCore, so the connection is not cooled down or rotated away.
  slot_busy (429) / 529 wait slow-retry-after (20s default, 5-600s, +-30%
  jitter) bounded by slow-max-wait (20min default, 1min-6h), then end +
  10min cool-off. weekly_limit / budget_exhausted / off / ineligible, a
  5h-window rollover, or ineligible + overage-in-use end the lane and let
  the response flow to the normal cooldown path.
- Session-limit reset: GET /api/oauth/usage?at_wall=1&skip_spend=1 ->
  juniper_tide block; when arm=reset and available, POST
  /api/organizations/{org}/reset_rate_limits {program: "juniper_tide"} and
  retry at full speed. already_used / not offered memoise next_available_at.
- State is in-memory per connection; the executor owns the abort-aware
  sleep; the pure state machine and the HTTP client are separate modules
  with unit tests; an executor-level test proves the header/retry wiring
  end to end with a mocked upstream.

* fix(sse): make the Claude usage-wall handling race-safe for parallel requests

Two requests on the same Claude OAuth connection can hit the 5-hour wall in
the same instant.

- Lower-priority lane: the executor now tells the decider whether THIS
  request carried `anthropic-usage-limit: slow`. A sibling built while the
  lane was still idle (no header) whose 429 lands after the lane activated
  is re-sent on the lane instead of being misread as a "wall" verdict that
  would end it; its 2xx is not counted as lane telemetry either.
- Session-limit reset: concurrent wall hits share one in-flight status+claim
  round trip (no duplicate POST reset_rate_limits), and for 60s after a
  granted reset stale sibling walls are answered "reset" without touching
  the network, so they retry at full speed instead of re-claiming or
  falling into the slow lane.

Tests cover both races.

* fix(sse): address adversarial review of the Claude usage-wall handling

Three defects found by a 3-lens review of the two previous commits.

1. Lane wait could outlive the request (high). The slot_busy/529 sleep shares
   the request's AbortSignal with chatCore's upstream-start timeout (10 min by
   default), while the lane's own max-wait defaults to 20 min and can reach 6h
   from the server header. A long slot_busy streak was therefore killed
   mid-sleep with a TimeoutError instead of ending gracefully as max_wait with
   its cool-off. The decision now takes a waitCeilingMs — what is left of the
   executor's own timeout, minus a 5s margin — which caps the effective
   max-wait and clamps each individual sleep.

2. A wall 429 surfacing only after a 400-driven intra-attempt retry was missed
   (medium). The context-editing / thinking-budget / effort / auto-learn
   fallbacks all re-fetch and REASSIGN `response`, and the wall check ran
   before them, so such a 429 fell through to the generic path and cooled the
   connection down. The check now runs after those retries, on the final
   response of the attempt.

3. `ineligible` + `overage-in-use: true` ended the lane as plain `ineligible`
   on a 429 (medium) because the status mapping ran first; only the non-429
   tail produced `extra_usage`. Overage takeover now wins on every status.

Also bounds the module-level per-connection maps with the same FIFO policy as
the identity caches in claudeIdentity.ts: the state key falls back to the
access token when a connection id is absent, and OAuth tokens rotate on every
refresh, so the maps could grow for the process lifetime.

Tests cover all three fixes, including an executor-level regression for the
400-then-wall ordering.

* fix(i18n): add the Claude usage-wall toggle strings to pt-BR

`tests/unit/i18n-pt-br.test.ts` (#6695) requires pt-BR.json to carry every key
present in en.json; the four new `providers.claude{LowPriorityMode,AutoLimitReset}*`
keys were only added to en and it, so the gate failed on this branch.

* refactor(sse): keep the usage-wall change inside the frozen quality budgets

The three ratchets this PR tripped were all its own, not inherited:

- file-size (frozen, may only shrink): open-sse/executors/base.ts 1857 > 1751
  and EditConnectionModal.tsx 1653 > 1631.
- complexity / cognitive-complexity (new-code mode): three functions over the
  15 threshold — runClaudeLimitResetAttempt (27), handleClaudeUsageLimitResponse
  (19 / cognitive 23) and observeClaudeLowPriorityResponse (17 / 17).

Extractions, all behavior-preserving:

- New open-sse/executors/claudeUsageLimit.ts owns the executor-side glue (header
  injection, wait accounting, abort-aware sleep, timeout-derived wait ceiling and
  the decision logging) behind a ClaudeUsageLimitGuard, so base.ts keeps a
  three-line call site instead of ~100 lines of mechanics.
- Three long-standing Claude blocks leave base.ts for the modules they belong to:
  mergeCcHeaders + applyStainlessHeaders into config/anthropicHeaders.ts and
  stripClaudeSystemPrefixBlocks into executors/claudeIdentity.ts. base.ts is back
  at its frozen 1750 lines.
- The modal's Claude section becomes ClaudeConnectionFields.tsx (mirroring
  CcCompatibleRequestDefaultsFields) plus a claudeConnectionFields.ts helper that
  de-duplicates the field defaults across the modal's two init sites; the file
  drops to 1622, below its frozen 1631.
- The three over-threshold functions are split into focused helpers
  (observeErrorResponse / observeSuccessResponse, shouldClaimLimitReset,
  resolveLimitResetOffer / runLimitResetClaim / memoiseNotBefore).

Gates now: file-size OK, complexity 0 new violations, cognitive 0 new,
fetch-targets / error-helper / build-scope / deps OK, typecheck clean, ESLint 0,
Prettier clean, 155 unit tests green across the feature and its neighbours.

Still failing and NOT this branch's: pack-policy (unexpected
@omniroute/opencode-plugin-v2 files in the npm artifact) and
mutation-test-coverage (stryker tap.testFiles missing entries for
circuitBreaker.ts and comboStructure.ts) — both reproduce on the untouched base.

---------

Co-authored-by: davidebaraldo <davidebaraldo@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-18 13:13:41 -03:00

245 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* End-to-end wiring of the Claude OAuth lower-priority lane and the session-limit reset
* through `BaseExecutor.execute()` (mirrors the fetch-capture pattern of
* context-editing-executor-injection.test.ts).
*
* Proves, on the real outbound request:
* - no `anthropic-usage-limit` header before the account hits its 5-hour wall;
* - on the wall 429 (slow-offer: treatment) with `lowPriorityMode` on, the executor
* retries the SAME account with `anthropic-usage-limit: slow` and returns the 200 —
* chatCore never sees the 429, so the connection is not cooled down;
* - later requests on that connection carry the header from the first attempt;
* - an opt-out connection surfaces the 429 untouched, header never sent;
* - with `autoLimitReset` on, the wall triggers status + claim and retries at full speed.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
import { _resetClaudeLowPriorityState } from "../../open-sse/services/claudeLowPriority.ts";
import {
CLAUDE_LIMIT_RESET_STATUS_URL,
_resetClaudeLimitResetMemo,
} from "../../open-sse/services/claudeLimitReset.ts";
type Captured = { url: string; headers: Record<string, string> };
const NOW_S = Math.floor(Date.now() / 1000);
function wall429(extra: Record<string, string> = {}): Response {
return new Response(
JSON.stringify({ type: "error", error: { type: "rate_limit_error", message: "usage limit" } }),
{
status: 429,
headers: {
"Content-Type": "application/json",
"anthropic-ratelimit-unified-status": "rejected",
"anthropic-ratelimit-unified-reset": String(NOW_S + 3600),
"anthropic-ratelimit-unified-representative-claim": "five_hour",
"anthropic-ratelimit-unified-slow-offer": "treatment",
"anthropic-ratelimit-unified-slow-retry-after": "20",
"anthropic-ratelimit-unified-slow-max-wait": "1200",
...extra,
},
}
);
}
function ok(headers: Record<string, string> = {}): Response {
return new Response(JSON.stringify({ id: "msg_1", type: "message", content: [] }), {
status: 200,
headers: { "Content-Type": "application/json", ...headers },
});
}
/**
* Sequenced fetch mock: `/v1/messages` POSTs consume `messages` in order; any other URL
* (identity bootstrap, usage/status, reset claim) is answered by `others` or an empty 200.
*/
function mockFetch(
messages: Array<() => Response>,
others: Record<string, () => Response> = {}
): { calls: Captured[]; restore: () => void } {
const calls: Captured[] = [];
const original = globalThis.fetch;
let i = 0;
globalThis.fetch = (async (input: unknown, init: RequestInit = {}) => {
const url = String(input);
const headers = { ...((init.headers as Record<string, string>) ?? {}) };
if (url.includes("/v1/messages")) {
calls.push({ url, headers });
const next = messages[Math.min(i, messages.length - 1)];
i++;
return next();
}
const route = Object.entries(others).find(([prefix]) => url.startsWith(prefix));
if (route) return route[1]();
return new Response("{}", { status: 200, headers: { "Content-Type": "application/json" } });
}) as typeof fetch;
return { calls, restore: () => void (globalThis.fetch = original) };
}
function lower(headers: Record<string, string>): Record<string, string> {
return Object.fromEntries(Object.entries(headers).map(([k, v]) => [k.toLowerCase(), v]));
}
function run(
connectionId: string,
providerSpecificData: Record<string, unknown>,
contextEditingEnabled = false
) {
return new DefaultExecutor("claude").execute({
model: "claude-opus-4-8",
body: { model: "claude-opus-4-8", messages: [{ role: "user", content: "hi" }], max_tokens: 8 },
stream: false,
credentials: {
connectionId,
accessToken: `sk-ant-oat-${connectionId}`,
providerSpecificData,
},
clientHeaders: { "user-agent": "Cursor/1.0" },
contextEditing: { enabled: contextEditingEnabled },
// Combo-style: the generic 2×2s intra-URL 429 retry is skipped so the test only
// exercises the lane's own retry (which runs regardless of this flag).
skipUpstreamRetry: true,
});
}
test.beforeEach(() => {
_resetClaudeLowPriorityState();
_resetClaudeLimitResetMemo();
});
test("wall 429 + lowPriorityMode → same-account retry with anthropic-usage-limit: slow, 200 returned", async () => {
const { calls, restore } = mockFetch([
() => wall429(),
() => ok({ "anthropic-ratelimit-unified-slow-status": "active" }),
]);
try {
const result = await run("conn-lowpri", { lowPriorityMode: true });
assert.equal(result.response.status, 200, "the intercepted 429 never reaches chatCore");
assert.equal(calls.length, 2);
assert.equal(
lower(calls[0].headers)["anthropic-usage-limit"],
undefined,
"not sent before the wall"
);
assert.equal(lower(calls[1].headers)["anthropic-usage-limit"], "slow", "sent on the retry");
assert.equal(lower(calls[1].headers)["anthropic-dispatch-id"], undefined);
// Next request on the same connection rides the lane from its first attempt.
const again = await run("conn-lowpri", { lowPriorityMode: true });
assert.equal(again.response.status, 200);
assert.equal(calls.length, 3);
assert.equal(lower(calls[2].headers)["anthropic-usage-limit"], "slow");
} finally {
restore();
}
});
test("wall 429 without the opt-in → 429 surfaced untouched, header never sent", async () => {
const { calls, restore } = mockFetch([() => wall429(), () => ok()]);
try {
const result = await run("conn-optout", {});
assert.equal(result.response.status, 429);
assert.equal(calls.length, 1);
assert.equal(lower(calls[0].headers)["anthropic-usage-limit"], undefined);
} finally {
restore();
}
});
test("control arm offer is not accepted even with the opt-in", async () => {
const { calls, restore } = mockFetch([
() => wall429({ "anthropic-ratelimit-unified-slow-offer": "control" }),
() => ok(),
]);
try {
const result = await run("conn-control", { lowPriorityMode: true });
assert.equal(result.response.status, 429);
assert.equal(calls.length, 1);
} finally {
restore();
}
});
test("lane state is per connection: another account still sees its own wall", async () => {
const { calls, restore } = mockFetch([() => wall429(), () => ok(), () => wall429(), () => ok()]);
try {
await run("conn-a", { lowPriorityMode: true });
assert.equal(calls.length, 2);
// conn-b: first attempt has no header (idle), hits the wall, accepts, retries.
const b = await run("conn-b", { lowPriorityMode: true });
assert.equal(b.response.status, 200);
assert.equal(lower(calls[2].headers)["anthropic-usage-limit"], undefined);
assert.equal(lower(calls[3].headers)["anthropic-usage-limit"], "slow");
} finally {
restore();
}
});
test("a wall 429 that only surfaces after a 400-driven intra-attempt retry is still intercepted", async () => {
// The context-editing 400 fallback re-fetches the same URL and REPLACES `response`.
// The usage-wall check must classify that final response, otherwise the offer is missed
// and the 429 reaches chatCore, cooling the connection down — the exact opposite of
// what lowPriorityMode is for.
const badRequest = () =>
new Response(JSON.stringify({ error: { message: "context_management not supported" } }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
const { calls, restore } = mockFetch([
badRequest, // 1st: 400 → context-editing fallback re-fetches
() => wall429(), // 2nd: the wall shows up only here
() => ok({ "anthropic-ratelimit-unified-slow-status": "active" }), // 3rd: lane retry
]);
try {
const result = await run("conn-400-then-wall", { lowPriorityMode: true }, true);
assert.equal(result.response.status, 200, "the late wall 429 was intercepted, not surfaced");
assert.equal(calls.length, 3);
assert.equal(lower(calls[1].headers)["anthropic-usage-limit"], undefined);
assert.equal(lower(calls[2].headers)["anthropic-usage-limit"], "slow");
} finally {
restore();
}
});
test("wall 429 + autoLimitReset → status + claim, then full-speed retry without the slow header", async () => {
const claimUrl = "https://api.anthropic.com/api/organizations/org-uuid-1/reset_rate_limits";
const hits: string[] = [];
const { calls, restore } = mockFetch([() => wall429(), () => ok()], {
[CLAUDE_LIMIT_RESET_STATUS_URL]: () => {
hits.push("status");
return new Response(
JSON.stringify({
juniper_tide: { eligible: true, in_experiment: true, arm: "reset", available: true },
}),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
},
[claimUrl]: () => {
hits.push("claim");
return new Response(JSON.stringify({ result: "reset", next_available_at: null }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
},
});
try {
const result = await run("conn-reset", {
autoLimitReset: true,
organizationUUID: "org-uuid-1",
});
assert.equal(result.response.status, 200);
assert.deepEqual(hits, ["status", "claim"]);
assert.equal(calls.length, 2);
assert.equal(
lower(calls[1].headers)["anthropic-usage-limit"],
undefined,
"full speed, no slow lane"
);
} finally {
restore();
}
});