@oh-my-pi/pi-ai 17.3.3 → 17.3.5

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/types/auth-broker/client.d.ts +8 -1
  3. package/dist/types/auth-storage.d.ts +15 -6
  4. package/dist/types/error/rate-limit.d.ts +5 -2
  5. package/dist/types/index.d.ts +1 -0
  6. package/dist/types/oneshot-retry.d.ts +89 -0
  7. package/dist/types/providers/anthropic-wire.d.ts +18 -5
  8. package/dist/types/providers/openai-shared.d.ts +1 -1
  9. package/dist/types/types.d.ts +5 -4
  10. package/dist/types/usage/kimi.d.ts +3 -1
  11. package/dist/types/utils/schema/strict-tool-validation.d.ts +10 -1
  12. package/dist/types/utils/schema/wire.d.ts +6 -0
  13. package/dist/types/utils/thinking-loop.d.ts +12 -9
  14. package/package.json +5 -5
  15. package/src/auth-broker/client.ts +24 -7
  16. package/src/auth-broker/remote-store.ts +48 -3
  17. package/src/auth-broker/server.ts +1 -1
  18. package/src/auth-storage.ts +182 -52
  19. package/src/error/flags.ts +6 -2
  20. package/src/error/rate-limit.ts +31 -2
  21. package/src/index.ts +1 -0
  22. package/src/oneshot-retry.ts +230 -0
  23. package/src/providers/anthropic-messages-server.ts +5 -5
  24. package/src/providers/anthropic-wire.ts +31 -5
  25. package/src/providers/anthropic.ts +15 -4
  26. package/src/providers/cursor.ts +44 -4
  27. package/src/providers/openai-completions.ts +107 -63
  28. package/src/providers/openai-responses.ts +13 -10
  29. package/src/providers/openai-shared.ts +5 -3
  30. package/src/registry/alibaba-coding-plan.ts +2 -2
  31. package/src/registry/alibaba-token-plan.ts +6 -2
  32. package/src/registry/oauth/github-copilot.ts +1 -22
  33. package/src/registry/oauth/kimi.ts +20 -0
  34. package/src/stream.ts +59 -57
  35. package/src/types.ts +5 -4
  36. package/src/usage/alibaba-token-plan.ts +89 -34
  37. package/src/usage/kimi.ts +15 -0
  38. package/src/usage/umans.ts +93 -20
  39. package/src/utils/leaked-thinking-stream.ts +2 -2
  40. package/src/utils/schema/strict-tool-validation.ts +39 -5
  41. package/src/utils/schema/wire.ts +31 -0
  42. package/src/utils/thinking-loop.ts +96 -63
package/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.3.5] - 2026-08-16
6
+
7
+ ### Added
8
+
9
+ - Added retryable oneshot completion support (`retryTransientCompletion`) so non-agent LLM calls correctly retry on transient provider failures (Anthropic overload/rate-limit errors, HTTP 429/500/502/503/529), honoring provider-supplied retry-after timing before giving up.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed xAI availability detection so paid-key-only setups correctly default to `xai/grok-4.5` instead of the free SuperGrok catalog; explicit `xai-oauth/…` selectors still work as before.
14
+ - Fixed xAI Responses requests sending unsupported parameters (reasoning summary, presence/frequency penalties) that some models rejected.
15
+ - Fixed Umans usage reporting incorrectly marking quota as exhausted based on raw request counts instead of actual weighted usage, and improved the usage display to show both a soft-cap warning and a hard exhaustion limit with an accurate countdown to reset.
16
+ - Fixed `omp usage invalidate` to fully clear stale usage data and force a fresh refresh, so upgraded subscriptions no longer show outdated quota information.
17
+ - Improved session recovery to correctly treat certain Cursor HTTP/2 connection errors as transient instead of ending the session.
18
+ - Fixed OpenAI-compatible streams (e.g. DeepSeek) that are cut off mid-generation being silently treated as a completed response instead of being retried.
19
+ - Fixed DeepSeek resource-exhaustion interruptions not being automatically retried.
20
+ - Fixed tool-call IDs being lost during same-model replay, which could break correlation with custom gateways.
21
+ - Fixed Kimi Code multi-account routing to prefer accounts with more available quota, respect usage-limit cooldowns, and keep consistent usage history across token refreshes.
22
+ - Fixed Anthropic custom signing-proxy conversations losing tool-search results and thinking content during replay.
23
+ - Fixed rare runaway response loops across model providers so they now fail gracefully instead of repeating indefinitely.
24
+ - Fixed xAI rejecting entire turns due to certain MCP tool schema shapes, restoring compatibility while isolating any remaining incompatible tools rather than failing the whole request.
25
+ - Fixed Alibaba DashScope/Bailian transient per-minute rate limits being misclassified as full quota exhaustion, causing unnecessary long backoffs instead of quick retries.
26
+ - Fixed Anthropic-compatible streams dropping thinking content, which broke replay of prior reasoning.
27
+ - Updated the Alibaba Coding Plan China login flow to point to the current Bailian API-key management console.
28
+
29
+ ## [17.3.4] - 2026-08-14
30
+
31
+ ### Fixed
32
+
33
+ - Fixed `omp usage invalidate` to discard stale OAuth and API-key usage snapshots, then force a cache-bypassing, per-provider serialized refresh with a broker request budget sized for the full unfiltered account batch, so upgraded subscriptions do not silently retain pre-change quota data.
34
+ - Fixed quota reporting and Cookie capture guidance for China (Beijing) Alibaba Token Plan credentials ([#8509](https://github.com/can1357/oh-my-pi/issues/8509)).
35
+
5
36
  ## [17.3.3] - 2026-08-14
6
37
 
7
38
  ### Fixed
@@ -66,7 +66,14 @@ export declare class AuthBrokerClient {
66
66
  openSnapshotStream(opts?: {
67
67
  signal?: AbortSignal;
68
68
  }): AsyncGenerator<SnapshotStreamEvent>;
69
- fetchUsage(signal?: AbortSignal): Promise<UsageResponse>;
69
+ /**
70
+ * Fetch aggregate broker usage with a timeout sized for serialized
71
+ * same-provider account probes.
72
+ */
73
+ fetchUsage(options?: {
74
+ signal?: AbortSignal;
75
+ maxAccountsPerProvider?: number;
76
+ }): Promise<UsageResponse>;
70
77
  /** Recorded usage-limit snapshots from the broker host, oldest first. */
71
78
  fetchUsageHistory(query?: {
72
79
  sinceMs?: number;
@@ -804,10 +804,20 @@ export declare class AuthStorage {
804
804
  */
805
805
  has(provider: string): boolean;
806
806
  /**
807
- * Check if any form of auth is configured for a provider.
808
- * Unlike getApiKey(), this doesn't refresh OAuth tokens.
807
+ * Dedicated auth for default-model availability (picker / `getAvailable`).
808
+ * Unlike {@link getApiKey}, this does not refresh OAuth tokens, and unlike
809
+ * {@link hasResolvableAuth} it ignores cross-provider env aliases so
810
+ * `XAI_API_KEY` does not auto-select SuperGrok (`xai-oauth`).
809
811
  */
810
812
  hasAuth(provider: string): boolean;
813
+ /**
814
+ * Whether a request could resolve a key for this provider, including
815
+ * cross-provider env aliases (`xai-oauth` borrowing `XAI_API_KEY`).
816
+ * Use this for explicit model preflight (`xai-oauth/grok-4.5`); use
817
+ * {@link hasAuth} for auto-availability so the default picker stays on
818
+ * paid `xai` when only `XAI_API_KEY` is set.
819
+ */
820
+ hasResolvableAuth(provider: string): boolean;
811
821
  /**
812
822
  * True iff a dedicated, non-env credential source is configured for this
813
823
  * provider — i.e. anything in the cascade EXCEPT `getEnvApiKey(provider)`.
@@ -1111,10 +1121,9 @@ export declare class AuthStorage {
1111
1121
  signal?: AbortSignal;
1112
1122
  }): Promise<ResetCreditRedeemOutcome>;
1113
1123
  /**
1114
- * Force-invalidate cached usage reports so the next fetch retrieves fresh
1115
- * values from upstream providers. If `provider` is specified, only that
1116
- * provider's credentials are invalidated; otherwise, all credentials in the
1117
- * store are invalidated.
1124
+ * Discard cached usage reports before a user-requested refresh. The next
1125
+ * read probes upstream serially per provider; a failure reports no fresh
1126
+ * usage instead of replaying an invalidated last-good snapshot.
1118
1127
  */
1119
1128
  invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void>;
1120
1129
  invalidateCredentialMatching(provider: string, apiKey: string, options?: InvalidateCredentialMatchingOptions): Promise<boolean>;
@@ -3,11 +3,14 @@
3
3
  * Ported from opencode-antigravity-auth plugin for consistency.
4
4
  */
5
5
  export type RateLimitReason = "QUOTA_EXHAUSTED" | "INSUFFICIENT_G1_CREDITS_BALANCE" | "RATE_LIMIT_EXCEEDED" | "CONCURRENT_LIMIT" | "MODEL_CAPACITY_EXHAUSTED" | "SERVER_ERROR" | "UNKNOWN";
6
+ /** True for DashScope/Bailian's documented OpenAI-compatible TPM/TPS throttle. */
7
+ export declare function isDashScopeTokenLimitText(errorMessage: string): boolean;
6
8
  /**
7
9
  * Classify a rate-limit error message into a reason category.
8
10
  * Priority order: explicit details in a resource-exhausted error > QUOTA
9
- * (Antigravity "quota will reset") > CONCURRENT_LIMIT > MODEL_CAPACITY >
10
- * QUOTA (account) > RATE_LIMIT > QUOTA (generic) > SERVER_ERROR > bare resource-exhausted > UNKNOWN.
11
+ * (Antigravity "quota will reset") > CN quota > DASHSCOPE_TOKEN_LIMIT (TPM/TPS
12
+ * throttle) > CONCURRENT_LIMIT > MODEL_CAPACITY > QUOTA (account) > RATE_LIMIT >
13
+ * QUOTA (generic) > SERVER_ERROR > bare resource-exhausted > UNKNOWN.
11
14
  *
12
15
  * Bare "resource exhausted" / "resource_exhausted" maps to MODEL_CAPACITY (transient, short wait).
13
16
  * Explicit details such as "quota exceeded" retain their normal classification.
@@ -6,6 +6,7 @@ export * from "./auth-gateway/types.js";
6
6
  export * from "./auth-retry.js";
7
7
  export * from "./auth-storage.js";
8
8
  export * from "./error/rate-limit.js";
9
+ export * from "./oneshot-retry.js";
9
10
  export * from "./provider-details.js";
10
11
  export * from "./providers/anthropic.js";
11
12
  export * from "./providers/anthropic-client.js";
@@ -0,0 +1,89 @@
1
+ import type { AssistantMessage } from "./types.js";
2
+ import { type HeadersLike } from "./utils/retry-after.js";
3
+ /**
4
+ * Transient-failure retry for **oneshot** (non-agent-loop) completions.
5
+ *
6
+ * Why this exists: `streamSimple`/`completeSimple` retry *auth* failures
7
+ * (credential rotation) but deliberately surface *transient* provider failures
8
+ * — Anthropic `overloaded_error`, `rate_limit_error`, HTTP 429/500/502/503/529
9
+ * — as a **resolved** `AssistantMessage` with `stopReason: "error"`. For the
10
+ * main agent turn that is correct: `TurnRecovery` owns recovery there, and it
11
+ * must refuse to replay once tool calls or visible text have streamed.
12
+ *
13
+ * Oneshots have no such hazard. A summary, title, handoff, or image
14
+ * description produces no side effects, so re-issuing the whole request is
15
+ * safe and is almost always what the caller wants. Before this helper every
16
+ * oneshot call site had to re-implement that decision, and most did not —
17
+ * failing on the first blip, or swallowing it into `null` so a transient
18
+ * overload was indistinguishable from a legitimate empty result.
19
+ *
20
+ * Classification reuses the existing provider predicates (`AIError`), so the
21
+ * set of retryable Anthropic failures stays defined in exactly one place.
22
+ * Usage limits are included: unlike the provider loop — which excludes them so
23
+ * credential rotation can own them — a oneshot has no rotation layer above it,
24
+ * and the retry hint the provider supplies (`retry-after`, "try again in ~5m")
25
+ * is honored, so waiting is the correct response.
26
+ */
27
+ export interface OneshotRetryOptions {
28
+ /** Total attempts, including the first. Default 3. Values < 1 are treated as 1. */
29
+ maxAttempts?: number;
30
+ /** First backoff step in ms; doubles per attempt. Default 500. */
31
+ baseDelayMs?: number;
32
+ /**
33
+ * Upper bound for a single wait. Default 30_000. A provider retry hint
34
+ * longer than this aborts the retry instead of parking the caller — the
35
+ * error surfaces so higher-level recovery (or the user) can decide.
36
+ */
37
+ maxDelayMs?: number;
38
+ /**
39
+ * Stops further attempts. Two distinct paths, both preserving the caller's
40
+ * intent: an abort already visible when an attempt settles surfaces that
41
+ * attempt's own result (`completeSimple` reports `stopReason: "aborted"`),
42
+ * while an abort that lands during the backoff wait rejects with the abort
43
+ * reason — a user cancel stays a cancel and is never relabelled as the
44
+ * provider failure we happened to be waiting on.
45
+ *
46
+ * This helper does NOT pass the signal into `run` — cancelling the in-flight
47
+ * request is the closure's job, because a per-attempt deadline must be
48
+ * rebuilt on every attempt. Construct it inside `run`
49
+ * (`signal: AbortSignal.timeout(MS)`, or `AbortSignal.any([outer, perAttempt])`);
50
+ * a deadline captured outside would fire once and then abort every retry,
51
+ * silently turning this helper into a single attempt.
52
+ */
53
+ signal?: AbortSignal;
54
+ /**
55
+ * Headers of the attempt that just failed, used to honor `retry-after`.
56
+ *
57
+ * Load-bearing: a transient Anthropic failure arrives as a **resolved**
58
+ * `AssistantMessage`, and `AssistantMessage` carries no headers — so without
59
+ * this the real `retry-after` / `x-ratelimit-reset` values on a 429/529 are
60
+ * invisible and only the (usually hint-free) error text is available.
61
+ * Callers that already capture headers via `SimpleStreamOptions.onResponse`
62
+ * should return the latest capture here; it is read once per failed attempt.
63
+ * Thrown errors need no wiring — headers are recovered from the error itself.
64
+ */
65
+ getResponseHeaders?: () => HeadersLike;
66
+ /** Observability hook. Fires immediately before sleeping. */
67
+ onRetry?: (info: OneshotRetryInfo) => void;
68
+ }
69
+ export interface OneshotRetryInfo {
70
+ /** 1-based index of the attempt that just failed. */
71
+ attempt: number;
72
+ maxAttempts: number;
73
+ delayMs: number;
74
+ /** True when `delayMs` came from a provider retry hint rather than backoff. */
75
+ fromRetryHint: boolean;
76
+ errorMessage: string;
77
+ /** `AIError` classification bits of the failure. */
78
+ errorId: number;
79
+ }
80
+ /**
81
+ * Run a oneshot completion, retrying transient provider failures.
82
+ *
83
+ * Handles both failure shapes: a resolved `AssistantMessage` carrying
84
+ * `stopReason: "error"` (what `completeSimple` produces) and a thrown error
85
+ * (what the raw HTTP helpers produce). A non-retryable failure is returned or
86
+ * rethrown unchanged, so existing caller error handling keeps working — this
87
+ * only removes the *first-blip* failure mode.
88
+ */
89
+ export declare function retryTransientCompletion(run: (attempt: number) => Promise<AssistantMessage>, options?: OneshotRetryOptions): Promise<AssistantMessage>;
@@ -69,6 +69,10 @@ export type ServerToolUseBlockParam = {
69
69
  export type WebSearchServerToolUseBlockParam = ServerToolUseBlockParam & {
70
70
  name: "web_search";
71
71
  };
72
+ /** Tool-search server-tool call whose matching result is replayable by omp. */
73
+ export type ToolSearchServerToolUseBlockParam = ServerToolUseBlockParam & {
74
+ name: "tool_search_tool_regex" | "tool_search_tool_bm25";
75
+ };
72
76
  /** Native web-search result replayed inside an assistant turn. */
73
77
  export type WebSearchToolResultBlockParam = {
74
78
  type: "web_search_tool_result";
@@ -76,14 +80,23 @@ export type WebSearchToolResultBlockParam = {
76
80
  content: unknown;
77
81
  [key: string]: unknown;
78
82
  };
79
- /** True for the complete native web-search history variants omp can replay. */
80
- export declare function isAnthropicWebSearchHistoryBlock(block: {
83
+ /** Native tool-search result replayed inside an assistant turn. */
84
+ export type ToolSearchToolResultBlockParam = {
85
+ type: "tool_search_tool_result";
86
+ tool_use_id: string;
87
+ content: unknown;
88
+ [key: string]: unknown;
89
+ };
90
+ /** Anthropic server-tool history variants omp can replay atomically. */
91
+ export type AnthropicServerToolHistoryBlockParam = WebSearchServerToolUseBlockParam | WebSearchToolResultBlockParam | ToolSearchServerToolUseBlockParam | ToolSearchToolResultBlockParam;
92
+ /** True when a block is complete Anthropic server-tool history omp can replay. */
93
+ export declare function isAnthropicServerToolHistoryBlock(block: {
81
94
  type: string;
82
95
  name?: unknown;
83
96
  id?: unknown;
84
97
  tool_use_id?: unknown;
85
98
  content?: unknown;
86
- }): block is WebSearchServerToolUseBlockParam | WebSearchToolResultBlockParam;
99
+ }): block is AnthropicServerToolHistoryBlockParam;
87
100
  export type ThinkingBlockParam = {
88
101
  type: "thinking";
89
102
  thinking: string;
@@ -108,7 +121,7 @@ export type FallbackBlockParam = {
108
121
  model: string;
109
122
  };
110
123
  };
111
- export type ContentBlockParam = TextBlockParam | ImageBlockParam | ToolUseBlockParam | ToolResultBlockParam | ServerToolUseBlockParam | WebSearchToolResultBlockParam | ThinkingBlockParam | RedactedThinkingBlockParam | FallbackBlockParam;
124
+ export type ContentBlockParam = TextBlockParam | ImageBlockParam | ToolUseBlockParam | ToolResultBlockParam | ServerToolUseBlockParam | WebSearchToolResultBlockParam | ToolSearchToolResultBlockParam | ThinkingBlockParam | RedactedThinkingBlockParam | FallbackBlockParam;
112
125
  /**
113
126
  * A single conversation turn.
114
127
  *
@@ -284,7 +297,7 @@ export type ResponseContentBlock = {
284
297
  id: string;
285
298
  name: string;
286
299
  input?: Record<string, unknown> | null;
287
- } | ServerToolUseBlockParam | WebSearchToolResultBlockParam | {
300
+ } | ServerToolUseBlockParam | WebSearchToolResultBlockParam | ToolSearchToolResultBlockParam | {
288
301
  type: "fallback";
289
302
  from: {
290
303
  model: string;
@@ -582,7 +582,7 @@ type CommonSamplingOptions = Pick<StreamOptions, "temperature" | "topP" | "topK"
582
582
  * reflect the model's context window rather than the upstream output limit.
583
583
  */
584
584
  export declare function applyCommonResponsesSamplingParams<P extends CommonResponsesParams>(params: P, options: CommonSamplingOptions | undefined, model: Pick<Model, "provider" | "api" | "id" | "omitMaxOutputTokens" | "maxTokens"> & {
585
- compat: Pick<ResolvedOpenAISharedCompat, "supportsSamplingParams">;
585
+ compat: Pick<ResolvedOpenAISharedCompat, "supportsSamplingParams" | "supportsPenaltyAndStopParams">;
586
586
  }): void;
587
587
  type ReasoningOptions = {
588
588
  reasoning?: string;
@@ -521,19 +521,20 @@ export interface AnthropicFallbackContent {
521
521
  };
522
522
  }
523
523
  /**
524
- * Verbatim Anthropic web-search call/result retained for same-provider
525
- * history replay. Other providers discard it in `transformMessages`.
524
+ * Verbatim Anthropic web-search or tool-search call/result retained for
525
+ * same-provider history replay. Other providers discard it in
526
+ * `transformMessages`.
526
527
  */
527
528
  export interface AnthropicServerToolContent {
528
529
  type: "anthropicServerTool";
529
530
  block: {
530
531
  type: "server_tool_use";
531
532
  id: string;
532
- name: "web_search";
533
+ name: "web_search" | "tool_search_tool_regex" | "tool_search_tool_bm25";
533
534
  input?: Record<string, unknown> | null;
534
535
  [key: string]: unknown;
535
536
  } | {
536
- type: "web_search_tool_result";
537
+ type: "web_search_tool_result" | "tool_search_tool_result";
537
538
  tool_use_id: string;
538
539
  content: unknown;
539
540
  [key: string]: unknown;
@@ -1,2 +1,4 @@
1
- import type { UsageProvider } from "../usage.js";
1
+ import type { CredentialRankingStrategy, UsageProvider } from "../usage.js";
2
2
  export declare const kimiUsageProvider: UsageProvider;
3
+ /** Ranks Kimi OAuth accounts by the canonical 5-hour and 7-day quota windows. */
4
+ export declare const kimiRankingStrategy: CredentialRankingStrategy;
@@ -7,10 +7,19 @@
7
7
  * copied onto a `type: "null"` branch, or an `enum` placed on an `array`
8
8
  * schema instead of its `items`). One such tool 400s the entire turn, so
9
9
  * callers quarantine just the offending tool. See issue #2652.
10
+ *
11
+ * xAI additionally rejects a leftover *root* `anyOf`/`oneOf` whose branches
12
+ * are not objects ("tool parameter root must be an object type"). That class
13
+ * is opt-in via {@link FindStrictToolSchemaViolationOptions.rejectXaiRootObjectUnion}
14
+ * so OpenAI/Azure/Codex keep valid object-root unions.
10
15
  */
16
+ export interface FindStrictToolSchemaViolationOptions {
17
+ /** xAI (paid + OAuth) only: leftover object-root unions 400 the whole turn. */
18
+ rejectXaiRootObjectUnion?: boolean;
19
+ }
11
20
  /**
12
21
  * Walk a tool parameter schema for OpenAI-strict `enum`/`const`-vs-`type`
13
22
  * contradictions. Returns a JSON-pointer-ish path to the first offending node,
14
23
  * or `null` when the schema is safe to emit.
15
24
  */
16
- export declare function findStrictToolSchemaViolation(schema: unknown, path?: string): string | null;
25
+ export declare function findStrictToolSchemaViolation(schema: unknown, path?: string, options?: FindStrictToolSchemaViolationOptions): string | null;
@@ -14,6 +14,12 @@ import type { Tool } from "../../types.js";
14
14
  * methods, while raw JSON Schema is a plain object.
15
15
  */
16
16
  export declare function isArkSchema(value: unknown): value is Type;
17
+ /**
18
+ * Return an xAI-compatible copy of an object-root schema whose union consists
19
+ * only of typeless required-key fragments. Other providers must retain the
20
+ * union because it is a real model-facing constraint.
21
+ */
22
+ export declare function flattenExclusiveRequiredRootUnion(schema: Record<string, unknown>): Record<string, unknown>;
17
23
  /**
18
24
  * Normalize `{}` (an unconstrained schema) to boolean `true` in every
19
25
  * schema-valued position. JSON Schema draft 2020-12 §4.3.1 defines them as
@@ -5,11 +5,12 @@ import { AssistantMessageEventStream } from "./event-stream.js";
5
5
  * classifiers treat it as a transient (retryable) stop without bespoke rules. */
6
6
  export declare const THINKING_LOOP_ERROR_MARKER = "Thinking loop detected";
7
7
  /**
8
- * True when `model.id` belongs to a family guarded for thinking/response loops:
9
- * Gemini, DeepSeek, or Grok.
8
+ * True when `model.id` belongs to a family guarded by the semantic loop
9
+ * heuristics: Gemini, DeepSeek, or Grok. Exact suffix-cycle detection applies to
10
+ * every enabled model independently of this predicate.
10
11
  *
11
12
  * Model identity is derived only from its id; provider and compatibility metadata
12
- * do not opt opaque aliases into the guard.
13
+ * do not opt opaque aliases into semantic detection.
13
14
  */
14
15
  export declare function isLoopGuardedModel(model: Model<Api>, options?: StreamOptions): boolean;
15
16
  /**
@@ -19,6 +20,8 @@ export declare function isLoopGuardedModel(model: Model<Api>, options?: StreamOp
19
20
  */
20
21
  export declare class ThinkingLoopDetector {
21
22
  #private;
23
+ private readonly semanticHeuristics;
24
+ constructor(semanticHeuristics?: boolean);
22
25
  push(delta: string): string | null;
23
26
  /** Process the buffered trailing paragraph (one with no blank-line / heading
24
27
  * terminator). Called when the thinking block ends so the final segment —
@@ -75,12 +78,12 @@ export declare class GeminiHeaderRunDetector {
75
78
  */
76
79
  export declare function guardThinkingLoopStream(inner: AssistantMessageEventStream, model: Model<Api>, controller: AbortController, options?: StreamOptions): AssistantMessageEventStream;
77
80
  /**
78
- * Apply the loop guard around a provider dispatch. For non-guarded models
79
- * (or when disabled) this is a transparent pass-through. For guarded models it injects a
80
- * guard abort signal into the provider call so a detected loop tears down the
81
- * upstream, then wraps the returned stream. The guard only raises the retryable
82
- * stall; bounding the re-samples and the final cook pass lives in the
83
- * result-awaiting caller.
81
+ * Apply the loop guard around a provider dispatch. Unless explicitly disabled,
82
+ * every model gets exact suffix-cycle detection; Gemini, DeepSeek, and Grok also
83
+ * get the semantic heuristics selected by {@link isLoopGuardedModel}. The guard
84
+ * injects an abort signal into the provider call so a detected loop tears down
85
+ * the upstream, then wraps the returned stream. Bounding result-path re-samples
86
+ * lives in the result-awaiting caller.
84
87
  */
85
88
  export declare function withThinkingLoopGuard<O extends {
86
89
  signal?: AbortSignal;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "17.3.3",
4
+ "version": "17.3.5",
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,10 +38,10 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/omptype": "17.3.3",
42
- "@oh-my-pi/pi-catalog": "17.3.3",
43
- "@oh-my-pi/pi-utils": "17.3.3",
44
- "@oh-my-pi/pi-wire": "17.3.3"
41
+ "@oh-my-pi/omptype": "17.3.5",
42
+ "@oh-my-pi/pi-catalog": "17.3.5",
43
+ "@oh-my-pi/pi-utils": "17.3.5",
44
+ "@oh-my-pi/pi-wire": "17.3.5"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@bufbuild/protoc-gen-es": "^2.12.1",
@@ -249,12 +249,23 @@ export class AuthBrokerClient {
249
249
  }
250
250
  }
251
251
 
252
- fetchUsage(signal?: AbortSignal): Promise<UsageResponse> {
253
- // Validates the envelope (`generatedAt`, `reports[].provider`, `limits`,
254
- // `metadata`) but leaves provider-specific extension fields permissive so
255
- // the broker can ship new shapes ahead of the client. `raw` is accepted
256
- // but normally stripped by the broker before send.
257
- return this.#request<UsageResponse>("GET", "/v1/usage", { schema: "usageResponseSchema", signal });
252
+ /**
253
+ * Fetch aggregate broker usage with a timeout sized for serialized
254
+ * same-provider account probes.
255
+ */
256
+ fetchUsage(options: { signal?: AbortSignal; maxAccountsPerProvider?: number } = {}): Promise<UsageResponse> {
257
+ const requestedAccountCount = options.maxAccountsPerProvider;
258
+ const accountCount =
259
+ typeof requestedAccountCount === "number" && Number.isFinite(requestedAccountCount)
260
+ ? Math.max(1, Math.floor(requestedAccountCount))
261
+ : 1;
262
+ const perAccountTimeoutMs = Math.max(DEFAULT_TIMEOUT_MS, this.#timeoutMs);
263
+ const timeoutMs = perAccountTimeoutMs * (accountCount + 1);
264
+ return this.#request<UsageResponse>("GET", "/v1/usage", {
265
+ schema: "usageResponseSchema",
266
+ signal: options.signal,
267
+ timeoutMs,
268
+ });
258
269
  }
259
270
 
260
271
  /** Recorded usage-limit snapshots from the broker host, oldest first. */
@@ -369,7 +380,13 @@ export class AuthBrokerClient {
369
380
  async #request<t>(
370
381
  method: "GET" | "POST" | "DELETE",
371
382
  path: string,
372
- opts: { schema: AuthBrokerResponseSchemaName; auth?: boolean; body?: unknown; signal?: AbortSignal },
383
+ opts: {
384
+ schema: AuthBrokerResponseSchemaName;
385
+ auth?: boolean;
386
+ body?: unknown;
387
+ signal?: AbortSignal;
388
+ timeoutMs?: number;
389
+ },
373
390
  ): Promise<t> {
374
391
  const response = await this.#fetchRaw(method, path, opts);
375
392
  const text = await response.text();
@@ -253,6 +253,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
253
253
  #usageInflight?: Promise<UsageReport[] | null>;
254
254
  #credentialBlockReconcileAfter: Map<string, number> = new Map();
255
255
  #usageCacheEpoch = 0;
256
+ /** Raw broker credentials retained to size aggregate usage requests before account-pool filtering. */
257
+ #brokerUsageProviderByCredentialId = new Map<number, Provider>();
258
+ #brokerUsageAccountCounts = new Map<Provider, number>();
256
259
  /** Per-snapshot lookup of oauth credentials by provider; rebuilt when `#snapshot` is replaced. */
257
260
  #usageFilterLookup?: { snapshot: SnapshotResponse; byProvider: Map<Provider, OAuthCredential[]> };
258
261
  /** Memoized `#filterUsageReports` output, keyed on (input identity, lookup identity). */
@@ -296,6 +299,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
296
299
 
297
300
  #applySnapshot(snapshot: SnapshotResponse, generation: number, protectNewBlocks = true): void {
298
301
  const nowMs = Date.now();
302
+ this.#replaceBrokerUsageAccounts(snapshot.credentials);
299
303
  const previousCredentials = this.#snapshot.credentials;
300
304
  const credentials = snapshot.credentials
301
305
  .filter(entry => isCredentialInAccountPool(entry, this.#accountPool))
@@ -427,8 +431,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
427
431
  generation: number,
428
432
  serverNowMs: number,
429
433
  ): void {
434
+ this.#upsertBrokerUsageAccount(entry);
430
435
  if (!isCredentialInAccountPool(entry, this.#accountPool)) {
431
- this.#removeStreamCredential(entry.id, refresher, generation, serverNowMs);
436
+ this.#removeStreamCredential(entry.id, refresher, generation, serverNowMs, { retainBrokerUsageAccount: true });
432
437
  return;
433
438
  }
434
439
  const incoming = this.#normalizeSnapshotEntryBlocks(entry, Date.now());
@@ -446,7 +451,14 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
446
451
  this.#snapshotReceivedAt = Date.now();
447
452
  }
448
453
 
449
- #removeStreamCredential(id: number, refresher: RefresherSchedule, generation: number, serverNowMs: number): void {
454
+ #removeStreamCredential(
455
+ id: number,
456
+ refresher: RefresherSchedule,
457
+ generation: number,
458
+ serverNowMs: number,
459
+ options?: { retainBrokerUsageAccount?: boolean },
460
+ ): void {
461
+ if (!options?.retainBrokerUsageAccount) this.#removeBrokerUsageAccount(id);
450
462
  const removed = this.#snapshot.credentials.find(entry => entry.id === id);
451
463
  if (removed?.blocks && removed.blocks.length > 0) this.#invalidateUsageCache();
452
464
  const credentials = this.#snapshot.credentials.filter(entry => entry.id !== id);
@@ -1066,6 +1078,39 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1066
1078
  });
1067
1079
  }
1068
1080
 
1081
+ #replaceBrokerUsageAccounts(entries: readonly SnapshotEntry[]): void {
1082
+ this.#brokerUsageProviderByCredentialId.clear();
1083
+ this.#brokerUsageAccountCounts.clear();
1084
+ for (const entry of entries) this.#upsertBrokerUsageAccount(entry);
1085
+ }
1086
+
1087
+ #upsertBrokerUsageAccount(entry: Pick<SnapshotEntry, "id" | "provider">): void {
1088
+ const previous = this.#brokerUsageProviderByCredentialId.get(entry.id);
1089
+ if (previous === entry.provider) return;
1090
+ if (previous !== undefined) {
1091
+ const count = this.#brokerUsageAccountCounts.get(previous) ?? 0;
1092
+ if (count <= 1) this.#brokerUsageAccountCounts.delete(previous);
1093
+ else this.#brokerUsageAccountCounts.set(previous, count - 1);
1094
+ }
1095
+ this.#brokerUsageProviderByCredentialId.set(entry.id, entry.provider);
1096
+ this.#brokerUsageAccountCounts.set(entry.provider, (this.#brokerUsageAccountCounts.get(entry.provider) ?? 0) + 1);
1097
+ }
1098
+
1099
+ #removeBrokerUsageAccount(id: number): void {
1100
+ const provider = this.#brokerUsageProviderByCredentialId.get(id);
1101
+ if (provider === undefined) return;
1102
+ this.#brokerUsageProviderByCredentialId.delete(id);
1103
+ const count = this.#brokerUsageAccountCounts.get(provider) ?? 0;
1104
+ if (count <= 1) this.#brokerUsageAccountCounts.delete(provider);
1105
+ else this.#brokerUsageAccountCounts.set(provider, count - 1);
1106
+ }
1107
+
1108
+ #maxBrokerUsageAccounts(): number {
1109
+ let maximum = 1;
1110
+ for (const count of this.#brokerUsageAccountCounts.values()) maximum = Math.max(maximum, count);
1111
+ return maximum;
1112
+ }
1113
+
1069
1114
  #loadUsageReports(): Promise<UsageReport[] | null> {
1070
1115
  const cached = this.#usageCache;
1071
1116
  if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) {
@@ -1074,7 +1119,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1074
1119
  if (this.#usageInflight) return this.#usageInflight;
1075
1120
  const epoch = this.#usageCacheEpoch;
1076
1121
  const inflight = this.#client
1077
- .fetchUsage()
1122
+ .fetchUsage({ maxAccountsPerProvider: this.#maxBrokerUsageAccounts() })
1078
1123
  .then(body => {
1079
1124
  if (epoch !== this.#usageCacheEpoch) return this.#loadUsageReports();
1080
1125
  this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
@@ -741,7 +741,7 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
741
741
  }
742
742
  if (req.method === "POST" && pathname === "/v1/usage/stale") {
743
743
  try {
744
- opts.storage.invalidateUsageCache?.();
744
+ await opts.storage.invalidateUsageCache?.();
745
745
  logger.info("auth-broker usage cache invalidated", { peer });
746
746
  return json(200, { ok: true });
747
747
  } catch (error) {