@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 +82 -1
- package/package.json +10 -2
- package/src/auth-storage.ts +207 -29
- package/src/index.ts +1 -1
- package/src/models.json +444 -311
- package/src/providers/google-shared.ts +3 -451
- package/src/providers/google.ts +0 -3
- package/src/providers/openai-codex-responses.ts +3 -5
- package/src/providers/openai-completions.ts +2 -4
- package/src/providers/openai-responses.ts +2 -4
- package/src/usage/claude.ts +10 -0
- package/src/usage/openai-codex.ts +31 -0
- package/src/usage.ts +16 -0
- package/src/utils/discovery/antigravity.ts +4 -4
- package/src/utils/discovery/codex.ts +3 -3
- package/src/utils/discovery/openai-compatible.ts +2 -2
- package/src/utils/schema/CONSTRAINTS.md +160 -0
- package/src/utils/schema/adapt.ts +20 -0
- package/src/utils/schema/compatibility.ts +397 -0
- package/src/utils/schema/equality.ts +93 -0
- package/src/utils/schema/fields.ts +147 -0
- package/src/utils/schema/index.ts +30 -0
- package/src/utils/schema/normalize-cca.ts +479 -0
- package/src/utils/schema/sanitize-google.ts +207 -0
- package/src/utils/{typebox-helpers.ts → schema/strict-mode.ts} +112 -78
- package/src/utils/schema/types.ts +5 -0
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.
|
|
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.
|
|
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
|
}
|
package/src/auth-storage.ts
CHANGED
|
@@ -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
|
-
/**
|
|
503
|
-
#
|
|
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
|
|
518
|
+
if (!backoffMap) return undefined;
|
|
506
519
|
const blockedUntil = backoffMap.get(credentialIndex);
|
|
507
|
-
if (!blockedUntil) return
|
|
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
|
|
526
|
+
return undefined;
|
|
514
527
|
}
|
|
515
|
-
return
|
|
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 (
|
|
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
|
|
1320
|
-
const checkUsage =
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
1351
|
-
|
|
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
|
-
|
|
1362
|
-
|
|
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