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

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 (41) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/package.json +10 -2
  3. package/src/auth-storage.ts +207 -29
  4. package/src/index.ts +1 -1
  5. package/src/models.json +489 -312
  6. package/src/provider-models/openai-compat.ts +2 -1
  7. package/src/providers/amazon-bedrock.ts +8 -9
  8. package/src/providers/anthropic.ts +214 -102
  9. package/src/providers/azure-openai-responses.ts +7 -8
  10. package/src/providers/google-gemini-cli.ts +223 -44
  11. package/src/providers/google-shared.ts +11 -462
  12. package/src/providers/google-vertex.ts +1 -2
  13. package/src/providers/google.ts +1 -5
  14. package/src/providers/openai-codex-responses.ts +9 -12
  15. package/src/providers/openai-completions.ts +8 -11
  16. package/src/providers/openai-responses.ts +7 -10
  17. package/src/types.ts +1 -2
  18. package/src/usage/claude.ts +13 -2
  19. package/src/usage/openai-codex.ts +31 -0
  20. package/src/usage.ts +16 -0
  21. package/src/utils/discovery/antigravity.ts +77 -76
  22. package/src/utils/discovery/codex.ts +3 -3
  23. package/src/utils/discovery/openai-compatible.ts +2 -2
  24. package/src/utils/oauth/anthropic.ts +16 -5
  25. package/src/utils/oauth/callback-server.ts +1 -1
  26. package/src/utils/oauth/cursor.ts +1 -1
  27. package/src/utils/oauth/google-antigravity.ts +108 -47
  28. package/src/utils/oauth/google-gemini-cli.ts +0 -11
  29. package/src/utils/oauth/index.ts +13 -4
  30. package/src/utils/schema/CONSTRAINTS.md +160 -0
  31. package/src/utils/schema/adapt.ts +20 -0
  32. package/src/utils/schema/compatibility.ts +397 -0
  33. package/src/utils/schema/equality.ts +93 -0
  34. package/src/utils/schema/fields.ts +147 -0
  35. package/src/utils/schema/index.ts +8 -0
  36. package/src/utils/schema/normalize-cca.ts +479 -0
  37. package/src/utils/schema/sanitize-google.ts +207 -0
  38. package/src/utils/schema/strict-mode.ts +353 -0
  39. package/src/utils/schema/types.ts +5 -0
  40. package/src/utils/sanitize-unicode.ts +0 -25
  41. package/src/utils/typebox-helpers.ts +0 -261
package/CHANGELOG.md CHANGED
@@ -2,12 +2,127 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [13.4.0] - 2026-03-01
6
+
7
+ ### Breaking Changes
8
+
9
+ - Removed `TInput` generic parameter from `ToolResultMessage` interface and removed `$normative` property
10
+
11
+ ### Added
12
+
13
+ - `hasUnrepresentableStrictObjectMap()` pre-flight check in `tryEnforceStrictSchema`: schemas with `patternProperties` or schema-valued `additionalProperties` now degrade gracefully to non-strict mode instead of throwing during enforcement
14
+ - `generateClaudeCloakingUserId()` generates structured user IDs for Anthropic OAuth metadata (`user_{hex64}_account_{uuid}_session_{uuid}`)
15
+ - `isClaudeCloakingUserId()` validates whether a string matches the cloaking user-ID format
16
+ - `mapStainlessOs()` and `mapStainlessArch()` map `process.platform`/`process.arch` to Stainless header values; X-Stainless-Os and X-Stainless-Arch in `claudeCodeHeaders` are now runtime-computed
17
+ - `buildClaudeCodeTlsFetchOptions()` attaches SNI and default TLS ciphers for direct `api.anthropic.com` connections
18
+ - `createClaudeBillingHeader()` generates the `x-anthropic-billing-header` block (SHA-256 payload fingerprint + random build hash)
19
+ - `buildAnthropicSystemBlocks()` now injects a billing header block and the Claude Agent SDK identity block with `ephemeral` 1h cache-control when `includeClaudeCodeInstruction` is set
20
+ - `resolveAnthropicMetadataUserId()` auto-generates a cloaking user ID for OAuth requests when `metadata.user_id` is absent or invalid
21
+ - `AnthropicOAuthFlow` is now exported for direct use
22
+ - OAuth callback server timeout extended from 2 min to 5 min
23
+ - `parseGeminiCliCredentials()` parses Google Cloud credential JSON with support for legacy (`{token,projectId}`), alias (`project_id`/`refresh`/`expires`), and enriched formats
24
+ - `shouldRefreshGeminiCliCredentials()` and proactive token refresh before requests for both Gemini CLI and Antigravity providers (60s pre-expiry buffer)
25
+ - `normalizeAntigravityTools()` converts `parametersJsonSchema` → `parameters` in function declarations for Antigravity compatibility
26
+ - `ANTIGRAVITY_SYSTEM_INSTRUCTION` is now exported for use by search and other consumers
27
+ - `ANTIGRAVITY_LOAD_CODE_ASSIST_METADATA` constant exported from OAuth module with `ANTIGRAVITY` ideType
28
+ - Antigravity project onboarding: `onboardProjectWithRetries()` provisions a new project via `onboardUser` LRO when `loadCodeAssist` returns no existing project (up to 5 attempts, 2s interval)
29
+ - `getOAuthApiKey` now includes `refreshToken`, `expiresAt`, `email`, and `accountId` in the Gemini/Antigravity JSON credential payload to enable proactive refresh
30
+ - Antigravity model discovery now tries the production daily endpoint first, with sandbox as fallback
31
+ - `ANTIGRAVITY_DISCOVERY_DENYLIST` filters low-quality/internal models from discovery results
32
+
33
+ ### Changed
34
+
35
+ - Replaced `sanitizeSurrogates()` utility with native `String.prototype.toWellFormed()` for handling unpaired Unicode surrogates across all providers
36
+ - Extended `ANTHROPIC_OAUTH_BETA` constant in the OpenAI-compat Anthropic route with `interleaved-thinking-2025-05-14`, `context-management-2025-06-27`, and `prompt-caching-scope-2026-01-05` beta flags
37
+ - `claudeCodeVersion` bumped to `2.1.63`; `claudeCodeSystemInstruction` updated to identify as Claude Agent SDK
38
+ - `claudeCodeHeaders`: removed `X-Stainless-Helper-Method`, updated package version to `0.74.0`, runtime version to `v24.3.0`
39
+ - `applyClaudeToolPrefix` / `stripClaudeToolPrefix` now accept an optional prefix override and skip Anthropic built-in tool names (`web_search`, `code_execution`, `text_editor`, `computer`)
40
+ - Accept-Encoding header updated to `gzip, deflate, br, zstd`
41
+ - Non-Anthropic base URLs now receive `Authorization: Bearer` regardless of OAuth status
42
+ - Prompt-caching logic now skips applying breakpoints when any block already carries `cache_control`, instead of stripping then re-applying
43
+ - `fine-grained-tool-streaming-2025-05-14` removed from default beta set
44
+ - Anthropic OAuth token URL changed from `platform.claude.com` to `api.anthropic.com`
45
+ - Anthropic OAuth scopes reduced to `org:create_api_key user:profile user:inference`
46
+ - OAuth code exchange now strips URL fragment from callback code, using the fragment as state override when present
47
+ - Claude usage headers aligned: user-agent updated to `claude-cli/2.1.63 (external, cli)`, anthropic-beta extended with full beta set
48
+ - Antigravity session ID format changed to signed decimal (negative int63 derived from SHA-256 of first user message, or random bounded int63)
49
+ - Antigravity `requestId` now uses `agent-{uuid}` format; non-Antigravity requests no longer include requestId/userAgent/requestType in the payload
50
+ - `ANTIGRAVITY_DAILY_ENDPOINT` corrected to `daily-cloudcode-pa.googleapis.com`; sandbox endpoint kept as fallback only
51
+ - Antigravity discovery: removed `recommended`/`agentModelSorts` filter; now includes all non-internal, non-denylisted models
52
+ - Antigravity discovery no longer sends `project` in the request body
53
+ - Gemini/Antigravity OAuth flows no longer use PKCE (code_challenge removed)
54
+ - Antigravity `loadCodeAssist` metadata ideType changed from `IDE_UNSPECIFIED` to `ANTIGRAVITY`
55
+ - Antigravity `discoverProject` now uses a single canonical production endpoint; falls back to project onboarding instead of a hardcoded default project ID
56
+ - `VALIDATED` tool calling config applied to Antigravity requests with Claude models
57
+ - `maxOutputTokens` removed from Antigravity generation config for non-Claude models
58
+ - System instruction injection for Antigravity scoped to Claude and `gemini-3-pro-high` models only
59
+
60
+ ### Removed
61
+
62
+ - Removed `sanitizeSurrogates()` utility function; use native `String.prototype.toWellFormed()` instead
63
+
64
+ ## [13.3.14] - 2026-02-28
65
+
66
+ ### Added
67
+
68
+ - Exported schema utilities from new `./utils/schema` module, consolidating JSON Schema handling across providers
69
+ - Added `CredentialRankingStrategy` interface for providers to implement usage-based credential selection
70
+ - Added `claudeRankingStrategy` for Anthropic OAuth credentials to enable smart multi-account selection based on usage windows
71
+ - Added `codexRankingStrategy` for OpenAI Codex OAuth credentials with priority boost for fresh 5-hour window starts
72
+ - Added `adaptSchemaForStrict()` helper for unified OpenAI strict schema enforcement across providers
73
+ - Added schema equality and merging utilities: `areJsonValuesEqual()`, `mergeCompatibleEnumSchemas()`, `mergePropertySchemas()`
74
+ - Added Cloud Code Assist schema normalization: `copySchemaWithout()`, `stripResidualCombiners()`, `prepareSchemaForCCA()`
75
+ - Added `sanitizeSchemaForGoogle()` and `sanitizeSchemaForCCA()` for provider-specific schema sanitization
76
+ - Added `StringEnum()` helper for creating string enum schemas compatible with Google and other providers
77
+ - Added `enforceStrictSchema()` and `sanitizeSchemaForStrictMode()` for OpenAI strict mode schema validation
78
+ - Added package exports for `./utils/schema` and `./utils/schema/*` subpaths
79
+ - Added `validateSchemaCompatibility()` to statically audit a JSON Schema against provider-specific rules (`openai-strict`, `google`, `cloud-code-assist-claude`) and return structured violations
80
+ - Added `validateStrictSchemaEnforcement()` to verify the strict-fail-open contract: enforced schemas pass strict validation, failed schemas return the original object identity
81
+ - Added `COMBINATOR_KEYS` (`anyOf`, `allOf`, `oneOf`) and `CCA_UNSUPPORTED_SCHEMA_FIELDS` as exported constants in `fields.ts` to eliminate duplication across modules
82
+ - Added `tryEnforceStrictSchema` result cache (`WeakMap`) to avoid redundant sanitize + enforce work for the same schema object
83
+ - Added comprehensive schema normalization test suite (`schema-normalization.test.ts`) covering strict mode, Google, and Cloud Code Assist normalization paths
84
+ - Added schema compatibility validation test suite (`schema-compatibility.test.ts`) covering all three provider targets
85
+
86
+ ### Changed
87
+
88
+ - Moved schema utilities from `./utils/typebox-helpers` to new `./utils/schema` module with expanded functionality
89
+ - Refactored OpenAI provider tool conversion to use unified `adaptSchemaForStrict()` helper across codex, completions, and responses
90
+ - Updated `AuthStorage` to support generic credential ranking via `CredentialRankingStrategy` instead of Codex-only logic
91
+ - Moved Google schema sanitization functions from `google-shared.ts` to `./utils/schema` module
92
+ - Changed export path: `./utils/typebox-helpers` → `./utils/schema` in main index
93
+ - `sanitizeSchemaForGoogle()` / `sanitizeSchemaForCCA()` now accept a parameterized `unsupportedFields` set internally, enabling code reuse between the two sanitizers
94
+ - `copySchemaWithout()` rewritten using object-rest destructuring for clarity
95
+
96
+ ### Fixed
97
+
98
+ - 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
99
+ - 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
100
+ - 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
101
+ - 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
102
+ - Fixed `mergeCompatibleEnumSchemas` to use deep structural equality (`areJsonValuesEqual`) instead of `Object.is` when deduplicating object-valued enum members
103
+ - Fixed `sanitizeSchemaForGoogle` const-to-enum deduplication to use deep equality instead of reference equality
104
+ - 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`
105
+ - Fixed `sanitizeSchemaForGoogle` recursion to spread options when descending (previously only `insideProperties`, `normalizeTypeArrayToNullable`, `stripNullableKeyword` were forwarded; new fields `unsupportedFields` and `seen` were silently dropped)
106
+ - Fixed `sanitizeSchemaForGoogle` array-valued `type` filtering to exclude non-string entries before processing
107
+ - Removed incorrect `additionalProperties: false` stripping from `sanitizeSchemaForGoogle` (the field is valid in Google schemas when `false`)
108
+ - 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
109
+ - Fixed `sanitizeSchemaForStrictMode` to infer `type: "array"` when `items` is present but `type` is absent
110
+ - Fixed `sanitizeSchemaForStrictMode` to infer a scalar `type` from uniform `enum` values when `type` is not explicitly set
111
+ - Fixed `sanitizeSchemaForStrictMode` const-to-enum merge to use deep equality, preventing duplicate enum entries when `const` and `enum` both exist with the same value
112
+ - Fixed `enforceStrictSchema` to drop `additionalProperties` unconditionally (previously only object-valued `additionalProperties` was recursed into; non-object values were passed through, violating strict schema requirements)
113
+ - Fixed `enforceStrictSchema` to recurse into `$defs` and `definitions` blocks so referenced sub-schemas are also made strict-compliant
114
+ - Fixed `enforceStrictSchema` to handle tuple-style `items` arrays (previously only single-schema `items` objects were recursed)
115
+ - Fixed `enforceStrictSchema` double-wrapping: optional properties already expressed as `anyOf: [..., {type: "null"}]` are not wrapped again
116
+ - Fixed `enforceStrictSchema` `Array.isArray` type-narrowing for `type` field to filter non-string entries before checking for `"object"`
117
+
5
118
  ## [13.3.8] - 2026-02-28
119
+
6
120
  ### Fixed
7
121
 
8
122
  - Fixed response body reuse error when handling 429 rate limit responses with retry logic
9
123
 
10
124
  ## [13.3.7] - 2026-02-27
125
+
11
126
  ### Added
12
127
 
13
128
  - Added `tryEnforceStrictSchema` function that gracefully downgrades to non-strict mode when schema enforcement fails, enabling better compatibility with malformed or circular schemas
@@ -25,6 +140,7 @@
25
140
  - Fixed `enforceStrictSchema` to correctly process nested object schemas within `anyOf`, `allOf`, and `oneOf` combinators
26
141
 
27
142
  ## [13.3.1] - 2026-02-26
143
+
28
144
  ### Added
29
145
 
30
146
  - Added `topP`, `topK`, `minP`, `presencePenalty`, and `repetitionPenalty` options to `StreamOptions` for fine-grained control over model sampling behavior
@@ -32,8 +148,11 @@
32
148
  ## [13.3.0] - 2026-02-26
33
149
 
34
150
  ### Changed
151
+
35
152
  - Allowed OAuth provider logins to supply a manual authorization code handler with a default prompt when none is provided
153
+
36
154
  ## [13.2.0] - 2026-02-23
155
+
37
156
  ### Added
38
157
 
39
158
  - Added support for GitHub Copilot provider in strict mode for both openai-completions and openai-responses tool schemas
@@ -43,6 +162,7 @@
43
162
  - Fixed tool descriptions being rejected when undefined by providing empty string fallback across all providers
44
163
 
45
164
  ## [12.19.1] - 2026-02-22
165
+
46
166
  ### Added
47
167
 
48
168
  - Exported `isProviderRetryableError` function for detecting rate-limit and transient stream errors
@@ -53,6 +173,7 @@
53
173
  - Expanded retry detection to include JSON parse errors (unterminated strings, unexpected end of input) in addition to rate-limit errors
54
174
 
55
175
  ## [12.19.0] - 2026-02-22
176
+
56
177
  ### Added
57
178
 
58
179
  - Added GitLab Duo provider with support for Claude, GPT-5, and other models via GitLab AI Gateway
@@ -78,6 +199,7 @@
78
199
  - Removed `CliAuthStorage` class in favor of new `AuthCredentialStore` with enhanced functionality
79
200
 
80
201
  ## [12.17.2] - 2026-02-21
202
+
81
203
  ### Added
82
204
 
83
205
  - Exported `getAntigravityUserAgent()` function for constructing Antigravity User-Agent headers
@@ -88,6 +210,7 @@
88
210
  - Unified User-Agent header generation across Antigravity API calls to use centralized `getAntigravityUserAgent()` function
89
211
 
90
212
  ## [12.17.1] - 2026-02-21
213
+
91
214
  ### Added
92
215
 
93
216
  - Added new export paths for provider models via `./provider-models` and `./provider-models/*`
@@ -102,10 +225,13 @@
102
225
  - Reorganized package.json field ordering for improved readability
103
226
 
104
227
  ## [12.17.0] - 2026-02-21
228
+
105
229
  ### Fixed
230
+
106
231
  - 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
232
 
108
233
  ## [12.16.0] - 2026-02-21
234
+
109
235
  ### Added
110
236
 
111
237
  - Exported `readModelCache` and `writeModelCache` functions for direct SQLite-backed model cache access
@@ -123,6 +249,7 @@
123
249
  - Updated tool call tracking to use status map (Resolved/Aborted) instead of separate sets for better handling of duplicate and aborted tool results
124
250
 
125
251
  ## [12.15.0] - 2026-02-20
252
+
126
253
  ### Fixed
127
254
 
128
255
  - Improved error messages for OAuth token refresh failures by including detailed error information from the provider
@@ -134,6 +261,7 @@
134
261
  - 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
262
 
136
263
  ## [12.14.0] - 2026-02-19
264
+
137
265
  ### Added
138
266
 
139
267
  - Added `gemini-3.1-pro` model to opencode provider with text and image input support
@@ -161,6 +289,7 @@
161
289
  - 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
290
 
163
291
  ## [12.12.3] - 2026-02-19
292
+
164
293
  ### Fixed
165
294
 
166
295
  - Fixed retry logic to recognize 'unable to connect' errors as transient failures
@@ -173,6 +302,7 @@
173
302
  - Fixed Codex websocket append fallback by resetting stale turn-state/model-etag session metadata when request shape diverges from appendable history.
174
303
 
175
304
  ## [12.11.1] - 2026-02-19
305
+
176
306
  ### Added
177
307
 
178
308
  - Added support for Claude 4.6 Opus and Sonnet models via Cursor API
@@ -224,6 +354,7 @@
224
354
  - Updated README documentation to list all newly supported providers and their authentication requirements
225
355
 
226
356
  ## [12.10.1] - 2026-02-18
357
+
227
358
  - Added Synthetic provider
228
359
  - Added API-key login helpers for Synthetic and Cerebras providers
229
360
 
@@ -279,6 +410,7 @@
279
410
  - Updated Qwen model context window and max token limits for improved accuracy
280
411
 
281
412
  ## [12.7.0] - 2026-02-16
413
+
282
414
  ### Added
283
415
 
284
416
  - Added DeepSeek-V3.2 model support via Amazon Bedrock
@@ -391,6 +523,7 @@
391
523
  - 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
524
 
393
525
  ## [11.14.1] - 2026-02-12
526
+
394
527
  ### Added
395
528
 
396
529
  - Added prompt-caching-scope-2026-01-05 beta feature support
@@ -410,6 +543,7 @@
410
543
  - Removed fine-grained-tool-streaming-2025-05-14 beta feature
411
544
 
412
545
  ## [11.13.1] - 2026-02-12
546
+
413
547
  ### Added
414
548
 
415
549
  - Added Perplexity (Pro/Max) OAuth login support via native macOS app extraction or email OTP authentication
@@ -417,6 +551,7 @@
417
551
  - Added Socket.IO v4 client implementation for authenticated WebSocket communication with Perplexity API
418
552
 
419
553
  ## [11.12.0] - 2026-02-11
554
+
420
555
  ### Changed
421
556
 
422
557
  - Increased maximum retry attempts for Codex requests from 2 to 5 to improve reliability on transient failures
@@ -444,6 +579,7 @@
444
579
  - Updated `@anthropic-ai/sdk` dependency from ^0.72.1 to ^0.74.0
445
580
 
446
581
  ## [11.10.0] - 2026-02-10
582
+
447
583
  ### Added
448
584
 
449
585
  - Added support for Kimi K2, K2 Turbo Preview, and K2.5 models with reasoning capabilities
@@ -454,6 +590,7 @@
454
590
  - Fixed Claude Sonnet 4 context window to 200K across multiple providers (was incorrectly set to 1M)
455
591
 
456
592
  ## [11.8.0] - 2026-02-10
593
+
457
594
  ### Added
458
595
 
459
596
  - Added `auto` model alias for OpenRouter with automatic model routing
@@ -532,11 +669,13 @@
532
669
  - Fixed Bedrock `supportsPromptCaching` to also check model cost fields
533
670
 
534
671
  ## [11.5.1] - 2026-02-07
672
+
535
673
  ### Fixed
536
674
 
537
675
  - Fixed schema normalization to handle array-valued `type` fields by converting them to a single type with nullable flag for Google provider compatibility
538
676
 
539
677
  ## [11.3.0] - 2026-02-06
678
+
540
679
  ### Added
541
680
 
542
681
  - Added `cacheRetention` option to control prompt cache retention preference ('none', 'short', 'long') across providers
@@ -562,6 +701,7 @@
562
701
  - Fixed handling of conversations ending with assistant messages on Anthropic-routed models that reject assistant prefill requests
563
702
 
564
703
  ## [11.2.3] - 2026-02-05
704
+
565
705
  ### Added
566
706
 
567
707
  - Added Claude Opus 4.6 model support across multiple providers (Anthropic, Amazon Bedrock, GitHub Copilot, OpenRouter, OpenCode, Vercel AI Gateway)
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.4.0",
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.4.0",
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";