@oh-my-pi/pi-ai 16.3.2 → 16.3.4

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.3.4] - 2026-07-03
6
+
7
+ ### Added
8
+
9
+ - Added support for Baseten as an AI provider
10
+
11
+ ### Changed
12
+
13
+ - Improved Claude usage reliability by removing proactive hard-blocking for Fable and Mythos tiers
14
+
15
+ ### Fixed
16
+
17
+ - Fixed Anthropic OAuth account rotation to exclude unreliable model-scoped Fable/Mythos weekly caps from proactive hard-blocking, ensuring they act only as ranking priority hints while still allowing reactive 429-fallback to rotate and reach serviceable siblings.
18
+
19
+ ## [16.3.3] - 2026-07-02
20
+
21
+ ### Added
22
+
23
+ - Added comprehensive tracking and credential-ranking support for Anthropic per-tier and weekly usage limits, including Claude Fable weekly caps. This prevents a single exhausted model-scoped cap from blocking the entire OAuth credential and improves credential selection based on drain-rate pressure.
24
+
25
+ ### Changed
26
+
27
+ - Updated Claude Fable reasoning replay to use bare text instead of wrapped thinking tags
28
+
29
+ ### Fixed
30
+
31
+ - Improved robustness of single-argument tool calls by automatically remapping mislabeled string arguments.
32
+ - Fixed Anthropic OAuth usage reporting to stop retrying on 429 rate-limit errors.
33
+ - Fixed usage cache to correctly persist null values during cold-start failure backoff windows.
34
+ - Fixed cursor-agent persisted transcripts losing tool-call structure for native execution tools, ensuring replayed tool results are correctly paired with their corresponding calls.
35
+ - Fixed OpenAI-compatible streaming usage parsing to prefer non-zero nested cached token counts when the root cached_tokens value is zero.
36
+ - Added automatic detection and remediation for custom proxies returning signature errors on Anthropic thinking blocks, allowing the client to automatically retry with unsigned blocks and prompt the user to adjust their configuration.
37
+ - Fixed potential hangs in GitLab Duo Workflow setup by adding proper timeout and abort signal handling to REST fetches.
38
+ - Fixed Cursor proxy tunnel setup hanging indefinitely by adding abort and timeout handling.
39
+ - Fixed Devin Connect streaming reader vulnerability to corrupt frame lengths by capping payloads at 16 MiB and throwing an envelope error immediately.
40
+
5
41
  ## [16.3.1] - 2026-07-02
6
42
 
7
43
  ### Changed
@@ -91,12 +91,13 @@ export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
91
91
  /**
92
92
  * Per-credential usage hook consumed by `AuthStorage.#getUsageReport`. Pulls
93
93
  * the aggregate broker `/v1/usage` once and serves all callers from the
94
- * same response (coalesced + cached), then matches the credential to a
95
- * report by provider + identity (accountId / email / projectId).
94
+ * same response (coalesced + cached), then overlays any client-observed
95
+ * header hints for the matching credential.
96
96
  *
97
97
  * The broker already aggregates with its own 30s TTL on the server side; our
98
98
  * 15s client TTL is below that so we usually re-use the broker's cache too.
99
99
  */
100
100
  getUsageReport(provider: Provider, credential: OAuthCredential, signal?: AbortSignal): Promise<UsageReport | null>;
101
+ ingestUsageReport(provider: Provider, credential: OAuthCredential, report: UsageReport): boolean;
101
102
  close(): void;
102
103
  }
@@ -287,6 +287,13 @@ export interface AuthCredentialStore {
287
287
  * `signal` propagates the agent's cancel down to the broker fetch.
288
288
  */
289
289
  getUsageReport?(provider: Provider, credential: OAuthCredential, signal?: AbortSignal): Promise<UsageReport | null>;
290
+ /**
291
+ * Optional store hook to ingest a parsed provider usage report for one OAuth
292
+ * credential. Remote broker stores use this to overlay header-derived limits
293
+ * onto their cached aggregate `/v1/usage` response without mutating broker
294
+ * state.
295
+ */
296
+ ingestUsageReport?(provider: Provider, credential: OAuthCredential, report: UsageReport): boolean;
290
297
  /**
291
298
  * Optional store hook to invalidate a specific credential after the upstream
292
299
  * provider returned 401 on a supposedly-fresh key. Remote stores force the
@@ -5,17 +5,22 @@
5
5
  * replayed unsigned `thought` part is schema-accepted but silently discarded —
6
6
  * neither recalled nor influencing generation).
7
7
  *
8
- * Fable is the exception: replaying prior reasoning inside `<thinking>` /
9
- * `antml:thinking`-style assistant text is treated as a reasoning-extraction
10
- * attempt and can train the next turn to leak thoughts, so Fable receives the
11
- * reasoning as markdown-italic assistant prose instead. Harmony and Gemma are
12
- * also exceptions: their `renderThinking` emits chat-template control tokens
13
- * (`<|channel|>analysis`, `<|channel>thought`) that must not appear inside a
14
- * structured native message, so they fall back to a plain `<think>` block. Every
15
- * other dialect's thinking form is inline-safe XML tags or a markdown fence.
8
+ * Fable is the exception: Anthropic's `reasoning_extraction` classifier blocks
9
+ * requests that replay prior reasoning inside `<thinking>` / `antml:thinking`
10
+ * tags OR the older `_Hmm. …_` italic envelope it reads the wrapped
11
+ * chain-of-thought as an attempt to duplicate model outputs and refuses the
12
+ * whole turn. Fable therefore receives prior reasoning as bare assistant prose:
13
+ * no tag, no `_Hmm.` wrapper, no trailing newline.
14
+ * Heat is cumulative (block count and early-conversation position also raise
15
+ * it), so this lowers per-block signal but does not license unbounded replay.
16
+ * Harmony and Gemma are also exceptions: their `renderThinking` emits
17
+ * chat-template control tokens (`<|channel|>analysis`, `<|channel>thought`)
18
+ * that must not appear inside a structured native message, so they fall back to
19
+ * a plain `<think>` block. Every other dialect's thinking form is inline-safe
20
+ * XML tags or a markdown fence.
16
21
  *
17
- * The result ends with a trailing newline so the block stays separated from the
18
- * turn's reply text when the wire encoder concatenates parts.
22
+ * The result does not append a delimiter; callers that flatten adjacent blocks
23
+ * into a single string must insert their own separator.
19
24
  *
20
25
  * Distinct from {@link DialectDefinition.renderThinking}, which targets the
21
26
  * owned-dialect *text transport* where those control tokens are legal.
@@ -204,6 +204,16 @@ export type AnthropicUsageLike = {
204
204
  * zero-valued objects clear prior extras from earlier stream usage snapshots.
205
205
  */
206
206
  export declare function applyAnthropicUsageExtras(usage: Usage, source: AnthropicUsageLike): void;
207
+ export declare function isInvalidThinkingSignatureError(message: string): boolean;
208
+ /**
209
+ * Prepend a pointed remediation to Anthropic's `Invalid signature in thinking
210
+ * block` 400 when the model looks like an unmarked custom signing proxy
211
+ * (opaque baseUrl, `spec.reasoning: true`, no explicit
212
+ * `compat.replayUnsignedThinking` override). The default is native replay for
213
+ * the 3p reasoning majority (#2005); this hint turns the misconfigured-proxy
214
+ * case into a one-line fix instead of a silent retry loop (#4297).
215
+ */
216
+ export declare function maybeAddReplayUnsignedThinkingHint(model: Model<"anthropic-messages">, message: string): string;
207
217
  /**
208
218
  * Public entry: wrap the single-attempt streamer with bounded empty-completion
209
219
  * retries (a benign terminal stop carrying no content/usage would otherwise
@@ -1,6 +1,6 @@
1
1
  import { type JsonValue } from "@bufbuild/protobuf";
2
2
  import type { AssistantMessage, CursorExecHandlerResult, CursorExecHandlers, CursorToolResultHandler, Message, StreamFunction, StreamOptions, TextContent, ThinkingContent, ToolCall, ToolResultMessage } from "../types";
3
- import { kStreamingBlockIndex, kStreamingBlockKind, kStreamingLastParseLen, kStreamingPartialJson } from "../utils/block-symbols";
3
+ import { kCursorExecResolved, kStreamingBlockIndex, kStreamingBlockKind, kStreamingLastParseLen, kStreamingPartialJson } from "../utils/block-symbols";
4
4
  import { AssistantMessageEventStream } from "../utils/event-stream";
5
5
  export declare const CURSOR_API_URL = "https://api2.cursor.sh";
6
6
  export declare const CURSOR_CLIENT_VERSION = "cli-2026.01.09-231024f";
@@ -15,7 +15,8 @@ export type ToolCallState = ToolCall & {
15
15
  [kStreamingBlockIndex]: number;
16
16
  [kStreamingPartialJson]?: string;
17
17
  [kStreamingLastParseLen]?: number;
18
- [kStreamingBlockKind]: "mcp" | "todo";
18
+ [kStreamingBlockKind]: "mcp" | "todo" | "cursor-exec";
19
+ [kCursorExecResolved]?: true;
19
20
  };
20
21
  export interface BlockState {
21
22
  currentTextBlock: (TextContent & {
@@ -60,6 +61,26 @@ export declare function resolveExecHandler<TArgs, TResult>(args: TArgs, handler:
60
61
  * - otherwise → completion wins.
61
62
  */
62
63
  export declare function mergeCursorMcpToolCallArgs(streamed: Record<string, unknown> | undefined, completion: Record<string, unknown> | undefined): Record<string, unknown>;
64
+ /**
65
+ * Synthesize a completed `toolCall` content block for a Cursor exec-channel
66
+ * native tool (`shell`, `read`, `write`, `grep`, `ls`, `delete`, `diagnostics`).
67
+ *
68
+ * Args arrive complete on the exec message, so the block opens and closes in
69
+ * one step — no partial-JSON streaming path. Without this the persisted
70
+ * assistant message carries only text/thinking blocks, and on replay the
71
+ * following `toolResult` messages have no matching `toolCall.id` in
72
+ * `renderSessionContext`, so they render as header-less `⎿` lines beneath the
73
+ * last text block instead of proper tool components (issue #4348).
74
+ *
75
+ * The block is stamped with {@link kCursorExecResolved} so the shared
76
+ * `agent-loop.ts` execution pass skips it — Cursor's server-driven exec
77
+ * channel already ran the tool via the bridge and buffered the result, so
78
+ * treating this block as runnable would re-execute the same side-effecting
79
+ * tool a second time.
80
+ *
81
+ * Exported for tests to exercise ordering with adjacent text/thinking blocks.
82
+ */
83
+ export declare function synthesizeCursorExecToolCall(output: AssistantMessage, stream: AssistantMessageEventStream, state: BlockState, toolCallId: string, toolName: string, args: Record<string, unknown>): void;
63
84
  /** Exported for tests: drives one Cursor interaction update through the streaming state machine. */
64
85
  export declare function processInteractionUpdate(update: any, output: AssistantMessage, stream: AssistantMessageEventStream, state: BlockState, usageState: UsageState): void;
65
86
  /**
@@ -0,0 +1,7 @@
1
+ import type { OAuthLoginCallbacks } from "./oauth/types";
2
+ export declare const loginBaseten: (options: import("./oauth").OAuthController) => Promise<string>;
3
+ export declare const basetenProvider: {
4
+ readonly id: "baseten";
5
+ readonly name: "Baseten";
6
+ readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
7
+ };
@@ -29,6 +29,10 @@ declare const ALL: ({
29
29
  } | {
30
30
  readonly id: "azure";
31
31
  readonly name: "Azure OpenAI";
32
+ } | {
33
+ readonly id: "baseten";
34
+ readonly name: "Baseten";
35
+ readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
32
36
  } | {
33
37
  readonly id: "cerebras";
34
38
  readonly name: "Cerebras";
@@ -1,4 +1,4 @@
1
- import type { CredentialRankingStrategy, UsageProvider, UsageReport } from "../usage";
1
+ import { type CredentialRankingStrategy, type UsageProvider, type UsageReport } from "../usage";
2
2
  export declare function parseClaudeRateLimitHeaders(headers: Record<string, string>, now?: number): UsageReport | null;
3
3
  export declare const claudeUsageProvider: UsageProvider;
4
4
  export declare const claudeRankingStrategy: CredentialRankingStrategy;
@@ -18,3 +18,19 @@ export declare const kStreamingLastParseLen: unique symbol;
18
18
  export declare const kStreamingArgumentsDone: unique symbol;
19
19
  /** Classifies Cursor's in-flight tool-call kind without leaking provider-private state. */
20
20
  export declare const kStreamingBlockKind: unique symbol;
21
+ /**
22
+ * Marks a `toolCall` content block that Cursor's exec channel already
23
+ * executed server-side (via the coding-agent bridge) and whose result is
24
+ * buffered separately for emission via the assistant-loop stream.
25
+ *
26
+ * `agent-loop.ts` MUST skip execution of blocks carrying this marker —
27
+ * treating them as a fresh runnable tool call would run the same
28
+ * side-effecting tool (bash, write, delete, …) a second time. Symbol-keyed
29
+ * so it never persists across the JSONL round-trip, where rebuild instead
30
+ * pairs the block with its already-persisted `toolResult` message by id.
31
+ */
32
+ export declare const kCursorExecResolved: unique symbol;
33
+ /** Carries the resolved marker without exposing a string-keyed property. */
34
+ export type CursorExecResolvedCarrier = object & {
35
+ [kCursorExecResolved]?: true;
36
+ };
@@ -22,8 +22,14 @@ export declare function getProxyForProvider(provider: string): string | undefine
22
22
  * Wraps a fetch implementation to inject proxy options for non-local hosts.
23
23
  */
24
24
  export declare function wrapFetchForProxy(fetchImpl: FetchImpl, provider: string): FetchImpl;
25
+ export interface ConnectProxiedSocketOptions {
26
+ /** Caller cancellation for the proxy TCP/TLS handshake and CONNECT tunnel. */
27
+ signal?: AbortSignal;
28
+ /** Maximum wall-clock time to establish the final TLS tunnel. Disabled when absent or non-positive. */
29
+ timeoutMs?: number;
30
+ }
25
31
  /**
26
32
  * Tunnel a socket connection through an HTTP CONNECT proxy.
27
33
  * This is used specifically to wrap Node's `http2.connect(baseUrl, { createConnection })` for Cursor.
28
34
  */
29
- export declare function connectProxiedSocket(proxyUrlStr: string, targetUrlStr: string): Promise<tls.TLSSocket>;
35
+ export declare function connectProxiedSocket(proxyUrlStr: string, targetUrlStr: string, options?: ConnectProxiedSocketOptions): Promise<tls.TLSSocket>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.3.2",
4
+ "version": "16.3.4",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.3.2",
42
- "@oh-my-pi/pi-utils": "16.3.2",
43
- "@oh-my-pi/pi-wire": "16.3.2",
41
+ "@oh-my-pi/pi-catalog": "16.3.4",
42
+ "@oh-my-pi/pi-utils": "16.3.4",
43
+ "@oh-my-pi/pi-wire": "16.3.4",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -25,10 +25,11 @@ import { type AuthBrokerClient, AuthBrokerStreamUnsupportedError } from "./clien
25
25
  import type { RefresherSchedule, SnapshotEntry, SnapshotResponse, SnapshotStreamEvent } from "./types";
26
26
 
27
27
  /**
28
- * Client-side TTL for the aggregate `/v1/usage` response. Set below the
29
- * broker server's own 30s usage cache so we typically pick up the broker's
30
- * cached value instead of re-walking the network but high enough to absorb
31
- * the parallel fan-out from `#rankOAuthSelections` into a single round-trip.
28
+ * Client-side TTL for the aggregate `/v1/usage` response. The broker dedups
29
+ * upstream `/usage` hits via AuthStorage's 5-minute per-credential cache plus
30
+ * single-flight, so this short client TTL mainly folds the parallel fan-out
31
+ * from `#rankOAuthSelections` into a single round-trip — a ranking pass issues
32
+ * one broker call instead of N.
32
33
  */
33
34
  const USAGE_CACHE_TTL_MS = 15_000;
34
35
  const WAIT_THRESHOLD_MS = 1_000;
@@ -68,6 +69,47 @@ interface UsageCacheEntry {
68
69
  fetchedAt: number;
69
70
  }
70
71
 
72
+ function usageOverlayKey(
73
+ provider: Provider,
74
+ ids: { accountId?: string; email?: string; projectId?: string },
75
+ ): string | undefined {
76
+ const accountId = ids.accountId?.trim().toLowerCase();
77
+ if (accountId) return `${provider}\0account:${accountId}`;
78
+ const email = ids.email?.trim().toLowerCase();
79
+ if (email) return `${provider}\0email:${email}`;
80
+ const projectId = ids.projectId?.trim().toLowerCase();
81
+ if (projectId) return `${provider}\0project:${projectId}`;
82
+ return undefined;
83
+ }
84
+
85
+ function mergeUsageReports(base: UsageReport, overlay: UsageReport): UsageReport {
86
+ const overlayLimitsById = new Map(overlay.limits.map(limit => [limit.id, limit]));
87
+ const limits = [];
88
+ for (const limit of base.limits) {
89
+ const replacement = overlayLimitsById.get(limit.id);
90
+ if (replacement) {
91
+ limits.push(replacement);
92
+ overlayLimitsById.delete(limit.id);
93
+ } else {
94
+ limits.push(limit);
95
+ }
96
+ }
97
+ for (const limit of overlayLimitsById.values()) limits.push(limit);
98
+ const overlayMetadata = (overlay.metadata ?? {}) as Record<string, unknown>;
99
+ return {
100
+ ...base,
101
+ fetchedAt: Math.max(base.fetchedAt, overlay.fetchedAt),
102
+ limits,
103
+ metadata: {
104
+ ...overlayMetadata,
105
+ ...(base.metadata ?? {}),
106
+ ...(overlayMetadata.headersUpdatedAt !== undefined
107
+ ? { headersUpdatedAt: overlayMetadata.headersUpdatedAt }
108
+ : {}),
109
+ },
110
+ };
111
+ }
112
+
71
113
  export interface RemoteAuthCredentialStoreOptions {
72
114
  client: AuthBrokerClient;
73
115
  /**
@@ -94,6 +136,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
94
136
  #snapshot: SnapshotResponse = emptySnapshot();
95
137
  #snapshotReceivedAt = Date.now();
96
138
  #generation = 0;
139
+ #usageOverlays: Map<string, UsageReport> = new Map();
97
140
  #backgroundAbort = new AbortController();
98
141
  #cache: Map<string, CacheEntry> = new Map();
99
142
  #usageCache?: UsageCacheEntry;
@@ -515,14 +558,15 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
515
558
  * residential laptop is, so all credentials surface every cycle.
516
559
  */
517
560
  async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
518
- return this.#raceWithSignal(this.#loadUsageReports(), signal);
561
+ const reports = await this.#raceWithSignal(this.#loadUsageReports(), signal);
562
+ return reports ? this.#applyUsageOverlays(reports) : null;
519
563
  }
520
564
 
521
565
  /**
522
566
  * Per-credential usage hook consumed by `AuthStorage.#getUsageReport`. Pulls
523
567
  * the aggregate broker `/v1/usage` once and serves all callers from the
524
- * same response (coalesced + cached), then matches the credential to a
525
- * report by provider + identity (accountId / email / projectId).
568
+ * same response (coalesced + cached), then overlays any client-observed
569
+ * header hints for the matching credential.
526
570
  *
527
571
  * The broker already aggregates with its own 30s TTL on the server side; our
528
572
  * 15s client TTL is below that so we usually re-use the broker's cache too.
@@ -533,8 +577,47 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
533
577
  signal?: AbortSignal,
534
578
  ): Promise<UsageReport | null> {
535
579
  const reports = await this.#raceWithSignal(this.#loadUsageReports(), signal);
536
- if (!reports) return null;
537
- return matchUsageReport(reports, provider, credential);
580
+ const matched = reports ? matchUsageReport(reports, provider, credential) : null;
581
+ const overlay = this.#getActiveUsageOverlay(provider, credential);
582
+ if (matched && overlay) return mergeUsageReports(matched, overlay);
583
+ return overlay ?? matched;
584
+ }
585
+
586
+ ingestUsageReport(provider: Provider, credential: OAuthCredential, report: UsageReport): boolean {
587
+ const key = usageOverlayKey(provider, credential);
588
+ if (!key) return false;
589
+ const activeOverlay = this.#getActiveUsageOverlay(provider, credential);
590
+ this.#usageOverlays.set(key, activeOverlay ? mergeUsageReports(activeOverlay, report) : report);
591
+ return true;
592
+ }
593
+
594
+ #getActiveUsageOverlay(provider: Provider, credential: OAuthCredential): UsageReport | undefined {
595
+ const key = usageOverlayKey(provider, credential);
596
+ if (!key) return undefined;
597
+ const overlay = this.#usageOverlays.get(key);
598
+ if (!overlay) return undefined;
599
+ if (Date.now() - overlay.fetchedAt >= USAGE_CACHE_TTL_MS) {
600
+ this.#usageOverlays.delete(key);
601
+ return undefined;
602
+ }
603
+ return overlay;
604
+ }
605
+
606
+ #applyUsageOverlays(reports: UsageReport[]): UsageReport[] {
607
+ const overlays = [...this.#usageOverlays.values()].filter(
608
+ overlay => Date.now() - overlay.fetchedAt < USAGE_CACHE_TTL_MS,
609
+ );
610
+ if (overlays.length === 0) return reports;
611
+ const merged = [...reports];
612
+ for (const overlay of overlays) {
613
+ const matchIndex = findMatchingReportIndex(merged, overlay);
614
+ if (matchIndex === -1) {
615
+ merged.push(overlay);
616
+ } else {
617
+ merged[matchIndex] = mergeUsageReports(merged[matchIndex]!, overlay);
618
+ }
619
+ }
620
+ return merged;
538
621
  }
539
622
 
540
623
  /**
@@ -597,6 +680,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
597
680
  this.#closed = true;
598
681
  this.#backgroundAbort.abort();
599
682
  this.#cache.clear();
683
+ this.#usageOverlays.clear();
600
684
  }
601
685
  }
602
686
 
@@ -623,6 +707,22 @@ function matchUsageReport(reports: UsageReport[], provider: Provider, credential
623
707
  return null;
624
708
  }
625
709
 
710
+ function findMatchingReportIndex(reports: UsageReport[], overlay: UsageReport): number {
711
+ const candidates = reports
712
+ .map((report, index) => ({ report, index }))
713
+ .filter(candidate => candidate.report.provider === overlay.provider);
714
+ if (candidates.length === 0) return -1;
715
+ if (candidates.length === 1) return candidates[0]!.index;
716
+ const metadata = (overlay.metadata ?? {}) as Record<string, unknown>;
717
+ const accountId = readMetadataString(metadata, "accountId")?.toLowerCase();
718
+ const email = readMetadataString(metadata, "email")?.toLowerCase();
719
+ const projectId = readMetadataString(metadata, "projectId")?.toLowerCase();
720
+ for (const candidate of candidates) {
721
+ if (reportMatchesIdentity(candidate.report, accountId, email, projectId)) return candidate.index;
722
+ }
723
+ return -1;
724
+ }
725
+
626
726
  function reportMatchesIdentity(
627
727
  report: UsageReport,
628
728
  accountId: string | undefined,
@@ -362,6 +362,13 @@ export interface AuthCredentialStore {
362
362
  * `signal` propagates the agent's cancel down to the broker fetch.
363
363
  */
364
364
  getUsageReport?(provider: Provider, credential: OAuthCredential, signal?: AbortSignal): Promise<UsageReport | null>;
365
+ /**
366
+ * Optional store hook to ingest a parsed provider usage report for one OAuth
367
+ * credential. Remote broker stores use this to overlay header-derived limits
368
+ * onto their cached aggregate `/v1/usage` response without mutating broker
369
+ * state.
370
+ */
371
+ ingestUsageReport?(provider: Provider, credential: OAuthCredential, report: UsageReport): boolean;
365
372
  /**
366
373
  * Optional store hook to invalidate a specific credential after the upstream
367
374
  * provider returned 401 on a supposedly-fresh key. Remote stores force the
@@ -2234,16 +2241,15 @@ export class AuthStorage {
2234
2241
  this.#recordUsageHistory(request, report);
2235
2242
  return report;
2236
2243
  }
2237
- // Failure: cache the LAST GOOD value (if any) with a short jittered TTL
2238
- // so the credential cools down briefly without dropping out of the
2239
- // report. If we never had a good value, return null this cycle and
2240
- // don't write let the next poll retry.
2244
+ // Failure: apply a short jittered cool-down so the credential doesn't
2245
+ // re-hit the endpoint on every poll. Serve the last good value when we
2246
+ // have one (keeps the credential in the report); otherwise cache null
2247
+ // so a cold or throttled credential stops re-bursting until the window
2248
+ // expires and the next poll retries.
2241
2249
  const lastGood = this.#usageCache.getStale<UsageReport | null>(cacheKey)?.value ?? null;
2242
- if (lastGood !== null) {
2243
- const backoffJitter = USAGE_FAILURE_BACKOFF_MS * (Math.random() * 0.5 - 0.25);
2244
- const coolDown = Date.now() + USAGE_FAILURE_BACKOFF_MS + backoffJitter;
2245
- this.#usageCache.set(cacheKey, { value: lastGood, expiresAt: coolDown });
2246
- }
2250
+ const backoffJitter = USAGE_FAILURE_BACKOFF_MS * (Math.random() * 0.5 - 0.25);
2251
+ const coolDown = Date.now() + USAGE_FAILURE_BACKOFF_MS + backoffJitter;
2252
+ this.#usageCache.set(cacheKey, { value: lastGood, expiresAt: coolDown });
2247
2253
  return lastGood;
2248
2254
  })().finally(() => {
2249
2255
  this.#usageRequestInFlight.delete(cacheKey);
@@ -2360,7 +2366,7 @@ export class AuthStorage {
2360
2366
  headers: Record<string, string>,
2361
2367
  options?: { sessionId?: string; baseUrl?: string },
2362
2368
  ): boolean {
2363
- if (this.#fetchUsageReportsOverride || this.#store.fetchUsageReports) return false;
2369
+ if (this.#fetchUsageReportsOverride) return false;
2364
2370
 
2365
2371
  const credential = this.#resolveActiveOAuthCredential(provider, options?.sessionId);
2366
2372
  if (!credential) return false;
@@ -2380,6 +2386,14 @@ export class AuthStorage {
2380
2386
  if (credential.projectId && metadata.projectId === undefined) metadata.projectId = credential.projectId;
2381
2387
  const report: UsageReport = { ...parsedReport, metadata };
2382
2388
 
2389
+ const storeIngest = this.#store.ingestUsageReport?.bind(this.#store);
2390
+ if (storeIngest) {
2391
+ const ingested = storeIngest(provider, credential, report);
2392
+ if (ingested) this.#usageHeaderIngestAt.set(cacheKey, now);
2393
+ return ingested;
2394
+ }
2395
+
2396
+ if (this.#fetchUsageReportsOverride || this.#store.fetchUsageReports) return false;
2383
2397
  const prior = this.#usageCache.getStale<UsageReport | null>(cacheKey)?.value;
2384
2398
  let merged = report;
2385
2399
  if (prior && Array.isArray(prior.limits)) {
@@ -10,17 +10,22 @@ const CLAUDE_FABLE_ID = /(?:^|[./])claude[-.]fable(?:[-.]|$)/i;
10
10
  * replayed unsigned `thought` part is schema-accepted but silently discarded —
11
11
  * neither recalled nor influencing generation).
12
12
  *
13
- * Fable is the exception: replaying prior reasoning inside `<thinking>` /
14
- * `antml:thinking`-style assistant text is treated as a reasoning-extraction
15
- * attempt and can train the next turn to leak thoughts, so Fable receives the
16
- * reasoning as markdown-italic assistant prose instead. Harmony and Gemma are
17
- * also exceptions: their `renderThinking` emits chat-template control tokens
18
- * (`<|channel|>analysis`, `<|channel>thought`) that must not appear inside a
19
- * structured native message, so they fall back to a plain `<think>` block. Every
20
- * other dialect's thinking form is inline-safe XML tags or a markdown fence.
13
+ * Fable is the exception: Anthropic's `reasoning_extraction` classifier blocks
14
+ * requests that replay prior reasoning inside `<thinking>` / `antml:thinking`
15
+ * tags OR the older `_Hmm. …_` italic envelope it reads the wrapped
16
+ * chain-of-thought as an attempt to duplicate model outputs and refuses the
17
+ * whole turn. Fable therefore receives prior reasoning as bare assistant prose:
18
+ * no tag, no `_Hmm.` wrapper, no trailing newline.
19
+ * Heat is cumulative (block count and early-conversation position also raise
20
+ * it), so this lowers per-block signal but does not license unbounded replay.
21
+ * Harmony and Gemma are also exceptions: their `renderThinking` emits
22
+ * chat-template control tokens (`<|channel|>analysis`, `<|channel>thought`)
23
+ * that must not appear inside a structured native message, so they fall back to
24
+ * a plain `<think>` block. Every other dialect's thinking form is inline-safe
25
+ * XML tags or a markdown fence.
21
26
  *
22
- * The result ends with a trailing newline so the block stays separated from the
23
- * turn's reply text when the wire encoder concatenates parts.
27
+ * The result does not append a delimiter; callers that flatten adjacent blocks
28
+ * into a single string must insert their own separator.
24
29
  *
25
30
  * Distinct from {@link DialectDefinition.renderThinking}, which targets the
26
31
  * owned-dialect *text transport* where those control tokens are legal.
@@ -30,7 +35,7 @@ export function renderDemotedThinking(modelId: string, text: string): string {
30
35
  text = text.toWellFormed();
31
36
  const canonicalId = bareModelId(modelId);
32
37
  const dialect = preferredDialect(modelId);
33
- if (CLAUDE_FABLE_ID.test(canonicalId)) return `_Hmm. ${text}_\n`;
34
- if (dialect === "harmony" || dialect === "gemma") return `<think>\n${text}\n</think>\n`;
35
- return `${getDialectDefinition(dialect).renderThinking(text)}\n`;
38
+ if (CLAUDE_FABLE_ID.test(canonicalId)) return text;
39
+ if (dialect === "harmony" || dialect === "gemma") return `<think>\n${text}\n</think>`;
40
+ return getDialectDefinition(dialect).renderThinking(text);
36
41
  }