@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 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 never rewrite it. Lets
15
- * one account email hold credentials for multiple subscriptions.
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
@@ -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
- * Defaults to "short" and uses PI_CACHE_RETENTION for backward compatibility.
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",
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.4",
42
- "@oh-my-pi/pi-utils": "17.0.4",
43
- "@oh-my-pi/pi-wire": "17.0.4",
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
  },
@@ -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<string, Map<string, { type: AuthCredential["type"]; index: number }>> = new 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(Date.now() / 1000) + 30 * 24 * 60 * 60;
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 { type: AuthCredential["type"]; index: number; credentialId?: number };
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
- // Anthropic: one account email can hold several organizations
3270
- // (Team seat + personal Max). Reports from different orgs must not
3271
- // merge — scope every identifier by org when the report carries one.
3272
- // When the email could not be recovered, fall back to the account
3273
- // (identical across orgs, hence the org qualifier is what keeps two
3274
- // subscriptions apart) so no-email reports still merge per org.
3275
- // Org-less reports (pre-upgrade caches) keep their bare identifiers
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 [`anthropic:org:${orgId.toLowerCase()}`];
3285
- return identifiers.map(identifier => `anthropic:org:${orgId.toLowerCase()}|${identifier.toLowerCase()}`);
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 headroom
3890
- * fraction alone is returned, degrading to most-headroom-first.
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 only when the session already has a working preferred credential re-ranking
4139
- // mid-session causes account switches that cold-start the server-side prompt cache. New sessions
4140
- // (no preference) and sessions whose preferred is blocked still rank, so we pick the account
4141
- // with the most headroom proactively and fall back intelligently when rate-limited.
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
- const rankingOrder = shouldRank && sessionId ? credentials.map((_credential, index) => index) : order;
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
- if (sessionPreferredIndex !== undefined && !hasPlanRequirement) {
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
- if ((email && credentialEmail === email) || (accountId && credentialAccountId === accountId)) {
5232
- matches.push(entry.id);
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 Anthropic account email can hold several organizations (e.g. a
5845
- // Team seat plus a personal Max plan), each with its own org-scoped
5846
- // token and limit pools. Scope identity by org so both subscriptions
5847
- // can be stored side by side. The qualifier rides on whichever base
5848
- // identity is available the account UUID is IDENTICAL across the
5849
- // orgs of one login account, so an unqualified account/project
5850
- // fallback would still collapse two subscriptions whenever the email
5851
- // could not be recovered. Org-less credentials (rows written before
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 providers never reach the
5900
- // checks below). An org-scoped login `org:<o>` claims (and re-keys) any
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`;