@oh-my-pi/pi-ai 17.0.4 → 17.0.6
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 +26 -0
- package/dist/types/auth-storage.d.ts +5 -5
- package/dist/types/providers/anthropic.d.ts +2 -0
- package/dist/types/providers/openai-codex-responses.d.ts +2 -0
- package/dist/types/registry/api-key-login.d.ts +1 -1
- package/dist/types/registry/oauth/types.d.ts +3 -2
- package/dist/types/utils/schema/normalize.d.ts +15 -0
- package/dist/types/utils.d.ts +4 -3
- package/package.json +4 -4
- package/src/auth-storage.ts +128 -54
- package/src/providers/__tests__/kimi-code-thinking.test.ts +47 -0
- package/src/providers/amazon-bedrock.ts +1 -1
- package/src/providers/anthropic.ts +57 -15
- package/src/providers/cursor.ts +1 -1
- package/src/providers/devin.ts +95 -14
- package/src/providers/openai-codex-responses.ts +2 -1
- package/src/providers/openai-completions.ts +43 -7
- package/src/providers/openai-responses.ts +263 -164
- package/src/registry/api-key-login.ts +5 -2
- package/src/registry/moonshot.ts +7 -1
- package/src/registry/oauth/openai-codex.ts +22 -2
- package/src/registry/oauth/types.ts +3 -2
- package/src/utils/schema/normalize.ts +103 -11
- package/src/utils.ts +10 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.0.6] - 2026-07-20
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed OpenAI Codex credentials limited to one ChatGPT workspace per email: a personal Plus/Pro plan and a Team/Enterprise seat under the same email now coexist in the auth store — with separate rotation and usage pools — instead of the second login silently replacing the first. The workspace (`chatgpt_account_id`) is captured as the credential's org at login with the plan type as its display label, and two members of one workspace keep separate rows ([#2966](https://github.com/can1357/oh-my-pi/issues/2966)).
|
|
10
|
+
- Fixed Devin total-token usage omitting cache reads and cache writes.
|
|
11
|
+
- Fixed model switches to Devin rejecting foreign provider response IDs, reasoning signatures, and empty interrupted turns as invalid Cascade history.
|
|
12
|
+
- Classified zero-output Devin `invalid_argument` trailers as context overflow when the serialized message history is already large, routing cumulative tool-output payload failures through context maintenance—including artifact-backed shake rescue—instead of retrying the same rejected history.
|
|
13
|
+
|
|
14
|
+
## [17.0.5] - 2026-07-18
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Changed Anthropic API-key requests to default to a 1-hour prompt-cache retention (using the extended-cache-ttl-2025-04-11 beta) to prevent cold-misses during idle sessions, with support for PI_CACHE_RETENTION values "short" and "none" to override this behavior.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Fixed transient OpenAI stream truncations by retrying once before output becomes replay-unsafe, preventing recoverable transport errors from failing the turn.
|
|
23
|
+
- Fixed native Kimi Code K3 thinking being disabled during named function selection by utilizing generic required tool choice.
|
|
24
|
+
- Fixed /login moonshot validating China-platform API keys against the international host instead of honoring MOONSHOT_BASE_URL.
|
|
25
|
+
- Fixed Anthropic session stickiness suppressing usage-based re-ranking indefinitely by gating stickiness on a 1-hour cache warmth window (configurable via ANTHROPIC_SESSION_STICKY_CACHE_WARM_MS) to restore proactive multi-account load balancing after long idle periods.
|
|
26
|
+
- Fixed credential ranking where clockless Anthropic usage windows incorrectly outranked clocked sibling credentials.
|
|
27
|
+
- Fixed tool request failures (HTTP 400) on local grammar-constrained OpenAI-compatible backends (such as llama.cpp, LM Studio, and vLLM) by widening bare boolean subschemas into a value-accepting primitive union.
|
|
28
|
+
- Fixed custom OAuth Anthropic-compatible endpoints receiving generated Claude Code fingerprint headers even when explicit header overrides were provided.
|
|
29
|
+
- Fixed active sessions for plan-gated OpenAI Codex models (Sol/Luna) silently re-routing to sibling OAuth accounts when usage headroom changed, ensuring session stickiness is preserved as long as the preferred credential remains usable and eligible.
|
|
30
|
+
|
|
5
31
|
## [17.0.4] - 2026-07-18
|
|
6
32
|
|
|
7
33
|
### Fixed
|
|
@@ -99,7 +99,7 @@ export interface CredentialHealthResult {
|
|
|
99
99
|
email?: string;
|
|
100
100
|
/** OAuth account id if known. */
|
|
101
101
|
accountId?: string;
|
|
102
|
-
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
|
|
102
|
+
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
|
|
103
103
|
orgId?: string;
|
|
104
104
|
orgName?: string;
|
|
105
105
|
/** `true` when the refresh token lives on a remote broker (sentinel was present). */
|
|
@@ -490,7 +490,7 @@ export interface OAuthAccess {
|
|
|
490
490
|
projectId?: string;
|
|
491
491
|
enterpriseUrl?: string;
|
|
492
492
|
apiEndpoint?: string;
|
|
493
|
-
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
|
|
493
|
+
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
|
|
494
494
|
orgId?: string;
|
|
495
495
|
orgName?: string;
|
|
496
496
|
}
|
|
@@ -513,7 +513,7 @@ export interface OAuthAccessFailure {
|
|
|
513
513
|
projectId?: string;
|
|
514
514
|
enterpriseUrl?: string;
|
|
515
515
|
apiEndpoint?: string;
|
|
516
|
-
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
|
|
516
|
+
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
|
|
517
517
|
orgId?: string;
|
|
518
518
|
orgName?: string;
|
|
519
519
|
error: string;
|
|
@@ -528,7 +528,7 @@ export interface OAuthAccountIdentity {
|
|
|
528
528
|
accountId?: string;
|
|
529
529
|
email?: string;
|
|
530
530
|
projectId?: string;
|
|
531
|
-
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
|
|
531
|
+
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
|
|
532
532
|
orgId?: string;
|
|
533
533
|
orgName?: string;
|
|
534
534
|
}
|
|
@@ -549,7 +549,7 @@ export interface OAuthAccountSummary {
|
|
|
549
549
|
email?: string;
|
|
550
550
|
projectId?: string;
|
|
551
551
|
enterpriseUrl?: string;
|
|
552
|
-
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
|
|
552
|
+
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
|
|
553
553
|
orgId?: string;
|
|
554
554
|
orgName?: string;
|
|
555
555
|
}
|
|
@@ -11,6 +11,8 @@ export type AnthropicHeaderOptions = {
|
|
|
11
11
|
isCloudflareAiGateway?: boolean;
|
|
12
12
|
claudeCodeSessionId?: string;
|
|
13
13
|
claudeCodeBetas?: readonly string[];
|
|
14
|
+
/** Allow explicit fingerprint headers to replace OAuth defaults on non-official endpoints. */
|
|
15
|
+
allowAnthropicHeaderOverrides?: boolean;
|
|
14
16
|
};
|
|
15
17
|
export declare function normalizeAnthropicBaseUrl(baseUrl?: string): string | undefined;
|
|
16
18
|
export declare function buildBetaHeader(baseBetas: readonly string[], extraBetas: readonly string[]): string;
|
|
@@ -155,6 +155,8 @@ export declare function getOpenAICodexTransportDetails(model: Model<"openai-code
|
|
|
155
155
|
preferWebsockets?: boolean;
|
|
156
156
|
providerSessionState?: Map<string, ProviderSessionState>;
|
|
157
157
|
}): OpenAICodexTransportDetails;
|
|
158
|
+
/** Resolve a Codex Responses endpoint exactly as the chat and compaction transports do. */
|
|
159
|
+
export declare function resolveCodexResponsesUrl(baseUrl: string | undefined): string;
|
|
158
160
|
declare function convertMessages(model: Model<"openai-codex-responses">, context: Context): ResponseInput;
|
|
159
161
|
/** @internal Exported for tests. */
|
|
160
162
|
export { convertMessages as convertCodexResponsesMessages };
|
|
@@ -21,7 +21,7 @@ type AnthropicMessagesValidation = {
|
|
|
21
21
|
type ModelsEndpointValidation = {
|
|
22
22
|
kind: "models-endpoint";
|
|
23
23
|
provider: string;
|
|
24
|
-
modelsUrl: string;
|
|
24
|
+
modelsUrl: string | (() => string);
|
|
25
25
|
headers?: Record<string, string> | (() => Record<string, string> | undefined);
|
|
26
26
|
};
|
|
27
27
|
export type ApiKeyLoginConfig = {
|
|
@@ -11,8 +11,9 @@ export type OAuthCredentials = {
|
|
|
11
11
|
apiEndpoint?: string;
|
|
12
12
|
/**
|
|
13
13
|
* Organization/workspace the token is scoped to (e.g. an Anthropic org
|
|
14
|
-
* UUID). Captured once at login; token refreshes
|
|
15
|
-
* one account email hold credentials for multiple
|
|
14
|
+
* UUID or a ChatGPT workspace id). Captured once at login; token refreshes
|
|
15
|
+
* never rewrite it. Lets one account email hold credentials for multiple
|
|
16
|
+
* subscriptions.
|
|
16
17
|
*/
|
|
17
18
|
orgId?: string;
|
|
18
19
|
/** Human-readable organization name for display (may embed the email). */
|
|
@@ -79,6 +79,21 @@ export declare function normalizeSchemaForMoonshot(value: unknown): unknown;
|
|
|
79
79
|
* cannot unmarshal into its object-shaped `Schema` struct.
|
|
80
80
|
*/
|
|
81
81
|
export declare function sanitizeSchemaForOllama(schema: JsonObject): JsonObject;
|
|
82
|
+
/**
|
|
83
|
+
* Rewrites the one JSON Schema form that grammar-constrained OpenAI-compatible
|
|
84
|
+
* backends (llama.cpp, LM Studio, vLLM) cannot compile to GBNF: a bare boolean
|
|
85
|
+
* subschema. `toolWireSchema` normalizes `{}` open subschemas to boolean `true`
|
|
86
|
+
* (issue #1179); llama.cpp's `json-schema-to-grammar.cpp` `visit()` has no case
|
|
87
|
+
* for a boolean schema and throws `Unrecognized schema: true` → HTTP 400 before
|
|
88
|
+
* the model is consulted (issue #5914).
|
|
89
|
+
*
|
|
90
|
+
* Narrower than {@link sanitizeSchemaForOllama}: only genuine subschema slots
|
|
91
|
+
* are widened. Boolean `additionalProperties`/`unevaluatedProperties` stay
|
|
92
|
+
* intact because the converter reads those as closed/open-object grammar
|
|
93
|
+
* semantics, and dropping `additionalProperties: false` would silently reopen
|
|
94
|
+
* every declared object.
|
|
95
|
+
*/
|
|
96
|
+
export declare function sanitizeSchemaForGrammar(schema: JsonObject): JsonObject;
|
|
82
97
|
/**
|
|
83
98
|
* OpenAI Responses rejects `oneOf` in tool schemas even when strict mode is
|
|
84
99
|
* disabled, and rejects every schema node with `type: "object"` unless it has
|
package/dist/types/utils.d.ts
CHANGED
|
@@ -33,7 +33,8 @@ export declare function createOpenAIResponsesHistoryPayload(provider: string, it
|
|
|
33
33
|
export declare function getOpenAIResponsesHistoryPayload(providerPayload: ProviderPayload | undefined, currentProvider: string, fallbackProvider?: string): OpenAIResponsesHistoryPayload | undefined;
|
|
34
34
|
export declare function getOpenAIResponsesHistoryItems(providerPayload: ProviderPayload | undefined, currentProvider: string, fallbackProvider?: string): Array<Record<string, unknown>> | undefined;
|
|
35
35
|
/**
|
|
36
|
-
* Resolve cache retention preference
|
|
37
|
-
*
|
|
36
|
+
* Resolve cache retention preference: explicit request option first, then the
|
|
37
|
+
* `PI_CACHE_RETENTION` env override (`long` | `short` | `none`), then the
|
|
38
|
+
* provider-supplied fallback.
|
|
38
39
|
*/
|
|
39
|
-
export declare function resolveCacheRetention(cacheRetention?: CacheRetention): CacheRetention;
|
|
40
|
+
export declare function resolveCacheRetention(cacheRetention?: CacheRetention, fallback?: CacheRetention): CacheRetention;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "17.0.
|
|
4
|
+
"version": "17.0.6",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.1",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "17.0.
|
|
42
|
-
"@oh-my-pi/pi-utils": "17.0.
|
|
43
|
-
"@oh-my-pi/pi-wire": "17.0.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "17.0.6",
|
|
42
|
+
"@oh-my-pi/pi-utils": "17.0.6",
|
|
43
|
+
"@oh-my-pi/pi-wire": "17.0.6",
|
|
44
44
|
"arktype": "2.2.3",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
package/src/auth-storage.ts
CHANGED
|
@@ -73,6 +73,15 @@ function fingerprintOAuthBearer(bearer: string): string {
|
|
|
73
73
|
return createHash("sha256").update(bearer).digest("base64url");
|
|
74
74
|
}
|
|
75
75
|
const SESSION_STICKY_CACHE_PREFIX = "session:sticky:";
|
|
76
|
+
/**
|
|
77
|
+
* Anthropic-only idle window after which a session's pinned credential no
|
|
78
|
+
* longer suppresses usage-based re-ranking. Anthropic caps OAuth prompt-cache
|
|
79
|
+
* retention at `ttl: "1h"` (ephemeral ~5min otherwise), so after this long
|
|
80
|
+
* without an Anthropic resolve the conversation-prefix cache is no longer
|
|
81
|
+
* guaranteed warm. Other providers retain indefinite stickiness until their
|
|
82
|
+
* own cache lifetimes are verified.
|
|
83
|
+
*/
|
|
84
|
+
const ANTHROPIC_SESSION_STICKY_CACHE_WARM_MS = 60 * 60_000;
|
|
76
85
|
|
|
77
86
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
78
87
|
// Credential Types
|
|
@@ -177,7 +186,7 @@ export interface CredentialHealthResult {
|
|
|
177
186
|
email?: string;
|
|
178
187
|
/** OAuth account id if known. */
|
|
179
188
|
accountId?: string;
|
|
180
|
-
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
|
|
189
|
+
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
|
|
181
190
|
orgId?: string;
|
|
182
191
|
orgName?: string;
|
|
183
192
|
/** `true` when the refresh token lives on a remote broker (sentinel was present). */
|
|
@@ -726,7 +735,7 @@ export interface OAuthAccess {
|
|
|
726
735
|
projectId?: string;
|
|
727
736
|
enterpriseUrl?: string;
|
|
728
737
|
apiEndpoint?: string;
|
|
729
|
-
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
|
|
738
|
+
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
|
|
730
739
|
orgId?: string;
|
|
731
740
|
orgName?: string;
|
|
732
741
|
}
|
|
@@ -751,7 +760,7 @@ export interface OAuthAccessFailure {
|
|
|
751
760
|
projectId?: string;
|
|
752
761
|
enterpriseUrl?: string;
|
|
753
762
|
apiEndpoint?: string;
|
|
754
|
-
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
|
|
763
|
+
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
|
|
755
764
|
orgId?: string;
|
|
756
765
|
orgName?: string;
|
|
757
766
|
error: string;
|
|
@@ -767,7 +776,7 @@ export interface OAuthAccountIdentity {
|
|
|
767
776
|
accountId?: string;
|
|
768
777
|
email?: string;
|
|
769
778
|
projectId?: string;
|
|
770
|
-
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
|
|
779
|
+
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
|
|
771
780
|
orgId?: string;
|
|
772
781
|
orgName?: string;
|
|
773
782
|
}
|
|
@@ -786,7 +795,7 @@ export interface OAuthAccountSummary {
|
|
|
786
795
|
email?: string;
|
|
787
796
|
projectId?: string;
|
|
788
797
|
enterpriseUrl?: string;
|
|
789
|
-
/** Organization/workspace the credential is scoped to (Anthropic multi-subscription). */
|
|
798
|
+
/** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
|
|
790
799
|
orgId?: string;
|
|
791
800
|
orgName?: string;
|
|
792
801
|
}
|
|
@@ -1137,7 +1146,10 @@ export class AuthStorage {
|
|
|
1137
1146
|
/** Tracks next credential index per provider:type key for round-robin distribution (non-session use). */
|
|
1138
1147
|
#providerRoundRobinIndex: Map<string, number> = new Map();
|
|
1139
1148
|
/** Tracks the last used credential per provider for a session (used for rate-limit switching). */
|
|
1140
|
-
#sessionLastCredential: Map<
|
|
1149
|
+
#sessionLastCredential: Map<
|
|
1150
|
+
string,
|
|
1151
|
+
Map<string, { type: AuthCredential["type"]; index: number; lastUsedAtMs?: number }>
|
|
1152
|
+
> = new Map();
|
|
1141
1153
|
/** Recent bearer fingerprints resolved for each durable OAuth row; used only for delayed usage-limit attribution. */
|
|
1142
1154
|
#oauthBearerFingerprints: Map<string, Map<number, string[]>> = new Map();
|
|
1143
1155
|
/** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
|
|
@@ -1685,17 +1697,18 @@ export class AuthStorage {
|
|
|
1685
1697
|
index: number,
|
|
1686
1698
|
): void {
|
|
1687
1699
|
if (!sessionId) return;
|
|
1700
|
+
const nowMs = Date.now();
|
|
1688
1701
|
const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map();
|
|
1689
|
-
sessionMap.set(sessionId, { type, index });
|
|
1702
|
+
sessionMap.set(sessionId, { type, index, lastUsedAtMs: nowMs });
|
|
1690
1703
|
this.#sessionLastCredential.set(provider, sessionMap);
|
|
1691
1704
|
|
|
1692
1705
|
try {
|
|
1693
1706
|
const credentialId = this.#getStoredCredentials(provider)[index]?.id;
|
|
1694
1707
|
if (credentialId !== undefined) {
|
|
1695
1708
|
const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
|
|
1696
|
-
const cacheValue = JSON.stringify({ type, index, credentialId });
|
|
1709
|
+
const cacheValue = JSON.stringify({ type, index, credentialId, lastUsedAtMs: nowMs });
|
|
1697
1710
|
// Expires in 30 days
|
|
1698
|
-
const expiresAtSec = Math.floor(
|
|
1711
|
+
const expiresAtSec = Math.floor(nowMs / 1000) + 30 * 24 * 60 * 60;
|
|
1699
1712
|
this.#store.setCache(cacheKey, cacheValue, expiresAtSec);
|
|
1700
1713
|
}
|
|
1701
1714
|
} catch (err) {
|
|
@@ -1707,7 +1720,7 @@ export class AuthStorage {
|
|
|
1707
1720
|
#getSessionCredential(
|
|
1708
1721
|
provider: string,
|
|
1709
1722
|
sessionId: string | undefined,
|
|
1710
|
-
): { type: AuthCredential["type"]; index: number } | undefined {
|
|
1723
|
+
): { type: AuthCredential["type"]; index: number; lastUsedAtMs?: number } | undefined {
|
|
1711
1724
|
if (!sessionId) return undefined;
|
|
1712
1725
|
let sessionMap = this.#sessionLastCredential.get(provider);
|
|
1713
1726
|
if (sessionMap?.has(sessionId)) {
|
|
@@ -1717,7 +1730,12 @@ export class AuthStorage {
|
|
|
1717
1730
|
const cacheKey = `${SESSION_STICKY_CACHE_PREFIX}${provider}:${sessionId}`;
|
|
1718
1731
|
const raw = this.#store.getCache(cacheKey);
|
|
1719
1732
|
if (raw) {
|
|
1720
|
-
const val = JSON.parse(raw) as {
|
|
1733
|
+
const val = JSON.parse(raw) as {
|
|
1734
|
+
type: AuthCredential["type"];
|
|
1735
|
+
index: number;
|
|
1736
|
+
credentialId?: number;
|
|
1737
|
+
lastUsedAtMs?: number;
|
|
1738
|
+
};
|
|
1721
1739
|
|
|
1722
1740
|
if (val.credentialId !== undefined) {
|
|
1723
1741
|
const stored = this.#getStoredCredentials(provider);
|
|
@@ -1737,7 +1755,7 @@ export class AuthStorage {
|
|
|
1737
1755
|
sessionMap = new Map();
|
|
1738
1756
|
this.#sessionLastCredential.set(provider, sessionMap);
|
|
1739
1757
|
}
|
|
1740
|
-
const sessionVal = { type: val.type, index: val.index };
|
|
1758
|
+
const sessionVal = { type: val.type, index: val.index, lastUsedAtMs: val.lastUsedAtMs };
|
|
1741
1759
|
sessionMap.set(sessionId, sessionVal);
|
|
1742
1760
|
return sessionVal;
|
|
1743
1761
|
}
|
|
@@ -3265,15 +3283,14 @@ export class AuthStorage {
|
|
|
3265
3283
|
const identifiers: string[] = [];
|
|
3266
3284
|
const email = this.#getUsageReportMetadataValue(report, "email");
|
|
3267
3285
|
if (email) identifiers.push(`email:${email.toLowerCase()}`);
|
|
3268
|
-
if (report.provider === "anthropic") {
|
|
3269
|
-
//
|
|
3270
|
-
// (
|
|
3271
|
-
// merge — scope every identifier by org
|
|
3272
|
-
//
|
|
3273
|
-
//
|
|
3274
|
-
//
|
|
3275
|
-
//
|
|
3276
|
-
// and only merge among themselves.
|
|
3286
|
+
if (report.provider === "anthropic" || report.provider === "openai-codex") {
|
|
3287
|
+
// One account email can hold several org-scoped subscriptions
|
|
3288
|
+
// (Anthropic organizations, ChatGPT workspaces). Reports from
|
|
3289
|
+
// different orgs must not merge — scope every identifier by org
|
|
3290
|
+
// when the report carries one; fall back to the account when the
|
|
3291
|
+
// email could not be recovered so no-email reports still merge
|
|
3292
|
+
// per org. Org-less reports (pre-upgrade caches) keep their bare
|
|
3293
|
+
// identifiers and only merge among themselves.
|
|
3277
3294
|
if (identifiers.length === 0) {
|
|
3278
3295
|
const accountId =
|
|
3279
3296
|
this.#getUsageReportMetadataValue(report, "accountId") ?? this.#getUsageReportScopeAccountId(report);
|
|
@@ -3281,12 +3298,11 @@ export class AuthStorage {
|
|
|
3281
3298
|
}
|
|
3282
3299
|
const orgId = this.#getUsageReportMetadataValue(report, "orgId");
|
|
3283
3300
|
if (orgId) {
|
|
3284
|
-
if (identifiers.length === 0) return [
|
|
3285
|
-
return identifiers.map(
|
|
3301
|
+
if (identifiers.length === 0) return [`${report.provider}:org:${orgId.toLowerCase()}`];
|
|
3302
|
+
return identifiers.map(
|
|
3303
|
+
identifier => `${report.provider}:org:${orgId.toLowerCase()}|${identifier.toLowerCase()}`,
|
|
3304
|
+
);
|
|
3286
3305
|
}
|
|
3287
|
-
return identifiers.map(identifier => `anthropic:${identifier.toLowerCase()}`);
|
|
3288
|
-
}
|
|
3289
|
-
if (report.provider === "openai-codex") {
|
|
3290
3306
|
return identifiers.map(identifier => `${report.provider}:${identifier.toLowerCase()}`);
|
|
3291
3307
|
}
|
|
3292
3308
|
const projectId =
|
|
@@ -3886,16 +3902,15 @@ export class AuthStorage {
|
|
|
3886
3902
|
* how fast the window's remaining quota must be consumed to fully use it
|
|
3887
3903
|
* before it resets and expires. Higher = more headroom at risk of expiring
|
|
3888
3904
|
* unused = ranked first, so selection chases quota that is about to be
|
|
3889
|
-
* wasted ("use it or lose it"). Without a reset clock the
|
|
3890
|
-
*
|
|
3905
|
+
* wasted ("use it or lose it"). Without a reset clock, the full window
|
|
3906
|
+
* duration is assumed to remain so clocked and clockless scores stay comparable.
|
|
3891
3907
|
*/
|
|
3892
3908
|
#computeWindowRequiredDrain(limit: UsageLimit | undefined, nowMs: number, fallbackDurationMs: number): number {
|
|
3893
3909
|
const headroom = 1 - this.#normalizeUsageFraction(limit);
|
|
3894
3910
|
if (headroom <= 0) return 0;
|
|
3895
3911
|
const resetAt = this.#resolveWindowResetAt(limit?.window);
|
|
3896
|
-
if (resetAt === undefined) return headroom;
|
|
3897
3912
|
const durationMs = limit?.window?.durationMs ?? fallbackDurationMs;
|
|
3898
|
-
let remainingMs = resetAt - nowMs;
|
|
3913
|
+
let remainingMs = resetAt === undefined ? durationMs : resetAt - nowMs;
|
|
3899
3914
|
if (Number.isFinite(durationMs) && durationMs > 0) {
|
|
3900
3915
|
remainingMs = Math.min(remainingMs, durationMs);
|
|
3901
3916
|
}
|
|
@@ -4135,16 +4150,39 @@ export class AuthStorage {
|
|
|
4135
4150
|
sessionPreferredCredential !== undefined &&
|
|
4136
4151
|
(sessionPreferredCredential.refresh.trim().length > 0 ||
|
|
4137
4152
|
Date.now() + OAUTH_REFRESH_SKEW_MS < sessionPreferredCredential.expires);
|
|
4138
|
-
// Skip ranking
|
|
4139
|
-
//
|
|
4140
|
-
//
|
|
4141
|
-
//
|
|
4153
|
+
// Skip ranking when the session already has a working preferred credential and its prompt
|
|
4154
|
+
// cache may still be warm. Only Anthropic has a verified idle boundary here; unverified
|
|
4155
|
+
// providers retain indefinite stickiness rather than risk switching while their prompt cache
|
|
4156
|
+
// remains warm. New Anthropic sessions (no preference), sessions whose preferred is blocked,
|
|
4157
|
+
// and sessions idle past {@link ANTHROPIC_SESSION_STICKY_CACHE_WARM_MS} still rank. Legacy
|
|
4158
|
+
// pins predating `lastUsedAtMs` count as warm until the next resolve rewrites the row.
|
|
4159
|
+
const sessionPreferredLastUsedAtMs =
|
|
4160
|
+
sessionCredential?.type === "oauth" ? sessionCredential.lastUsedAtMs : undefined;
|
|
4161
|
+
const sessionPreferredIsWarm =
|
|
4162
|
+
provider !== "anthropic" ||
|
|
4163
|
+
sessionPreferredLastUsedAtMs === undefined ||
|
|
4164
|
+
Date.now() - sessionPreferredLastUsedAtMs < ANTHROPIC_SESSION_STICKY_CACHE_WARM_MS;
|
|
4142
4165
|
const sessionPreferredIsAvailable =
|
|
4143
4166
|
sessionPreferredIndex !== undefined &&
|
|
4144
4167
|
sessionPreferredCanRefreshOrUse &&
|
|
4145
4168
|
!this.#isCredentialBlocked(provider, providerKey, sessionPreferredIndex, blockScope);
|
|
4146
|
-
const shouldRank = checkUsage && (!sessionPreferredIsAvailable || hasPlanRequirement);
|
|
4147
|
-
|
|
4169
|
+
const shouldRank = checkUsage && (!sessionPreferredIsAvailable || !sessionPreferredIsWarm || hasPlanRequirement);
|
|
4170
|
+
// When ranking, seed the pinned credential first in the evaluation order so it wins genuine
|
|
4171
|
+
// ties (the ranked comparator falls back to `orderPos`) without overriding a strictly-better
|
|
4172
|
+
// sibling — this respects the residual value of a same-account shared static prefix that other
|
|
4173
|
+
// workspace traffic may have kept warm, while still rotating away from a clearly-worse account.
|
|
4174
|
+
const baseRankingOrder = credentials.map((_credential, index) => index);
|
|
4175
|
+
let rankingOrder = shouldRank && sessionId ? baseRankingOrder : order;
|
|
4176
|
+
const sessionPreferredRankingPos =
|
|
4177
|
+
shouldRank && sessionId && sessionPreferredIndex !== undefined && !hasPlanRequirement
|
|
4178
|
+
? credentials.findIndex(entry => entry.index === sessionPreferredIndex)
|
|
4179
|
+
: -1;
|
|
4180
|
+
if (sessionPreferredRankingPos > 0) {
|
|
4181
|
+
rankingOrder = [
|
|
4182
|
+
sessionPreferredRankingPos,
|
|
4183
|
+
...baseRankingOrder.filter(index => index !== sessionPreferredRankingPos),
|
|
4184
|
+
];
|
|
4185
|
+
}
|
|
4148
4186
|
const candidates = shouldRank
|
|
4149
4187
|
? await this.#rankOAuthSelections({
|
|
4150
4188
|
providerKey,
|
|
@@ -4162,7 +4200,10 @@ export class AuthStorage {
|
|
|
4162
4200
|
.filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
|
|
4163
4201
|
.map(selection => ({ selection, usage: null, usageChecked: false }));
|
|
4164
4202
|
|
|
4165
|
-
|
|
4203
|
+
// On the warm skip path the candidate list follows the round-robin `order`, not the pin, so
|
|
4204
|
+
// hoist the pinned credential to the front to actually reuse it. When ranking ran, the pin is
|
|
4205
|
+
// already a mere tie-break via `rankingOrder`; do not override the ranked result here.
|
|
4206
|
+
if (!shouldRank && sessionPreferredIndex !== undefined && !hasPlanRequirement) {
|
|
4166
4207
|
const sessionPreferredCandidate = candidates.findIndex(
|
|
4167
4208
|
candidate =>
|
|
4168
4209
|
!this.#isCredentialBlocked(provider, providerKey, candidate.selection.index, blockScope) &&
|
|
@@ -4249,6 +4290,29 @@ export class AuthStorage {
|
|
|
4249
4290
|
hasPlanRequirement &&
|
|
4250
4291
|
candidates.some(candidate => getOpenAICodexPlanEligibility(candidate.usage, planRequirement) === true);
|
|
4251
4292
|
|
|
4293
|
+
// Plan-gated Codex models rank on every resolve to re-verify account tiers,
|
|
4294
|
+
// so the drain-urgency order can flip between two eligible accounts as their
|
|
4295
|
+
// usage headroom shifts. Promote the session-preferred credential back to the
|
|
4296
|
+
// front while it is unblocked and still plan-eligible (or the requirement is
|
|
4297
|
+
// unenforced and the pin is not known-ineligible) so an active session never
|
|
4298
|
+
// silently migrates accounts mid-conversation; blocked, exhausted, or
|
|
4299
|
+
// known-ineligible pins still fall through to the ranked sibling.
|
|
4300
|
+
if (hasPlanRequirement && sessionPreferredIndex !== undefined) {
|
|
4301
|
+
const sessionPreferredCandidate = candidates.findIndex(
|
|
4302
|
+
candidate =>
|
|
4303
|
+
!this.#isCredentialBlocked(provider, providerKey, candidate.selection.index, blockScope) &&
|
|
4304
|
+
candidate.selection.index === sessionPreferredIndex,
|
|
4305
|
+
);
|
|
4306
|
+
if (sessionPreferredCandidate > 0) {
|
|
4307
|
+
const preferred = candidates[sessionPreferredCandidate]!;
|
|
4308
|
+
const planEligibility = getOpenAICodexPlanEligibility(preferred.usage, planRequirement);
|
|
4309
|
+
if (planEligibility === true || (!enforcePlanRequirement && planEligibility !== false)) {
|
|
4310
|
+
candidates.splice(sessionPreferredCandidate, 1);
|
|
4311
|
+
candidates.unshift(preferred);
|
|
4312
|
+
}
|
|
4313
|
+
}
|
|
4314
|
+
}
|
|
4315
|
+
|
|
4252
4316
|
const passes: Array<{ allowBlocked: boolean; enforcePlanRequirement: boolean }> = [
|
|
4253
4317
|
{ allowBlocked: false, enforcePlanRequirement },
|
|
4254
4318
|
{ allowBlocked: true, enforcePlanRequirement },
|
|
@@ -5228,9 +5292,15 @@ export class AuthStorage {
|
|
|
5228
5292
|
if (credential.type !== "oauth") continue;
|
|
5229
5293
|
const credentialEmail = credential.email?.trim().toLowerCase();
|
|
5230
5294
|
const credentialAccountId = credential.accountId?.trim().toLowerCase();
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5295
|
+
// Every identity dimension present on BOTH sides must agree — the
|
|
5296
|
+
// account id is shared workspace-wide and one email can span
|
|
5297
|
+
// workspaces, so a single-dimension match can cross-link siblings.
|
|
5298
|
+
const emailComparable = Boolean(email && credentialEmail);
|
|
5299
|
+
const accountComparable = Boolean(accountId && credentialAccountId);
|
|
5300
|
+
if (!emailComparable && !accountComparable) continue;
|
|
5301
|
+
if (emailComparable && credentialEmail !== email) continue;
|
|
5302
|
+
if (accountComparable && credentialAccountId !== accountId) continue;
|
|
5303
|
+
matches.push(entry.id);
|
|
5234
5304
|
}
|
|
5235
5305
|
return matches;
|
|
5236
5306
|
}
|
|
@@ -5840,16 +5910,15 @@ function toStoredAuthCredential(row: AuthRow, credential: AuthCredential): Store
|
|
|
5840
5910
|
|
|
5841
5911
|
function resolveProviderCredentialIdentityKey(provider: string, identifiers: string[]): string | null {
|
|
5842
5912
|
const emailIdentifier = identifiers.find(identifier => identifier.startsWith("email:"));
|
|
5843
|
-
if (provider === "anthropic") {
|
|
5844
|
-
// One
|
|
5845
|
-
// Team seat plus a personal
|
|
5846
|
-
//
|
|
5847
|
-
//
|
|
5848
|
-
//
|
|
5849
|
-
//
|
|
5850
|
-
//
|
|
5851
|
-
//
|
|
5852
|
-
// org capture existed) keep their bare key.
|
|
5913
|
+
if (provider === "anthropic" || provider === "openai-codex") {
|
|
5914
|
+
// One account email can hold several organizations/workspaces (e.g. a
|
|
5915
|
+
// Team seat plus a personal plan), each with its own org-scoped token
|
|
5916
|
+
// and limit pools. Scope identity by org so both subscriptions can be
|
|
5917
|
+
// stored side by side. The qualifier rides on whichever base identity
|
|
5918
|
+
// is available, so an unqualified account/project fallback would
|
|
5919
|
+
// still collapse two subscriptions whenever the email could not be
|
|
5920
|
+
// recovered. Org-less credentials (rows written before org capture
|
|
5921
|
+
// existed) keep their bare key.
|
|
5853
5922
|
const base =
|
|
5854
5923
|
emailIdentifier ??
|
|
5855
5924
|
identifiers.find(identifier => identifier.startsWith("account:")) ??
|
|
@@ -5859,7 +5928,6 @@ function resolveProviderCredentialIdentityKey(provider: string, identifiers: str
|
|
|
5859
5928
|
// No base identity at all: the org alone still distinguishes the row.
|
|
5860
5929
|
return orgIdentifier ?? null;
|
|
5861
5930
|
}
|
|
5862
|
-
if (provider === "openai-codex" && emailIdentifier) return emailIdentifier;
|
|
5863
5931
|
const accountIdentifier = identifiers.find(identifier => identifier.startsWith("account:"));
|
|
5864
5932
|
if (accountIdentifier) return accountIdentifier;
|
|
5865
5933
|
if (emailIdentifier) return emailIdentifier;
|
|
@@ -5896,9 +5964,9 @@ function matchesReplacementCredential(
|
|
|
5896
5964
|
if (incomingIdentityKey === existingIdentityKey) return true;
|
|
5897
5965
|
if (existingIdentityKey === null) return false;
|
|
5898
5966
|
// One-way upgrade, applied only when the INCOMING identity key carries the
|
|
5899
|
-
// org qualifier (only anthropic keys do, so other
|
|
5900
|
-
// checks below). An org-scoped login `org:<o>`
|
|
5901
|
-
// existing row that denotes the same subscription:
|
|
5967
|
+
// org qualifier (only anthropic and openai-codex keys do, so other
|
|
5968
|
+
// providers never reach the checks below). An org-scoped login `org:<o>`
|
|
5969
|
+
// claims (and re-keys) any existing row that denotes the same subscription:
|
|
5902
5970
|
// - `org:<o>` — org-only row stored when identity recovery failed, claimed
|
|
5903
5971
|
// once a later same-org login recovers a base identity;
|
|
5904
5972
|
// - `<b>` for any base identity `<b>` (email/account/project) the incoming
|
|
@@ -5922,10 +5990,16 @@ function matchesReplacementCredential(
|
|
|
5922
5990
|
existing.type === "oauth" && existingIdentityKey.endsWith(`|${orgIdentifier}`)
|
|
5923
5991
|
? extractOAuthCredentialIdentifiers(existing)
|
|
5924
5992
|
: null;
|
|
5993
|
+
// A base identifier that merely repeats the org qualifier's id carries no
|
|
5994
|
+
// per-user identity (openai-codex stores the ChatGPT workspace id as both
|
|
5995
|
+
// accountId and orgId, shared by every member) — letting it act as a
|
|
5996
|
+
// claimable base would re-key another member's same-org row.
|
|
5997
|
+
const orgQualifierId = orgIdentifier.slice("org:".length);
|
|
5925
5998
|
for (const identifier of incomingIdentifiers) {
|
|
5926
5999
|
const isBase =
|
|
5927
6000
|
identifier.startsWith("email:") || identifier.startsWith("account:") || identifier.startsWith("project:");
|
|
5928
6001
|
if (!isBase) continue;
|
|
6002
|
+
if (identifier.slice(identifier.indexOf(":") + 1) === orgQualifierId) continue;
|
|
5929
6003
|
if (existingIdentityKey === identifier) return true;
|
|
5930
6004
|
if (existingIdentityKey === `${identifier}|${orgIdentifier}`) return true;
|
|
5931
6005
|
if (existingIdentifiers?.includes(identifier)) return true;
|
|
@@ -157,6 +157,53 @@ describe("Kimi K3 thinking transport", () => {
|
|
|
157
157
|
expect(payload).toMatchObject({ thinking: { type: "enabled", effort: Effort.Low } });
|
|
158
158
|
expect(payload).not.toMatchObject({ thinking: { type: "disabled" } });
|
|
159
159
|
});
|
|
160
|
+
|
|
161
|
+
it("downgrades named tool choice to required for K3 thinking", async () => {
|
|
162
|
+
vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
|
|
163
|
+
const bundledModel = getBundledModel<"openai-completions">("kimi-code", "k3");
|
|
164
|
+
expect(bundledModel.compat.thinkingFormat).toBe("zai");
|
|
165
|
+
let payload: unknown;
|
|
166
|
+
const capturePayload = async (
|
|
167
|
+
model: Model<"openai-completions">,
|
|
168
|
+
toolChoice: "required" | { type: "tool"; name: string },
|
|
169
|
+
tools = TITLE_CONTEXT.tools,
|
|
170
|
+
) => {
|
|
171
|
+
const stream = streamKimi(
|
|
172
|
+
model,
|
|
173
|
+
{ ...TITLE_CONTEXT, tools },
|
|
174
|
+
{
|
|
175
|
+
apiKey: "test-key",
|
|
176
|
+
format: "openai",
|
|
177
|
+
reasoning: Effort.Max,
|
|
178
|
+
toolChoice,
|
|
179
|
+
onPayload: body => {
|
|
180
|
+
payload = body;
|
|
181
|
+
throw new Error("stop after payload capture");
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
await stream.result();
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
for (const model of [K3_MODEL, bundledModel]) {
|
|
189
|
+
await capturePayload(model, { type: "tool", name: "set_title" });
|
|
190
|
+
expect(payload).toMatchObject({
|
|
191
|
+
thinking: { type: "enabled" },
|
|
192
|
+
tool_choice: "required",
|
|
193
|
+
tools: [{ type: "function", function: { name: "set_title" } }],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await capturePayload(model, "required");
|
|
197
|
+
expect(payload).toMatchObject({
|
|
198
|
+
thinking: { type: "enabled" },
|
|
199
|
+
tool_choice: "required",
|
|
200
|
+
tools: [{ type: "function", function: { name: "set_title" } }],
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
await capturePayload(K3_MODEL, { type: "tool", name: "missing_tool" }, []);
|
|
205
|
+
expect((payload as { tool_choice?: unknown }).tool_choice).toBeUndefined();
|
|
206
|
+
});
|
|
160
207
|
});
|
|
161
208
|
|
|
162
209
|
describe("Kimi K2.7 Code thinking policy", () => {
|
|
@@ -334,7 +334,7 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
|
|
|
334
334
|
toolConfig,
|
|
335
335
|
additionalModelRequestFields,
|
|
336
336
|
};
|
|
337
|
-
options?.onPayload?.(commandInput);
|
|
337
|
+
options?.onPayload?.(commandInput, model);
|
|
338
338
|
|
|
339
339
|
const host = `bedrock-runtime.${region}.amazonaws.com`;
|
|
340
340
|
const url = `https://${host}/model/${encodeURIComponent(model.id)}/converse-stream`;
|