@oh-my-pi/pi-ai 16.3.0 → 16.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -148,6 +148,13 @@ type OpenAICompletionsCompletionTokenDetails = {
148
148
  reasoning_tokens?: unknown;
149
149
  };
150
150
 
151
+ function firstPositiveNumber(...values: unknown[]): number {
152
+ for (const value of values) {
153
+ if (typeof value === "number" && value > 0) return value;
154
+ }
155
+ return 0;
156
+ }
157
+
151
158
  /**
152
159
  * Normalize tool call ID for Mistral.
153
160
  * Mistral requires tool IDs to be exactly 9 alphanumeric characters (a-z, A-Z, 0-9).
@@ -1558,11 +1565,7 @@ export function parseChunkUsage(
1558
1565
  const accounting = calculateOpenAIUsageAccounting({
1559
1566
  promptTokens: typeof promptTokens === "number" ? promptTokens : 0,
1560
1567
  outputTokens,
1561
- cachedTokens:
1562
- (typeof cachedTokens === "number" ? cachedTokens : undefined) ??
1563
- (typeof promptCacheHitTokens === "number" ? promptCacheHitTokens : undefined) ??
1564
- (typeof promptTokenCachedTokens === "number" ? promptTokenCachedTokens : undefined) ??
1565
- 0,
1568
+ cachedTokens: firstPositiveNumber(cachedTokens, promptCacheHitTokens, promptTokenCachedTokens),
1566
1569
  reasoningTokens: typeof completionReasoningTokens === "number" ? completionReasoningTokens : 0,
1567
1570
  cacheWriteOpenRouter: typeof cacheWriteTokens === "number" ? cacheWriteTokens : undefined,
1568
1571
  cacheWriteDeepSeek: typeof promptCacheMissTokens === "number" ? promptCacheMissTokens : undefined,
@@ -1786,12 +1789,12 @@ export function convertMessages(
1786
1789
  if (compat.requiresThinkingAsText) {
1787
1790
  const thinkingText = nonEmptyThinkingBlocks
1788
1791
  .map(b => renderDemotedThinking(model.id, b.thinking))
1789
- .join("");
1792
+ .join(" ");
1790
1793
  // `content` is a plain string at this point (set above) or null —
1791
1794
  // never an array. Prepend the demoted thinking to the string form.
1792
1795
  assistantMsg.content =
1793
1796
  typeof assistantMsg.content === "string" && assistantMsg.content.length > 0
1794
- ? `${thinkingText}${assistantMsg.content}`
1797
+ ? `${thinkingText} ${assistantMsg.content}`
1795
1798
  : thinkingText;
1796
1799
  } else if (compat.requiresReasoningContentForToolCalls) {
1797
1800
  // Use the streamed signature when the backend accepts whichever
@@ -166,8 +166,8 @@ interface OpenAIResponsesProviderSessionState
166
166
 
167
167
  interface OpenAIResponsesChainState {
168
168
  /**
169
- * Wire params of the last successful turn, with per-turn trailing
170
- * scaffolding stripped from `input` (never carries previous_response_id).
169
+ * Wire params of the last successful turn; never carries
170
+ * `previous_response_id`.
171
171
  */
172
172
  lastParams?: OpenAIResponsesSamplingParams;
173
173
  lastResponseId?: string;
@@ -256,30 +256,19 @@ interface OpenAIResponsesChainedParams {
256
256
  * (same options, strict history prefix), chain via `previous_response_id` +
257
257
  * delta-only `input`; otherwise break the chain and replay the full transcript.
258
258
  *
259
- * The prefix check runs on the wire form of the conversation arguments alone:
260
- * per-turn trailing scaffolding is excluded from both sides and re-appended to
261
- * the delta, so a decoration that trails every request can never masquerade as
262
- * a history mutation.
259
+ * The prefix check runs on the wire form of the conversation arguments, so
260
+ * history mutations or option changes force a full replay.
263
261
  */
264
262
  function buildOpenAIResponsesChainedParams(
265
263
  params: OpenAIResponsesSamplingParams,
266
- trailingScaffoldingItems: number,
267
264
  chain: OpenAIResponsesChainState,
268
265
  ): OpenAIResponsesChainedParams {
269
- const historyParams =
270
- trailingScaffoldingItems > 0 && Array.isArray(params.input)
271
- ? { ...params, input: params.input.slice(0, params.input.length - trailingScaffoldingItems) }
272
- : params;
273
266
  const deltaInput = chain.canAppend
274
- ? buildResponsesDeltaInput(chain.lastParams, chain.lastResponseItems, historyParams)
267
+ ? buildResponsesDeltaInput(chain.lastParams, chain.lastResponseItems, params)
275
268
  : null;
276
269
  if (deltaInput && deltaInput.length > 0 && chain.lastResponseId) {
277
- const scaffolding =
278
- historyParams !== params && Array.isArray(params.input)
279
- ? params.input.slice(params.input.length - trailingScaffoldingItems)
280
- : [];
281
270
  return {
282
- params: { ...params, previous_response_id: chain.lastResponseId, input: [...deltaInput, ...scaffolding] },
271
+ params: { ...params, previous_response_id: chain.lastResponseId, input: deltaInput },
283
272
  previousResponseId: chain.lastResponseId,
284
273
  };
285
274
  }
@@ -416,9 +405,7 @@ const streamOpenAIResponsesOnce = (
416
405
  const strictToolsScope = getOpenAIStrictToolsScope(model, baseUrl);
417
406
  const builtParams = buildParams(model, context, options, providerSessionState, strictToolsScope);
418
407
  const params = builtParams.params;
419
- const { trailingScaffoldingItems } = builtParams;
420
408
  let activeParams = params;
421
- let activeTrailingScaffoldingItems = trailingScaffoldingItems;
422
409
  const resolvedBaseUrl = (baseUrl ?? "https://api.openai.com/v1").replace(/\/+$/, "");
423
410
  const requestReasoningEffortFallbacks = new Map<string, OpenAIReasoningEffortFallback>();
424
411
  const attemptedReasoningEffortFallbacks = new Set<string>();
@@ -448,9 +435,7 @@ const streamOpenAIResponsesOnce = (
448
435
  }
449
436
  applyReasoningEffortFallbackForRequest(params);
450
437
  let chained: OpenAIResponsesChainedParams =
451
- chainState && !chainState.disabled
452
- ? buildOpenAIResponsesChainedParams(params, trailingScaffoldingItems, chainState)
453
- : { params };
438
+ chainState && !chainState.disabled ? buildOpenAIResponsesChainedParams(params, chainState) : { params };
454
439
  sentPreviousResponseId = chained.previousResponseId;
455
440
  const idleTimeoutMs =
456
441
  options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs(model.compat.streamIdleTimeoutMs);
@@ -589,11 +574,7 @@ const streamOpenAIResponsesOnce = (
589
574
  if (chainState && !chainState.disabled) fallbackParams.store = true;
590
575
  let fallbackChained: OpenAIResponsesChainedParams =
591
576
  chainState && !chainState.disabled
592
- ? buildOpenAIResponsesChainedParams(
593
- fallbackParams,
594
- fallbackBuilt.trailingScaffoldingItems,
595
- chainState,
596
- )
577
+ ? buildOpenAIResponsesChainedParams(fallbackParams, chainState)
597
578
  : { params: fallbackParams };
598
579
  sentPreviousResponseId = fallbackChained.previousResponseId;
599
580
  fallbackChained = {
@@ -603,7 +584,6 @@ const streamOpenAIResponsesOnce = (
603
584
  chained = fallbackChained;
604
585
  rawRequestDump.body = chained.params;
605
586
  activeParams = fallbackParams;
606
- activeTrailingScaffoldingItems = fallbackBuilt.trailingScaffoldingItems;
607
587
  activeStrictToolsApplied = fallbackBuilt.strictToolsApplied;
608
588
  continue;
609
589
  }
@@ -646,7 +626,6 @@ const streamOpenAIResponsesOnce = (
646
626
  chained = { params: retryParams };
647
627
  rawRequestDump.body = retryParams;
648
628
  activeParams = currentParams;
649
- activeTrailingScaffoldingItems = currentBuilt.trailingScaffoldingItems;
650
629
  activeStrictToolsApplied = currentBuilt.strictToolsApplied;
651
630
  }
652
631
  }
@@ -710,14 +689,7 @@ const streamOpenAIResponsesOnce = (
710
689
  output.providerPayload = createOpenAIResponsesHistoryPayload(model.provider, nativeOutputItems);
711
690
  if (providerSessionState) providerSessionState.nativeHistoryReplayWarmed = true;
712
691
  if (chainState) {
713
- chainState.lastParams = structuredCloneJSON(
714
- activeTrailingScaffoldingItems > 0 && Array.isArray(activeParams.input)
715
- ? {
716
- ...activeParams,
717
- input: activeParams.input.slice(0, activeParams.input.length - activeTrailingScaffoldingItems),
718
- }
719
- : activeParams,
720
- );
692
+ chainState.lastParams = structuredCloneJSON(activeParams);
721
693
  if (output.responseId) {
722
694
  chainState.lastResponseId = output.responseId;
723
695
  chainState.lastResponseItems = sanitizeOpenAIResponsesHistoryItemsForReplay(
@@ -790,7 +762,7 @@ export function buildParams(
790
762
  providerSessionState: OpenAIResponsesProviderSessionState | undefined,
791
763
  strictToolsScope?: OpenAIStrictToolsScope,
792
764
  disableStrictToolsOverride = false,
793
- ): { params: OpenAIResponsesSamplingParams; trailingScaffoldingItems: number; strictToolsApplied: boolean } {
765
+ ): { params: OpenAIResponsesSamplingParams; strictToolsApplied: boolean } {
794
766
  const policy = resolveOpenAICompatPolicy(model, {
795
767
  endpoint: "responses",
796
768
  reasoning: options?.reasoning,
@@ -914,7 +886,7 @@ export function buildParams(
914
886
  filterReasoningHistory: options?.filterReasoningHistory,
915
887
  omitReasoningEffort: options?.omitReasoningEffort,
916
888
  });
917
- const trailingScaffoldingItems = applyResponsesCompatPolicy(params, messages, reasoningPolicy, {
889
+ applyResponsesCompatPolicy(params, reasoningPolicy, {
918
890
  reasoningSummary: options?.reasoningSummary,
919
891
  mapEffort: effort =>
920
892
  model.compat.reasoningEffortMap?.[effort as NonNullable<OpenAIResponsesOptions["reasoning"]>] ??
@@ -926,7 +898,7 @@ export function buildParams(
926
898
 
927
899
  applyOpenAIExtraBody(params, options?.extraBody);
928
900
 
929
- return { params, trailingScaffoldingItems, strictToolsApplied };
901
+ return { params, strictToolsApplied };
930
902
  }
931
903
 
932
904
  /**
@@ -76,7 +76,6 @@ import {
76
76
  } from "./github-copilot-headers";
77
77
  import type { ChatCompletionCreateParamsStreaming } from "./openai-chat-wire";
78
78
  import type { InputItem } from "./openai-codex/request-transformer";
79
- import responsesReasoningSuppressionPrompt from "./openai-responses-reasoning-suppression.md" with { type: "text" };
80
79
  import type {
81
80
  ResponseContentPartAddedEvent,
82
81
  ResponseCreateParamsStreaming,
@@ -2404,8 +2403,6 @@ export function applyCommonResponsesSamplingParams<P extends CommonResponsesPara
2404
2403
  applyOpenAIServiceTier(params, options?.serviceTier, model.provider);
2405
2404
  }
2406
2405
 
2407
- const RESPONSES_REASONING_SUPPRESSION_PROMPT = responsesReasoningSuppressionPrompt.trim();
2408
-
2409
2406
  type ReasoningOptions = {
2410
2407
  reasoning?: string;
2411
2408
  reasoningSummary?: "auto" | "detailed" | "concise" | null;
@@ -2420,12 +2417,11 @@ export interface ApplyResponsesCompatPolicyOptions {
2420
2417
 
2421
2418
  export function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreaming>(
2422
2419
  params: P,
2423
- messages: ResponseInput,
2424
2420
  policy: OpenAICompatPolicy,
2425
2421
  options: ApplyResponsesCompatPolicyOptions | undefined,
2426
- ): number {
2422
+ ): void {
2427
2423
  const reasoning = policy.reasoning;
2428
- if (!reasoning.modelSupported) return 0;
2424
+ if (!reasoning.modelSupported) return;
2429
2425
  if (reasoning.includeEncryptedReasoning) {
2430
2426
  const include = params.include ?? [];
2431
2427
  if (!include.includes("reasoning.encrypted_content")) include.push("reasoning.encrypted_content");
@@ -2435,7 +2431,7 @@ export function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreami
2435
2431
  if (reasoning.disabled) {
2436
2432
  if (reasoning.disableMode === "openrouter-enabled-false") {
2437
2433
  params.reasoning = { enabled: false } as P["reasoning"];
2438
- return 0;
2434
+ return;
2439
2435
  }
2440
2436
  if (
2441
2437
  reasoning.disableMode === "lowest-effort" &&
@@ -2445,16 +2441,9 @@ export function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreami
2445
2441
  type ReasoningParam = NonNullable<ResponseCreateParamsStreaming["reasoning"]>;
2446
2442
  params.reasoning = { effort: reasoning.wireEffort as ReasoningParam["effort"] } as P["reasoning"] &
2447
2443
  ReasoningParam;
2448
- return 0;
2449
- }
2450
- if (policy.compat.requiresReasoningSuppressionPrompt && reasoning.requestedEffort === undefined) {
2451
- messages.push({
2452
- role: "developer",
2453
- content: [{ type: "input_text", text: RESPONSES_REASONING_SUPPRESSION_PROMPT }],
2454
- });
2455
- return 1;
2444
+ return;
2456
2445
  }
2457
- return 0;
2446
+ return;
2458
2447
  }
2459
2448
 
2460
2449
  if (reasoning.requestedEffort !== undefined || options?.reasoningSummary !== undefined) {
@@ -2463,7 +2452,7 @@ export function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreami
2463
2452
  type ReasoningParam = NonNullable<ResponseCreateParamsStreaming["reasoning"]>;
2464
2453
  params.reasoning = { summary: options.reasoningSummary || "auto" } as P["reasoning"] & ReasoningParam;
2465
2454
  }
2466
- return 0;
2455
+ return;
2467
2456
  }
2468
2457
 
2469
2458
  const requested = reasoning.requestedEffort ?? "medium";
@@ -2476,17 +2465,8 @@ export function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreami
2476
2465
  reasoningParams.summary = options?.reasoningSummary || "auto";
2477
2466
  }
2478
2467
  params.reasoning = reasoningParams as P["reasoning"];
2479
- return 0;
2480
- }
2481
-
2482
- if (policy.compat.requiresReasoningSuppressionPrompt) {
2483
- messages.push({
2484
- role: "developer",
2485
- content: [{ type: "input_text", text: RESPONSES_REASONING_SUPPRESSION_PROMPT }],
2486
- });
2487
- return 1;
2468
+ return;
2488
2469
  }
2489
- return 0;
2490
2470
  }
2491
2471
 
2492
2472
  /**
@@ -2497,14 +2477,12 @@ export function applyResponsesReasoningParams<P extends ResponseCreateParamsStre
2497
2477
  params: P,
2498
2478
  model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
2499
2479
  options: ReasoningOptions | undefined,
2500
- messages: ResponseInput,
2501
2480
  mapEffort?: (effort: string) => string,
2502
2481
  includeEncryptedReasoning?: boolean,
2503
2482
  omitReasoningEffort?: boolean,
2504
- ): number {
2483
+ ): void {
2505
2484
  return applyResponsesCompatPolicy(
2506
2485
  params,
2507
- messages,
2508
2486
  resolveOpenAICompatPolicy(model, {
2509
2487
  endpoint: "responses",
2510
2488
  reasoning: options?.reasoning,
@@ -341,13 +341,18 @@ export function transformMessages<TApi extends Api>(
341
341
  const isLatestSurvivingAssistant = index === latestSurvivingAssistantIndex;
342
342
  // Signature policy is a second axis. Anthropic cryptographically
343
343
  // binds reasoning signatures to its key+session+model, so cross-model
344
- // signatures must be stripped whenever official Anthropic is on
345
- // either end of the replay:
346
- // * official 3p: the 3p target can't reverify the signature;
347
- // keeping it leaks private continuation metadata for no benefit.
348
- // * 3p → official: official rejects a foreign signature outright.
349
- // * official official cross-model: the new model rejects the
350
- // previous model's signature.
344
+ // signatures must be stripped whenever a signing Anthropic endpoint
345
+ // is on either end of the replay:
346
+ // * official Anthropic (source): the 3p target can't reverify a
347
+ // foreign signature and keeping it leaks continuation metadata
348
+ // for no benefit.
349
+ // * signing Anthropic (target): official Anthropic, GitHub Copilot,
350
+ // ZenMux, Cloudflare AI Gateway `/anthropic`, and Google Vertex
351
+ // `publishers/anthropic/…` all forward to signature-enforcing
352
+ // Anthropic. Any stale/cross-model signature on the wire triggers
353
+ // `400 Invalid signature in thinking block` — same failure class
354
+ // whether `officialEndpoint` is true or the endpoint is one of
355
+ // the known signing proxies (#4297).
351
356
  // 3p ↔ 3p replays preserve signatures because compatible providers
352
357
  // (Z.AI, DeepSeek, custom `models.yaml` providers) treat them as
353
358
  // opaque continuation hints rather than verified material; stripping
@@ -358,8 +363,8 @@ export function transformMessages<TApi extends Api>(
358
363
  // a custom proxy via `models.yaml` will see signatures stripped, the
359
364
  // conservative direction (degraded reasoning, not broken requests).
360
365
  const isOfficialAnthropicSource = isAnthropicReplay && assistantMsg.provider === "anthropic";
361
- const isOfficialAnthropicTarget = isAnthropicTarget && model.compat.officialEndpoint;
362
- const officialAnthropicInvolved = isOfficialAnthropicSource || isOfficialAnthropicTarget;
366
+ const isSigningAnthropicTarget = isAnthropicTarget && model.compat.signingEndpoint;
367
+ const signingAnthropicInvolved = isOfficialAnthropicSource || isSigningAnthropicTarget;
363
368
  // Compatible Anthropic-messages reasoning targets that accept
364
369
  // unsigned thinking natively (Z.AI, DeepSeek, the generic
365
370
  // `reasoning && !official` case in the compat builder). Used to keep
@@ -421,7 +426,7 @@ export function transformMessages<TApi extends Api>(
421
426
  if (
422
427
  !isLatestSurvivingAssistant &&
423
428
  !isSameModel &&
424
- officialAnthropicInvolved &&
429
+ signingAnthropicInvolved &&
425
430
  sanitized.thinkingSignature
426
431
  ) {
427
432
  sanitized = { ...sanitized, thinkingSignature: undefined };
@@ -438,7 +443,7 @@ export function transformMessages<TApi extends Api>(
438
443
  // textual thinking dialect; keep demotion for signatures stripped
439
444
  // by the untrustworthy-turn recovery above and for literal thinking
440
445
  // envelopes that never carried a signature field.
441
- if (isSameModel && isOfficialAnthropicTarget && sanitized.thinkingSignature?.trim() === "") {
446
+ if (isSameModel && isSigningAnthropicTarget && sanitized.thinkingSignature?.trim() === "") {
442
447
  return [];
443
448
  }
444
449
  return sanitized;
@@ -1,17 +1,20 @@
1
1
  import { scheduler } from "node:timers/promises";
2
+ import { bareModelId, parseAnthropicModel } from "@oh-my-pi/pi-catalog/identity";
2
3
  import { toNumber } from "@oh-my-pi/pi-catalog/utils";
3
4
  import * as AIError from "../error";
4
5
  import { claudeCodeVersion } from "../providers/anthropic";
5
- import type {
6
- CredentialRankingStrategy,
7
- UsageAmount,
8
- UsageFetchContext,
9
- UsageFetchParams,
10
- UsageLimit,
11
- UsageProvider,
12
- UsageReport,
13
- UsageStatus,
14
- UsageWindow,
6
+ import {
7
+ type CredentialRankingContext,
8
+ type CredentialRankingStrategy,
9
+ resolveUsedFraction,
10
+ type UsageAmount,
11
+ type UsageFetchContext,
12
+ type UsageFetchParams,
13
+ type UsageLimit,
14
+ type UsageProvider,
15
+ type UsageReport,
16
+ type UsageStatus,
17
+ type UsageWindow,
15
18
  } from "../usage";
16
19
  import { isRecord } from "../utils";
17
20
 
@@ -60,13 +63,37 @@ interface ParsedUsageBucket {
60
63
  utilization?: number;
61
64
  resetsAt?: number;
62
65
  }
63
- type ClaudeUnifiedWindow = "5h" | "7d";
66
+ type ClaudeUnifiedWindow = "5h" | "7d" | "7d_oi";
67
+ type ClaudeModelKind = "opus" | "sonnet" | "fable" | "mythos";
64
68
 
65
69
  interface ClaudeUsageResponse {
66
70
  five_hour?: ClaudeUsageBucket | null;
67
71
  seven_day?: ClaudeUsageBucket | null;
68
72
  seven_day_opus?: ClaudeUsageBucket | null;
69
73
  seven_day_sonnet?: ClaudeUsageBucket | null;
74
+ limits?: unknown;
75
+ }
76
+
77
+ interface ClaudeApiLimitModelScope {
78
+ display_name?: string | null;
79
+ }
80
+
81
+ interface ClaudeApiLimitScope {
82
+ model?: ClaudeApiLimitModelScope | null;
83
+ }
84
+
85
+ interface ClaudeApiLimitEntry {
86
+ kind?: string;
87
+ percent?: unknown;
88
+ resets_at?: string | null;
89
+ scope?: ClaudeApiLimitScope | null;
90
+ is_active?: boolean;
91
+ }
92
+
93
+ interface ParsedApiLimitEntry {
94
+ kind: string;
95
+ bucket: ParsedUsageBucket;
96
+ displayName?: string;
70
97
  }
71
98
 
72
99
  type ClaudeUsagePayload = {
@@ -89,6 +116,43 @@ function parseBucket(bucket: unknown): ParsedUsageBucket | undefined {
89
116
  }
90
117
  return { utilization, resetsAt };
91
118
  }
119
+
120
+ function getApiLimitDisplayName(scope: unknown): string | undefined {
121
+ if (!isRecord(scope)) return undefined;
122
+ const model = scope.model;
123
+ if (!isRecord(model)) return undefined;
124
+ const displayName = model.display_name;
125
+ return typeof displayName === "string" && displayName.trim() ? displayName.trim() : undefined;
126
+ }
127
+
128
+ /**
129
+ * Anthropic kept the legacy account-wide buckets populated, but as of
130
+ * 2026-07-02 the legacy per-model weekly buckets (`seven_day_opus` /
131
+ * `seven_day_sonnet`) are permanently null. Model-scoped weekly caps now arrive
132
+ * only through generic `limits[]` entries (`kind: "weekly_scoped"`) with the
133
+ * model family named by `scope.model.display_name`.
134
+ */
135
+ function parseApiLimitEntries(raw: unknown): ParsedApiLimitEntry[] {
136
+ if (!Array.isArray(raw)) return [];
137
+ const entries: ParsedApiLimitEntry[] = [];
138
+ for (const rawEntry of raw) {
139
+ if (!isRecord(rawEntry)) continue;
140
+ const entry = rawEntry as ClaudeApiLimitEntry;
141
+ if (typeof entry.kind !== "string") continue;
142
+ if (entry.is_active === false) continue;
143
+ const utilization = toNumber(entry.percent);
144
+ const resetsAt = parseIsoTime(typeof entry.resets_at === "string" ? entry.resets_at : undefined);
145
+ if (utilization === undefined && resetsAt === undefined) continue;
146
+ const displayName = getApiLimitDisplayName(entry.scope);
147
+ entries.push({
148
+ kind: entry.kind,
149
+ bucket: { utilization, resetsAt },
150
+ ...(displayName ? { displayName } : {}),
151
+ });
152
+ }
153
+ return entries;
154
+ }
155
+
92
156
  function parseUnifiedWindow(
93
157
  headers: Record<string, string>,
94
158
  window: ClaudeUnifiedWindow,
@@ -144,12 +208,17 @@ function hasUsageData(payload: ClaudeUsageResponse): boolean {
144
208
  parseBucket(payload.five_hour)?.utilization !== undefined ||
145
209
  parseBucket(payload.seven_day)?.utilization !== undefined ||
146
210
  parseBucket(payload.seven_day_opus)?.utilization !== undefined ||
147
- parseBucket(payload.seven_day_sonnet)?.utilization !== undefined
211
+ parseBucket(payload.seven_day_sonnet)?.utilization !== undefined ||
212
+ parseApiLimitEntries(payload.limits).some(entry => entry.bucket.utilization !== undefined)
148
213
  );
149
214
  }
150
215
 
151
216
  function isRetryableStatus(status: number): boolean {
152
- return AIError.isTransientStatus(status);
217
+ // Exclude 429: the usage endpoint is informational and rate-limited per
218
+ // source IP, so retrying a rate_limit_error inside a single fetch can't
219
+ // succeed and only deepens the throttle (3 attempts per poll). Fall through
220
+ // to the caller's failure cool-down and retry on the next poll instead.
221
+ return AIError.isTransientStatus(status) && status !== 429;
153
222
  }
154
223
 
155
224
  function isAbortError(error: unknown, signal?: AbortSignal): boolean {
@@ -334,8 +403,8 @@ function buildUsageLimit(args: {
334
403
  scope: {
335
404
  provider: args.provider,
336
405
  windowId: args.windowId,
337
- tier: args.tier,
338
- shared: args.shared,
406
+ ...(args.tier !== undefined ? { tier: args.tier } : {}),
407
+ ...(args.shared !== undefined ? { shared: args.shared } : {}),
339
408
  },
340
409
  window,
341
410
  amount,
@@ -343,9 +412,47 @@ function buildUsageLimit(args: {
343
412
  };
344
413
  }
345
414
 
415
+ function slugifyClaudeLimitDisplayName(displayName: string): string {
416
+ return displayName
417
+ .trim()
418
+ .toLowerCase()
419
+ .replace(/[^a-z0-9]+/g, "-")
420
+ .replace(/^-+|-+$/g, "");
421
+ }
422
+
423
+ /**
424
+ * Scoped weekly rows are per-model-family counters, not account-wide windows.
425
+ * They deliberately leave `scope.shared` unset so credential-wide exhaustion
426
+ * gating only considers the shared umbrella windows; an exhausted Fable weekly
427
+ * cap must not block Opus or Sonnet requests on the same credential.
428
+ */
429
+ function buildScopedWeeklyUsageLimits(entries: readonly ParsedApiLimitEntry[]): UsageLimit[] {
430
+ const seenSlugs = new Set<string>();
431
+ const limits: UsageLimit[] = [];
432
+ for (const entry of entries) {
433
+ if (entry.kind !== "weekly_scoped" || !entry.displayName) continue;
434
+ const slug = slugifyClaudeLimitDisplayName(entry.displayName);
435
+ if (!slug || seenSlugs.has(slug)) continue;
436
+ seenSlugs.add(slug);
437
+ const limit = buildUsageLimit({
438
+ id: `anthropic:7d:${slug}`,
439
+ label: `Claude 7 Day (${entry.displayName})`,
440
+ windowId: "7d",
441
+ windowLabel: "7 Day",
442
+ durationMs: SEVEN_DAYS_MS,
443
+ bucket: entry.bucket,
444
+ provider: "anthropic",
445
+ tier: slug,
446
+ });
447
+ if (limit) limits.push(limit);
448
+ }
449
+ return limits;
450
+ }
451
+
346
452
  export function parseClaudeRateLimitHeaders(headers: Record<string, string>, now = Date.now()): UsageReport | null {
347
453
  const fiveHour = parseUnifiedWindow(headers, "5h");
348
454
  const sevenDay = parseUnifiedWindow(headers, "7d");
455
+ const modelScopedSevenDay = parseUnifiedWindow(headers, "7d_oi");
349
456
  const limits = [
350
457
  buildUsageLimit({
351
458
  id: "anthropic:5h",
@@ -367,6 +474,16 @@ export function parseClaudeRateLimitHeaders(headers: Record<string, string>, now
367
474
  provider: "anthropic",
368
475
  shared: true,
369
476
  }),
477
+ buildUsageLimit({
478
+ id: "anthropic:7d:fable",
479
+ label: "Claude 7 Day (Fable)",
480
+ windowId: "7d",
481
+ windowLabel: "7 Day",
482
+ durationMs: SEVEN_DAYS_MS,
483
+ bucket: modelScopedSevenDay,
484
+ provider: "anthropic",
485
+ tier: "fable",
486
+ }),
370
487
  ].filter((limit): limit is UsageLimit => limit !== null);
371
488
 
372
489
  if (limits.length === 0) return null;
@@ -394,8 +511,10 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
394
511
  if (!payloadResult || !isRecord(payloadResult.payload)) return null;
395
512
  const { payload, orgId } = payloadResult;
396
513
 
397
- const fiveHour = parseBucket(payload.five_hour);
398
- const sevenDay = parseBucket(payload.seven_day);
514
+ const apiLimitEntries = parseApiLimitEntries(payload.limits);
515
+ const fiveHour = parseBucket(payload.five_hour) ?? apiLimitEntries.find(entry => entry.kind === "session")?.bucket;
516
+ const sevenDay =
517
+ parseBucket(payload.seven_day) ?? apiLimitEntries.find(entry => entry.kind === "weekly_all")?.bucket;
399
518
  const sevenDayOpus = parseBucket(payload.seven_day_opus);
400
519
  const sevenDaySonnet = parseBucket(payload.seven_day_sonnet);
401
520
 
@@ -440,6 +559,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext
440
559
  provider: "anthropic",
441
560
  tier: "sonnet",
442
561
  }),
562
+ ...buildScopedWeeklyUsageLimits(apiLimitEntries),
443
563
  ].filter((limit): limit is UsageLimit => limit !== null);
444
564
 
445
565
  if (limits.length === 0) return null;
@@ -475,11 +595,84 @@ export const claudeUsageProvider: UsageProvider = {
475
595
  supports: params => params.provider === "anthropic" && params.credential.type === "oauth",
476
596
  };
477
597
 
598
+ function getClaudeModelKind(context: CredentialRankingContext | undefined): ClaudeModelKind | undefined {
599
+ const modelId = context?.modelId;
600
+ if (!modelId) return undefined;
601
+ return parseAnthropicModel(bareModelId(modelId))?.kind;
602
+ }
603
+
604
+ /**
605
+ * Claude model-scoped rows are only relevant to the matching model family.
606
+ * Credential-wide exhaustion checks stay on shared umbrella windows unless the
607
+ * request model parses to a concrete Anthropic kind, preventing a Fable cap from
608
+ * suppressing unrelated Opus/Sonnet traffic.
609
+ */
610
+ function scopeClaudeLimitsForModel(report: UsageReport, context: CredentialRankingContext | undefined): UsageLimit[] {
611
+ const kind = getClaudeModelKind(context);
612
+ return report.limits.filter(
613
+ limit => limit.scope.shared === true || (kind !== undefined && limit.scope.tier === kind),
614
+ );
615
+ }
616
+
617
+ function rankingUsedFraction(limit: UsageLimit): number {
618
+ const fraction = resolveUsedFraction(limit);
619
+ if (typeof fraction !== "number" || !Number.isFinite(fraction)) return 0.5;
620
+ return Math.min(Math.max(fraction, 0), 1);
621
+ }
622
+
623
+ function rankingDrainRate(limit: UsageLimit, nowMs: number): number {
624
+ const usedFraction = rankingUsedFraction(limit);
625
+ const durationMs = limit.window?.durationMs ?? SEVEN_DAYS_MS;
626
+ if (!Number.isFinite(durationMs) || durationMs <= 0) return usedFraction;
627
+ const resetAt = limit.window?.resetsAt;
628
+ if (typeof resetAt !== "number" || !Number.isFinite(resetAt)) return usedFraction;
629
+ const remainingWindowMs = resetAt - nowMs;
630
+ const clampedRemainingWindowMs = Math.min(Math.max(remainingWindowMs, 0), durationMs);
631
+ const elapsedMs = durationMs - clampedRemainingWindowMs;
632
+ if (elapsedMs <= 0) return usedFraction;
633
+ const elapsedHours = elapsedMs / (60 * 60 * 1000);
634
+ if (!Number.isFinite(elapsedHours) || elapsedHours <= 0) return usedFraction;
635
+ return usedFraction / elapsedHours;
636
+ }
637
+
638
+ function morePressuredLimit(
639
+ left: UsageLimit | undefined,
640
+ right: UsageLimit | undefined,
641
+ nowMs: number,
642
+ ): UsageLimit | undefined {
643
+ if (!left) return right;
644
+ if (!right) return left;
645
+ const leftDrainRate = rankingDrainRate(left, nowMs);
646
+ const rightDrainRate = rankingDrainRate(right, nowMs);
647
+ if (rightDrainRate !== leftDrainRate) return rightDrainRate > leftDrainRate ? right : left;
648
+ return rankingUsedFraction(right) > rankingUsedFraction(left) ? right : left;
649
+ }
650
+
651
+ function findClaudeSecondaryLimit(
652
+ report: UsageReport,
653
+ context: CredentialRankingContext | undefined,
654
+ ): UsageLimit | undefined {
655
+ const nowMs = Date.now();
656
+ return scopeClaudeLimitsForModel(report, context)
657
+ .filter(limit => limit.scope.windowId === "7d" || limit.window?.id === "7d")
658
+ .reduce<UsageLimit | undefined>((selected, limit) => morePressuredLimit(selected, limit, nowMs), undefined);
659
+ }
660
+
478
661
  export const claudeRankingStrategy: CredentialRankingStrategy = {
479
- findWindowLimits(report) {
480
- const primary = report.limits.find(l => l.id === "anthropic:5h");
481
- const secondary = report.limits.find(l => l.id === "anthropic:7d");
662
+ findWindowLimits(report, context) {
663
+ const primary = report.limits.find(limit => limit.id === "anthropic:5h");
664
+ const secondary = findClaudeSecondaryLimit(report, context);
482
665
  return { primary, secondary };
483
666
  },
667
+ scopeLimits: scopeClaudeLimitsForModel,
668
+ /**
669
+ * Fable/Mythos usage-limit errors map to tier-local weekly counters. Scope
670
+ * reactive backoff blocks for those tiers, mirroring the per-counter
671
+ * precedent in packages/ai/src/usage/google-antigravity.ts:466-497.
672
+ */
673
+ blockScope(context) {
674
+ const kind = getClaudeModelKind(context);
675
+ return kind === "fable" || kind === "mythos" ? `tier:${kind}` : undefined;
676
+ },
484
677
  windowDefaults: { primaryMs: 5 * 60 * 60 * 1000, secondaryMs: 7 * 24 * 60 * 60 * 1000 },
485
678
  };
@@ -30,3 +30,19 @@ export const kStreamingArgumentsDone = Symbol("provider.block.argumentsDone");
30
30
 
31
31
  /** Classifies Cursor's in-flight tool-call kind without leaking provider-private state. */
32
32
  export const kStreamingBlockKind = Symbol("provider.block.kind");
33
+
34
+ /**
35
+ * Marks a `toolCall` content block that Cursor's exec channel already
36
+ * executed server-side (via the coding-agent bridge) and whose result is
37
+ * buffered separately for emission via the assistant-loop stream.
38
+ *
39
+ * `agent-loop.ts` MUST skip execution of blocks carrying this marker —
40
+ * treating them as a fresh runnable tool call would run the same
41
+ * side-effecting tool (bash, write, delete, …) a second time. Symbol-keyed
42
+ * so it never persists across the JSONL round-trip, where rebuild instead
43
+ * pairs the block with its already-persisted `toolResult` message by id.
44
+ */
45
+ export const kCursorExecResolved = Symbol("provider.block.cursorExecResolved");
46
+
47
+ /** Carries the resolved marker without exposing a string-keyed property. */
48
+ export type CursorExecResolvedCarrier = object & { [kCursorExecResolved]?: true };