mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 22:32:22 +03:00
* 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>
147 lines
5.6 KiB
TypeScript
147 lines
5.6 KiB
TypeScript
/**
|
|
* claudeUsageLimit.ts — executor-side glue for the Claude OAuth usage wall.
|
|
*
|
|
* Keeps `BaseExecutor.execute()` free of the lower-priority lane's mechanics: this guard
|
|
* owns the per-request wait accounting, the header injection, the abort-aware sleep and the
|
|
* decision logging. The decision itself is the pure state machine in
|
|
* `open-sse/services/claudeLowPriority.ts`; the weekly session-limit reset lives in
|
|
* `open-sse/services/claudeLimitReset.ts`.
|
|
*
|
|
* Both behaviors are opt-in per connection (`providerSpecificData.lowPriorityMode` /
|
|
* `autoLimitReset`) and only ever act on a real 5-hour usage wall — see the module header of
|
|
* claudeLowPriority.ts for the wire contract and the lifecycle.
|
|
*/
|
|
|
|
import { attemptClaudeLimitReset } from "../services/claudeLimitReset.ts";
|
|
import {
|
|
CLAUDE_USAGE_LIMIT_HEADER,
|
|
CLAUDE_USAGE_LIMIT_SLOW,
|
|
createClaudeLowPriorityWait,
|
|
handleClaudeUsageLimitResponse,
|
|
isClaudeLowPriorityActive,
|
|
readClaudeUsageLimitConfig,
|
|
resolveClaudeUsageLimitKey,
|
|
type ClaudeLowPriorityWait,
|
|
} from "../services/claudeLowPriority.ts";
|
|
import type { ExecutorLog, ProviderCredentials } from "./base.ts";
|
|
|
|
/** Safety margin kept between the last lane wait and the request's own upstream timeout. */
|
|
export const CLAUDE_USAGE_LIMIT_WAIT_MARGIN_MS = 5_000;
|
|
|
|
type GuardResponse = { status: number; headers: Headers };
|
|
|
|
export type ClaudeUsageLimitRetryInput = {
|
|
credentials?: ProviderCredentials | null;
|
|
signal?: AbortSignal | null;
|
|
/** The request's upstream-start timeout; 0/undefined means "unbounded". */
|
|
budgetMs?: number;
|
|
/** Whether THIS request went out carrying the slow header (see `sentSlow` in the service). */
|
|
sentSlow: boolean;
|
|
};
|
|
|
|
/** True for a native Claude connection authenticated with a subscription OAuth token. */
|
|
function isClaudeOAuth(provider: string, credentials?: ProviderCredentials | null): boolean {
|
|
return (
|
|
provider === "claude" &&
|
|
typeof credentials?.accessToken === "string" &&
|
|
credentials.accessToken.startsWith("sk-ant-oat") &&
|
|
!credentials?.apiKey
|
|
);
|
|
}
|
|
|
|
/** Sleep that rejects as soon as the request is aborted, so a lane wait never outlives it. */
|
|
function abortableSleep(delayMs: number, signal?: AbortSignal | null): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
signal?.removeEventListener("abort", onAbort);
|
|
resolve();
|
|
}, delayMs);
|
|
const onAbort = () => {
|
|
clearTimeout(timer);
|
|
reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
|
|
};
|
|
if (signal?.aborted) return onAbort();
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
}
|
|
|
|
/** One instance per `execute()` call: the wait window spans the intra-URL retries. */
|
|
export class ClaudeUsageLimitGuard {
|
|
private readonly wait: ClaudeLowPriorityWait = createClaudeLowPriorityWait();
|
|
private readonly startedAtMs = Date.now();
|
|
private key: string | null = null;
|
|
|
|
constructor(
|
|
private readonly provider: string,
|
|
private readonly log?: ExecutorLog | null
|
|
) {}
|
|
|
|
/**
|
|
* Stamp `anthropic-usage-limit: slow` when this connection's lane is active. Returns
|
|
* whether the header went out, which the response side needs to tell a lane verdict from
|
|
* a header-less sibling's outcome.
|
|
*/
|
|
applyHeader(headers: Record<string, string>, credentials?: ProviderCredentials | null): boolean {
|
|
this.key = isClaudeOAuth(this.provider, credentials)
|
|
? resolveClaudeUsageLimitKey(credentials ?? {})
|
|
: null;
|
|
if (this.key === null || !isClaudeLowPriorityActive(this.key)) return false;
|
|
headers[CLAUDE_USAGE_LIMIT_HEADER] = CLAUDE_USAGE_LIMIT_SLOW;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Classify an upstream response. Resolves true when the caller must retry the SAME
|
|
* account (the sleep, if any, has already happened) instead of surfacing the response.
|
|
*/
|
|
async shouldRetry(
|
|
response: GuardResponse,
|
|
url: string,
|
|
input: ClaudeUsageLimitRetryInput
|
|
): Promise<boolean> {
|
|
if (this.key === null) return false;
|
|
const key = this.key;
|
|
const credentials = input.credentials;
|
|
const decision = await handleClaudeUsageLimitResponse({
|
|
key,
|
|
config: readClaudeUsageLimitConfig(credentials?.providerSpecificData),
|
|
response,
|
|
wait: this.wait,
|
|
sentSlow: input.sentSlow,
|
|
waitCeilingMs: this.waitCeilingMs(input.budgetMs),
|
|
claimLimitReset: () =>
|
|
attemptClaudeLimitReset({
|
|
key,
|
|
accessToken: credentials?.accessToken ?? "",
|
|
providerSpecificData: credentials?.providerSpecificData,
|
|
log: this.log,
|
|
}).then((attempt) => attempt.reset),
|
|
});
|
|
|
|
if (decision.kind === "ended") {
|
|
this.log?.info?.("CLAUDE_LOW_PRIORITY", `lane ended (${decision.reason}) on ${url}`);
|
|
return false;
|
|
}
|
|
if (decision.kind !== "retry") return false;
|
|
|
|
this.log?.info?.(
|
|
"CLAUDE_LOW_PRIORITY",
|
|
`${decision.via} on ${url} — retrying same account in ${decision.delayMs}ms`
|
|
);
|
|
if (decision.delayMs > 0) await abortableSleep(decision.delayMs, input.signal);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* What is left of the request's upstream timeout, minus a safety margin. Without this the
|
|
* server-announced max-wait (20 min by default, up to 6 h) outlives the request and the
|
|
* sleep is aborted mid-wait, surfacing a TimeoutError instead of the graceful `max_wait`
|
|
* end plus its cool-off.
|
|
*/
|
|
private waitCeilingMs(budgetMs?: number): number | undefined {
|
|
if (!budgetMs || budgetMs <= 0) return undefined;
|
|
const elapsed = Date.now() - this.startedAtMs;
|
|
return Math.max(0, budgetMs - elapsed - CLAUDE_USAGE_LIMIT_WAIT_MARGIN_MS);
|
|
}
|
|
}
|