@oh-my-pi/pi-ai 13.3.13 → 13.3.14

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,12 +2,68 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.3.14] - 2026-02-28
6
+
7
+ ### Added
8
+
9
+ - Exported schema utilities from new `./utils/schema` module, consolidating JSON Schema handling across providers
10
+ - Added `CredentialRankingStrategy` interface for providers to implement usage-based credential selection
11
+ - Added `claudeRankingStrategy` for Anthropic OAuth credentials to enable smart multi-account selection based on usage windows
12
+ - Added `codexRankingStrategy` for OpenAI Codex OAuth credentials with priority boost for fresh 5-hour window starts
13
+ - Added `adaptSchemaForStrict()` helper for unified OpenAI strict schema enforcement across providers
14
+ - Added schema equality and merging utilities: `areJsonValuesEqual()`, `mergeCompatibleEnumSchemas()`, `mergePropertySchemas()`
15
+ - Added Cloud Code Assist schema normalization: `copySchemaWithout()`, `stripResidualCombiners()`, `prepareSchemaForCCA()`
16
+ - Added `sanitizeSchemaForGoogle()` and `sanitizeSchemaForCCA()` for provider-specific schema sanitization
17
+ - Added `StringEnum()` helper for creating string enum schemas compatible with Google and other providers
18
+ - Added `enforceStrictSchema()` and `sanitizeSchemaForStrictMode()` for OpenAI strict mode schema validation
19
+ - Added package exports for `./utils/schema` and `./utils/schema/*` subpaths
20
+ - Added `validateSchemaCompatibility()` to statically audit a JSON Schema against provider-specific rules (`openai-strict`, `google`, `cloud-code-assist-claude`) and return structured violations
21
+ - Added `validateStrictSchemaEnforcement()` to verify the strict-fail-open contract: enforced schemas pass strict validation, failed schemas return the original object identity
22
+ - Added `COMBINATOR_KEYS` (`anyOf`, `allOf`, `oneOf`) and `CCA_UNSUPPORTED_SCHEMA_FIELDS` as exported constants in `fields.ts` to eliminate duplication across modules
23
+ - Added `tryEnforceStrictSchema` result cache (`WeakMap`) to avoid redundant sanitize + enforce work for the same schema object
24
+ - Added comprehensive schema normalization test suite (`schema-normalization.test.ts`) covering strict mode, Google, and Cloud Code Assist normalization paths
25
+ - Added schema compatibility validation test suite (`schema-compatibility.test.ts`) covering all three provider targets
26
+
27
+ ### Changed
28
+
29
+ - Moved schema utilities from `./utils/typebox-helpers` to new `./utils/schema` module with expanded functionality
30
+ - Refactored OpenAI provider tool conversion to use unified `adaptSchemaForStrict()` helper across codex, completions, and responses
31
+ - Updated `AuthStorage` to support generic credential ranking via `CredentialRankingStrategy` instead of Codex-only logic
32
+ - Moved Google schema sanitization functions from `google-shared.ts` to `./utils/schema` module
33
+ - Changed export path: `./utils/typebox-helpers` → `./utils/schema` in main index
34
+ - `sanitizeSchemaForGoogle()` / `sanitizeSchemaForCCA()` now accept a parameterized `unsupportedFields` set internally, enabling code reuse between the two sanitizers
35
+ - `copySchemaWithout()` rewritten using object-rest destructuring for clarity
36
+
37
+ ### Fixed
38
+
39
+ - Fixed cycle detection: `WeakSet` guards added to all recursive schema traversals (`sanitizeSchemaForStrictMode`, `enforceStrictSchema`, `normalizeSchemaForCCA`, `normalizeNullablePropertiesForCloudCodeAssist`, `stripResidualCombiners`, `sanitizeSchemaImpl`, `hasResidualCloudCodeAssistIncompatibilities`) — circular schemas no longer cause infinite loops or stack overflows
40
+ - Fixed `hasResidualCloudCodeAssistIncompatibilities`: cycle detection now returns `false` (not `true`) for already-visited nodes, eliminating false positives that forced the CCA fallback schema on valid recursive inputs
41
+ - Fixed `stripResidualCombiners` to iterate to a fixpoint rather than making a single pass, ensuring chained combiner reductions (where one reduction enables another) are fully resolved
42
+ - Fixed `mergeObjectCombinerVariants` required-field computation: the flattened object now takes the intersection of all variants' `required` arrays (unioned with own-level required properties that exist in the merged schema), preventing required fields from being silently dropped or over-included
43
+ - Fixed `mergeCompatibleEnumSchemas` to use deep structural equality (`areJsonValuesEqual`) instead of `Object.is` when deduplicating object-valued enum members
44
+ - Fixed `sanitizeSchemaForGoogle` const-to-enum deduplication to use deep equality instead of reference equality
45
+ - Fixed `sanitizeSchemaForGoogle` type inference for `anyOf`/`oneOf`-flattened const enums: type is now derived from all variants (must agree), falling back to inference from enum values; mixed null/non-null infers the non-null type and sets `nullable`
46
+ - Fixed `sanitizeSchemaForGoogle` recursion to spread options when descending (previously only `insideProperties`, `normalizeTypeArrayToNullable`, `stripNullableKeyword` were forwarded; new fields `unsupportedFields` and `seen` were silently dropped)
47
+ - Fixed `sanitizeSchemaForGoogle` array-valued `type` filtering to exclude non-string entries before processing
48
+ - Removed incorrect `additionalProperties: false` stripping from `sanitizeSchemaForGoogle` (the field is valid in Google schemas when `false`)
49
+ - Fixed `sanitizeSchemaForStrictMode` to strip the `nullable` keyword and expand it into `anyOf: [schema, {type: "null"}]` in the output, matching what OpenAI strict mode actually expects
50
+ - Fixed `sanitizeSchemaForStrictMode` to infer `type: "array"` when `items` is present but `type` is absent
51
+ - Fixed `sanitizeSchemaForStrictMode` to infer a scalar `type` from uniform `enum` values when `type` is not explicitly set
52
+ - Fixed `sanitizeSchemaForStrictMode` const-to-enum merge to use deep equality, preventing duplicate enum entries when `const` and `enum` both exist with the same value
53
+ - Fixed `enforceStrictSchema` to drop `additionalProperties` unconditionally (previously only object-valued `additionalProperties` was recursed into; non-object values were passed through, violating strict schema requirements)
54
+ - Fixed `enforceStrictSchema` to recurse into `$defs` and `definitions` blocks so referenced sub-schemas are also made strict-compliant
55
+ - Fixed `enforceStrictSchema` to handle tuple-style `items` arrays (previously only single-schema `items` objects were recursed)
56
+ - Fixed `enforceStrictSchema` double-wrapping: optional properties already expressed as `anyOf: [..., {type: "null"}]` are not wrapped again
57
+ - Fixed `enforceStrictSchema` `Array.isArray` type-narrowing for `type` field to filter non-string entries before checking for `"object"`
58
+
5
59
  ## [13.3.8] - 2026-02-28
60
+
6
61
  ### Fixed
7
62
 
8
63
  - Fixed response body reuse error when handling 429 rate limit responses with retry logic
9
64
 
10
65
  ## [13.3.7] - 2026-02-27
66
+
11
67
  ### Added
12
68
 
13
69
  - Added `tryEnforceStrictSchema` function that gracefully downgrades to non-strict mode when schema enforcement fails, enabling better compatibility with malformed or circular schemas
@@ -25,6 +81,7 @@
25
81
  - Fixed `enforceStrictSchema` to correctly process nested object schemas within `anyOf`, `allOf`, and `oneOf` combinators
26
82
 
27
83
  ## [13.3.1] - 2026-02-26
84
+
28
85
  ### Added
29
86
 
30
87
  - Added `topP`, `topK`, `minP`, `presencePenalty`, and `repetitionPenalty` options to `StreamOptions` for fine-grained control over model sampling behavior
@@ -32,8 +89,11 @@
32
89
  ## [13.3.0] - 2026-02-26
33
90
 
34
91
  ### Changed
92
+
35
93
  - Allowed OAuth provider logins to supply a manual authorization code handler with a default prompt when none is provided
94
+
36
95
  ## [13.2.0] - 2026-02-23
96
+
37
97
  ### Added
38
98
 
39
99
  - Added support for GitHub Copilot provider in strict mode for both openai-completions and openai-responses tool schemas
@@ -43,6 +103,7 @@
43
103
  - Fixed tool descriptions being rejected when undefined by providing empty string fallback across all providers
44
104
 
45
105
  ## [12.19.1] - 2026-02-22
106
+
46
107
  ### Added
47
108
 
48
109
  - Exported `isProviderRetryableError` function for detecting rate-limit and transient stream errors
@@ -53,6 +114,7 @@
53
114
  - Expanded retry detection to include JSON parse errors (unterminated strings, unexpected end of input) in addition to rate-limit errors
54
115
 
55
116
  ## [12.19.0] - 2026-02-22
117
+
56
118
  ### Added
57
119
 
58
120
  - Added GitLab Duo provider with support for Claude, GPT-5, and other models via GitLab AI Gateway
@@ -78,6 +140,7 @@
78
140
  - Removed `CliAuthStorage` class in favor of new `AuthCredentialStore` with enhanced functionality
79
141
 
80
142
  ## [12.17.2] - 2026-02-21
143
+
81
144
  ### Added
82
145
 
83
146
  - Exported `getAntigravityUserAgent()` function for constructing Antigravity User-Agent headers
@@ -88,6 +151,7 @@
88
151
  - Unified User-Agent header generation across Antigravity API calls to use centralized `getAntigravityUserAgent()` function
89
152
 
90
153
  ## [12.17.1] - 2026-02-21
154
+
91
155
  ### Added
92
156
 
93
157
  - Added new export paths for provider models via `./provider-models` and `./provider-models/*`
@@ -102,10 +166,13 @@
102
166
  - Reorganized package.json field ordering for improved readability
103
167
 
104
168
  ## [12.17.0] - 2026-02-21
169
+
105
170
  ### Fixed
171
+
106
172
  - Cursor provider: bind `execHandlers` when passing handler methods to the exec protocol so handlers receive correct `this` context (fixes "undefined is not an object (evaluating 'this.options')" when using exec tools such as web search with Cursor)
107
173
 
108
174
  ## [12.16.0] - 2026-02-21
175
+
109
176
  ### Added
110
177
 
111
178
  - Exported `readModelCache` and `writeModelCache` functions for direct SQLite-backed model cache access
@@ -123,6 +190,7 @@
123
190
  - Updated tool call tracking to use status map (Resolved/Aborted) instead of separate sets for better handling of duplicate and aborted tool results
124
191
 
125
192
  ## [12.15.0] - 2026-02-20
193
+
126
194
  ### Fixed
127
195
 
128
196
  - Improved error messages for OAuth token refresh failures by including detailed error information from the provider
@@ -134,6 +202,7 @@
134
202
  - Changed 429 retry strategy for OpenAI Codex and Google Gemini CLI to use a 5-minute time budget when the server provides a retry delay, instead of a fixed attempt cap
135
203
 
136
204
  ## [12.14.0] - 2026-02-19
205
+
137
206
  ### Added
138
207
 
139
208
  - Added `gemini-3.1-pro` model to opencode provider with text and image input support
@@ -161,6 +230,7 @@
161
230
  - Added NanoGPT provider support with API-key login, dynamic model discovery from `https://nano-gpt.com/api/v1/models`, and text-model filtering for catalog/runtime discovery ([#111](https://github.com/can1357/oh-my-pi/issues/111))
162
231
 
163
232
  ## [12.12.3] - 2026-02-19
233
+
164
234
  ### Fixed
165
235
 
166
236
  - Fixed retry logic to recognize 'unable to connect' errors as transient failures
@@ -173,6 +243,7 @@
173
243
  - Fixed Codex websocket append fallback by resetting stale turn-state/model-etag session metadata when request shape diverges from appendable history.
174
244
 
175
245
  ## [12.11.1] - 2026-02-19
246
+
176
247
  ### Added
177
248
 
178
249
  - Added support for Claude 4.6 Opus and Sonnet models via Cursor API
@@ -224,6 +295,7 @@
224
295
  - Updated README documentation to list all newly supported providers and their authentication requirements
225
296
 
226
297
  ## [12.10.1] - 2026-02-18
298
+
227
299
  - Added Synthetic provider
228
300
  - Added API-key login helpers for Synthetic and Cerebras providers
229
301
 
@@ -279,6 +351,7 @@
279
351
  - Updated Qwen model context window and max token limits for improved accuracy
280
352
 
281
353
  ## [12.7.0] - 2026-02-16
354
+
282
355
  ### Added
283
356
 
284
357
  - Added DeepSeek-V3.2 model support via Amazon Bedrock
@@ -391,6 +464,7 @@
391
464
  - Added deprecation filter in model generation script to prevent re-adding deprecated Anthropic models ([#33](https://github.com/can1357/oh-my-pi/issues/33))
392
465
 
393
466
  ## [11.14.1] - 2026-02-12
467
+
394
468
  ### Added
395
469
 
396
470
  - Added prompt-caching-scope-2026-01-05 beta feature support
@@ -410,6 +484,7 @@
410
484
  - Removed fine-grained-tool-streaming-2025-05-14 beta feature
411
485
 
412
486
  ## [11.13.1] - 2026-02-12
487
+
413
488
  ### Added
414
489
 
415
490
  - Added Perplexity (Pro/Max) OAuth login support via native macOS app extraction or email OTP authentication
@@ -417,6 +492,7 @@
417
492
  - Added Socket.IO v4 client implementation for authenticated WebSocket communication with Perplexity API
418
493
 
419
494
  ## [11.12.0] - 2026-02-11
495
+
420
496
  ### Changed
421
497
 
422
498
  - Increased maximum retry attempts for Codex requests from 2 to 5 to improve reliability on transient failures
@@ -444,6 +520,7 @@
444
520
  - Updated `@anthropic-ai/sdk` dependency from ^0.72.1 to ^0.74.0
445
521
 
446
522
  ## [11.10.0] - 2026-02-10
523
+
447
524
  ### Added
448
525
 
449
526
  - Added support for Kimi K2, K2 Turbo Preview, and K2.5 models with reasoning capabilities
@@ -454,6 +531,7 @@
454
531
  - Fixed Claude Sonnet 4 context window to 200K across multiple providers (was incorrectly set to 1M)
455
532
 
456
533
  ## [11.8.0] - 2026-02-10
534
+
457
535
  ### Added
458
536
 
459
537
  - Added `auto` model alias for OpenRouter with automatic model routing
@@ -532,11 +610,13 @@
532
610
  - Fixed Bedrock `supportsPromptCaching` to also check model cost fields
533
611
 
534
612
  ## [11.5.1] - 2026-02-07
613
+
535
614
  ### Fixed
536
615
 
537
616
  - Fixed schema normalization to handle array-valued `type` fields by converting them to a single type with nullable flag for Google provider compatibility
538
617
 
539
618
  ## [11.3.0] - 2026-02-06
619
+
540
620
  ### Added
541
621
 
542
622
  - Added `cacheRetention` option to control prompt cache retention preference ('none', 'short', 'long') across providers
@@ -562,6 +642,7 @@
562
642
  - Fixed handling of conversations ending with assistant messages on Anthropic-routed models that reject assistant prefill requests
563
643
 
564
644
  ## [11.2.3] - 2026-02-05
645
+
565
646
  ### Added
566
647
 
567
648
  - Added Claude Opus 4.6 model support across multiple providers (Anthropic, Amazon Bedrock, GitHub Copilot, OpenRouter, OpenCode, Vercel AI Gateway)
@@ -1342,4 +1423,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
1342
1423
 
1343
1424
  ## [0.9.4] - 2025-11-26
1344
1425
 
1345
- Initial release with multi-provider LLM support.
1426
+ Initial release with multi-provider LLM support.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "13.3.13",
4
+ "version": "13.3.14",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -41,7 +41,7 @@
41
41
  "@aws-sdk/client-bedrock-runtime": "^3.998",
42
42
  "@bufbuild/protobuf": "^2.11",
43
43
  "@google/genai": "^1.43",
44
- "@oh-my-pi/pi-utils": "13.3.13",
44
+ "@oh-my-pi/pi-utils": "13.3.14",
45
45
  "@sinclair/typebox": "^0.34",
46
46
  "@smithy/node-http-handler": "^4.4",
47
47
  "ajv": "^8.18",
@@ -117,6 +117,14 @@
117
117
  "./utils/oauth/*": {
118
118
  "types": "./src/utils/oauth/*.ts",
119
119
  "import": "./src/utils/oauth/*.ts"
120
+ },
121
+ "./utils/schema": {
122
+ "types": "./src/utils/schema/index.ts",
123
+ "import": "./src/utils/schema/index.ts"
124
+ },
125
+ "./utils/schema/*": {
126
+ "types": "./src/utils/schema/*.ts",
127
+ "import": "./src/utils/schema/*.ts"
120
128
  }
121
129
  }
122
130
  }
@@ -15,6 +15,7 @@ import { googleGeminiCliUsageProvider } from "./providers/google-gemini-cli-usag
15
15
  import { getEnvApiKey } from "./stream";
16
16
  import type { Provider } from "./types";
17
17
  import type {
18
+ CredentialRankingStrategy,
18
19
  UsageCache,
19
20
  UsageCacheEntry,
20
21
  UsageCredential,
@@ -23,11 +24,11 @@ import type {
23
24
  UsageProvider,
24
25
  UsageReport,
25
26
  } from "./usage";
26
- import { claudeUsageProvider } from "./usage/claude";
27
+ import { claudeRankingStrategy, claudeUsageProvider } from "./usage/claude";
27
28
  import { githubCopilotUsageProvider } from "./usage/github-copilot";
28
29
  import { antigravityUsageProvider } from "./usage/google-antigravity";
29
30
  import { kimiUsageProvider } from "./usage/kimi";
30
- import { openaiCodexUsageProvider } from "./usage/openai-codex";
31
+ import { codexRankingStrategy, openaiCodexUsageProvider } from "./usage/openai-codex";
31
32
  import { zaiUsageProvider } from "./usage/zai";
32
33
  import { getOAuthApiKey, getOAuthProvider } from "./utils/oauth";
33
34
  // Re-export login functions so consumers of AuthStorage.login() have access
@@ -114,6 +115,7 @@ export interface StoredAuthCredential {
114
115
 
115
116
  export type AuthStorageOptions = {
116
117
  usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
118
+ rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
117
119
  usageCache?: UsageCache;
118
120
  usageFetch?: typeof fetch;
119
121
  usageNow?: () => number;
@@ -163,6 +165,15 @@ function resolveDefaultUsageProvider(provider: Provider): UsageProvider | undefi
163
165
  return DEFAULT_USAGE_PROVIDER_MAP.get(provider);
164
166
  }
165
167
 
168
+ const DEFAULT_RANKING_STRATEGIES = new Map<Provider, CredentialRankingStrategy>([
169
+ ["openai-codex", codexRankingStrategy],
170
+ ["anthropic", claudeRankingStrategy],
171
+ ]);
172
+
173
+ function resolveDefaultRankingStrategy(provider: Provider): CredentialRankingStrategy | undefined {
174
+ return DEFAULT_RANKING_STRATEGIES.get(provider);
175
+ }
176
+
166
177
  function parseUsageCacheEntry(raw: string): UsageCacheEntry | undefined {
167
178
  try {
168
179
  const parsed = JSON.parse(raw) as { value?: UsageReport | null; expiresAt?: unknown };
@@ -228,6 +239,7 @@ export class AuthStorage {
228
239
  /** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
229
240
  #credentialBackoff: Map<string, Map<number, number>> = new Map();
230
241
  #usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
242
+ #rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
231
243
  #usageCache?: UsageCache;
232
244
  #usageFetch: typeof fetch;
233
245
  #usageNow: () => number;
@@ -240,6 +252,7 @@ export class AuthStorage {
240
252
  this.#store = store;
241
253
  this.#configValueResolver = options.configValueResolver ?? defaultConfigValueResolver;
242
254
  this.#usageProviderResolver = options.usageProviderResolver ?? resolveDefaultUsageProvider;
255
+ this.#rankingStrategyResolver = options.rankingStrategyResolver ?? resolveDefaultRankingStrategy;
243
256
  this.#usageCache = options.usageCache ?? new AuthStorageUsageCache(this.#store);
244
257
  this.#usageFetch = options.usageFetch ?? fetch;
245
258
  this.#usageNow = options.usageNow ?? Date.now;
@@ -499,20 +512,25 @@ export class AuthStorage {
499
512
  return order;
500
513
  }
501
514
 
502
- /** Checks if a credential is temporarily blocked due to usage limits. */
503
- #isCredentialBlocked(providerKey: string, credentialIndex: number): boolean {
515
+ /** Returns block expiry timestamp for a credential, cleaning up expired entries. */
516
+ #getCredentialBlockedUntil(providerKey: string, credentialIndex: number): number | undefined {
504
517
  const backoffMap = this.#credentialBackoff.get(providerKey);
505
- if (!backoffMap) return false;
518
+ if (!backoffMap) return undefined;
506
519
  const blockedUntil = backoffMap.get(credentialIndex);
507
- if (!blockedUntil) return false;
520
+ if (!blockedUntil) return undefined;
508
521
  if (blockedUntil <= Date.now()) {
509
522
  backoffMap.delete(credentialIndex);
510
523
  if (backoffMap.size === 0) {
511
524
  this.#credentialBackoff.delete(providerKey);
512
525
  }
513
- return false;
526
+ return undefined;
514
527
  }
515
- return true;
528
+ return blockedUntil;
529
+ }
530
+
531
+ /** Checks if a credential is temporarily blocked due to usage limits. */
532
+ #isCredentialBlocked(providerKey: string, credentialIndex: number): boolean {
533
+ return this.#getCredentialBlockedUntil(providerKey, credentialIndex) !== undefined;
516
534
  }
517
535
 
518
536
  /** Marks a credential as blocked until the specified time. */
@@ -1273,7 +1291,7 @@ export class AuthStorage {
1273
1291
  const now = this.#usageNow();
1274
1292
  let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
1275
1293
 
1276
- if (provider === "openai-codex" && sessionCredential.type === "oauth") {
1294
+ if (sessionCredential.type === "oauth" && this.#rankingStrategyResolver?.(provider)) {
1277
1295
  const credential = this.#getCredentialsForProvider(provider)[sessionCredential.index];
1278
1296
  if (credential?.type === "oauth") {
1279
1297
  const report = await this.#getUsageReport(provider, credential, options);
@@ -1298,6 +1316,148 @@ export class AuthStorage {
1298
1316
  return remainingCredentials.some(candidate => !this.#isCredentialBlocked(providerKey, candidate.index));
1299
1317
  }
1300
1318
 
1319
+ #resolveWindowResetInMs(window: UsageLimit["window"], nowMs: number): number | undefined {
1320
+ if (!window) return undefined;
1321
+ if (typeof window.resetInMs === "number" && Number.isFinite(window.resetInMs)) {
1322
+ return window.resetInMs;
1323
+ }
1324
+ if (typeof window.resetsAt === "number" && Number.isFinite(window.resetsAt)) {
1325
+ return window.resetsAt - nowMs;
1326
+ }
1327
+ return undefined;
1328
+ }
1329
+
1330
+ #normalizeUsageFraction(limit: UsageLimit | undefined): number {
1331
+ const usedFraction = limit?.amount.usedFraction;
1332
+ if (typeof usedFraction !== "number" || !Number.isFinite(usedFraction)) {
1333
+ return 0.5;
1334
+ }
1335
+ return Math.min(Math.max(usedFraction, 0), 1);
1336
+ }
1337
+
1338
+ /** Computes `usedFraction / elapsedHours` — consumption rate per hour within the current window. Lower drain rate = less pressure = preferred. */
1339
+ #computeWindowDrainRate(limit: UsageLimit | undefined, nowMs: number, fallbackDurationMs: number): number {
1340
+ const usedFraction = this.#normalizeUsageFraction(limit);
1341
+ const durationMs = limit?.window?.durationMs ?? fallbackDurationMs;
1342
+ if (!Number.isFinite(durationMs) || durationMs <= 0) {
1343
+ return usedFraction;
1344
+ }
1345
+ const resetInMs = this.#resolveWindowResetInMs(limit?.window, nowMs);
1346
+ if (!Number.isFinite(resetInMs)) {
1347
+ return usedFraction;
1348
+ }
1349
+ const clampedResetInMs = Math.min(Math.max(resetInMs as number, 0), durationMs);
1350
+ const elapsedMs = durationMs - clampedResetInMs;
1351
+ if (elapsedMs <= 0) {
1352
+ return usedFraction;
1353
+ }
1354
+ const elapsedHours = elapsedMs / (60 * 60 * 1000);
1355
+ if (!Number.isFinite(elapsedHours) || elapsedHours <= 0) {
1356
+ return usedFraction;
1357
+ }
1358
+ return usedFraction / elapsedHours;
1359
+ }
1360
+
1361
+ async #rankOAuthSelections(args: {
1362
+ providerKey: string;
1363
+ provider: string;
1364
+ order: number[];
1365
+ credentials: Array<{ credential: OAuthCredential; index: number }>;
1366
+ options?: { baseUrl?: string };
1367
+ strategy: CredentialRankingStrategy;
1368
+ }): Promise<
1369
+ Array<{
1370
+ selection: { credential: OAuthCredential; index: number };
1371
+ usage: UsageReport | null;
1372
+ usageChecked: boolean;
1373
+ }>
1374
+ > {
1375
+ const nowMs = this.#usageNow();
1376
+ const { strategy } = args;
1377
+ const ranked: Array<{
1378
+ selection: { credential: OAuthCredential; index: number };
1379
+ usage: UsageReport | null;
1380
+ usageChecked: boolean;
1381
+ blocked: boolean;
1382
+ blockedUntil?: number;
1383
+ hasPriorityBoost: boolean;
1384
+ secondaryUsed: number;
1385
+ secondaryDrainRate: number;
1386
+ primaryUsed: number;
1387
+ primaryDrainRate: number;
1388
+ orderPos: number;
1389
+ }> = [];
1390
+ // Pre-fetch usage reports in parallel for non-blocked credentials
1391
+ const usageResults = await Promise.all(
1392
+ args.order.map(async idx => {
1393
+ const selection = args.credentials[idx];
1394
+ if (!selection) return null;
1395
+ const blockedUntil = this.#getCredentialBlockedUntil(args.providerKey, selection.index);
1396
+ if (blockedUntil !== undefined) return { selection, usage: null, usageChecked: false, blockedUntil };
1397
+ const usage = await this.#getUsageReport(args.provider, selection.credential, args.options);
1398
+ return { selection, usage, usageChecked: true, blockedUntil: undefined as number | undefined };
1399
+ }),
1400
+ );
1401
+
1402
+ for (let orderPos = 0; orderPos < usageResults.length; orderPos += 1) {
1403
+ const result = usageResults[orderPos];
1404
+ if (!result) continue;
1405
+ const { selection, usage, usageChecked } = result;
1406
+ let { blockedUntil } = result;
1407
+ let blocked = blockedUntil !== undefined;
1408
+ if (!blocked && usage && this.#isUsageLimitReached(usage)) {
1409
+ const resetAtMs = this.#getUsageResetAtMs(usage, nowMs);
1410
+ blockedUntil = resetAtMs ?? nowMs + AuthStorage.#defaultBackoffMs;
1411
+ this.#markCredentialBlocked(args.providerKey, selection.index, blockedUntil);
1412
+ blocked = true;
1413
+ }
1414
+ const windows = usage ? strategy.findWindowLimits(usage) : undefined;
1415
+ const primary = windows?.primary;
1416
+ const secondary = windows?.secondary;
1417
+ const secondaryTarget = secondary ?? primary;
1418
+ ranked.push({
1419
+ selection,
1420
+ usage,
1421
+ usageChecked,
1422
+ blocked,
1423
+ blockedUntil,
1424
+ hasPriorityBoost: strategy.hasPriorityBoost?.(primary) ?? false,
1425
+ secondaryUsed: this.#normalizeUsageFraction(secondaryTarget),
1426
+ secondaryDrainRate: this.#computeWindowDrainRate(
1427
+ secondaryTarget,
1428
+ nowMs,
1429
+ strategy.windowDefaults.secondaryMs,
1430
+ ),
1431
+ primaryUsed: this.#normalizeUsageFraction(primary),
1432
+ primaryDrainRate: this.#computeWindowDrainRate(primary, nowMs, strategy.windowDefaults.primaryMs),
1433
+ orderPos,
1434
+ });
1435
+ }
1436
+ ranked.sort((left, right) => {
1437
+ if (left.blocked !== right.blocked) return left.blocked ? 1 : -1;
1438
+ if (left.blocked && right.blocked) {
1439
+ const leftBlockedUntil = left.blockedUntil ?? Number.POSITIVE_INFINITY;
1440
+ const rightBlockedUntil = right.blockedUntil ?? Number.POSITIVE_INFINITY;
1441
+ if (leftBlockedUntil !== rightBlockedUntil) return leftBlockedUntil - rightBlockedUntil;
1442
+ return left.orderPos - right.orderPos;
1443
+ }
1444
+ if (left.hasPriorityBoost !== right.hasPriorityBoost) {
1445
+ return left.hasPriorityBoost ? -1 : 1;
1446
+ }
1447
+ if (left.secondaryDrainRate !== right.secondaryDrainRate)
1448
+ return left.secondaryDrainRate - right.secondaryDrainRate;
1449
+ if (left.secondaryUsed !== right.secondaryUsed) return left.secondaryUsed - right.secondaryUsed;
1450
+ if (left.primaryDrainRate !== right.primaryDrainRate) return left.primaryDrainRate - right.primaryDrainRate;
1451
+ if (left.primaryUsed !== right.primaryUsed) return left.primaryUsed - right.primaryUsed;
1452
+ return left.orderPos - right.orderPos;
1453
+ });
1454
+ return ranked.map(candidate => ({
1455
+ selection: candidate.selection,
1456
+ usage: candidate.usage,
1457
+ usageChecked: candidate.usageChecked,
1458
+ }));
1459
+ }
1460
+
1301
1461
  /**
1302
1462
  * Resolves an OAuth API key, trying credentials in priority order.
1303
1463
  * Skips blocked credentials and checks usage limits for providers with usage data.
@@ -1316,25 +1476,33 @@ export class AuthStorage {
1316
1476
 
1317
1477
  const providerKey = this.#getProviderTypeKey(provider, "oauth");
1318
1478
  const order = this.#getCredentialOrder(providerKey, sessionId, credentials.length);
1319
- const fallback = credentials[order[0]];
1320
- const checkUsage = provider === "openai-codex" && credentials.length > 1;
1321
-
1322
- for (const idx of order) {
1323
- const selection = credentials[idx];
1324
- const apiKey = await this.#tryOAuthCredential(
1325
- provider,
1326
- selection,
1327
- providerKey,
1328
- sessionId,
1329
- options,
1479
+ const strategy = this.#rankingStrategyResolver?.(provider);
1480
+ const checkUsage = strategy !== undefined && credentials.length > 1;
1481
+ const candidates = checkUsage
1482
+ ? await this.#rankOAuthSelections({ providerKey, provider, order, credentials, options, strategy })
1483
+ : order
1484
+ .map(idx => credentials[idx])
1485
+ .filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
1486
+ .map(selection => ({ selection, usage: null, usageChecked: false }));
1487
+ const fallback = candidates[0];
1488
+
1489
+ for (const candidate of candidates) {
1490
+ const apiKey = await this.#tryOAuthCredential(provider, candidate.selection, providerKey, sessionId, options, {
1330
1491
  checkUsage,
1331
- false,
1332
- );
1492
+ allowBlocked: false,
1493
+ prefetchedUsage: candidate.usage,
1494
+ usagePrechecked: candidate.usageChecked,
1495
+ });
1333
1496
  if (apiKey) return apiKey;
1334
1497
  }
1335
1498
 
1336
- if (fallback && this.#isCredentialBlocked(providerKey, fallback.index)) {
1337
- return this.#tryOAuthCredential(provider, fallback, providerKey, sessionId, options, checkUsage, true);
1499
+ if (fallback && this.#isCredentialBlocked(providerKey, fallback.selection.index)) {
1500
+ return this.#tryOAuthCredential(provider, fallback.selection, providerKey, sessionId, options, {
1501
+ checkUsage,
1502
+ allowBlocked: true,
1503
+ prefetchedUsage: fallback.usage,
1504
+ usagePrechecked: fallback.usageChecked,
1505
+ });
1338
1506
  }
1339
1507
 
1340
1508
  return undefined;
@@ -1342,14 +1510,19 @@ export class AuthStorage {
1342
1510
 
1343
1511
  /** Attempts to use a single OAuth credential, checking usage and refreshing token. */
1344
1512
  async #tryOAuthCredential(
1345
- provider: string,
1513
+ provider: Provider,
1346
1514
  selection: { credential: OAuthCredential; index: number },
1347
1515
  providerKey: string,
1348
1516
  sessionId: string | undefined,
1349
1517
  options: { baseUrl?: string } | undefined,
1350
- checkUsage: boolean,
1351
- allowBlocked: boolean,
1518
+ usageOptions: {
1519
+ checkUsage: boolean;
1520
+ allowBlocked: boolean;
1521
+ prefetchedUsage?: UsageReport | null;
1522
+ usagePrechecked?: boolean;
1523
+ },
1352
1524
  ): Promise<string | undefined> {
1525
+ const { checkUsage, allowBlocked, prefetchedUsage = null, usagePrechecked = false } = usageOptions;
1353
1526
  if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index)) {
1354
1527
  return undefined;
1355
1528
  }
@@ -1358,8 +1531,13 @@ export class AuthStorage {
1358
1531
  let usageChecked = false;
1359
1532
 
1360
1533
  if (checkUsage && !allowBlocked) {
1361
- usage = await this.#getUsageReport(provider, selection.credential, options);
1362
- usageChecked = true;
1534
+ if (usagePrechecked) {
1535
+ usage = prefetchedUsage;
1536
+ usageChecked = true;
1537
+ } else {
1538
+ usage = await this.#getUsageReport(provider, selection.credential, options);
1539
+ usageChecked = true;
1540
+ }
1363
1541
  if (usage && this.#isUsageLimitReached(usage)) {
1364
1542
  const resetAtMs = this.#getUsageResetAtMs(usage, this.#usageNow());
1365
1543
  this.#markCredentialBlocked(
package/src/index.ts CHANGED
@@ -35,5 +35,5 @@ export * from "./utils/event-stream";
35
35
  export * from "./utils/oauth";
36
36
  export * from "./utils/overflow";
37
37
  export * from "./utils/retry";
38
- export * from "./utils/typebox-helpers";
38
+ export * from "./utils/schema";
39
39
  export * from "./utils/validation";