@oh-my-pi/pi-ai 18.4.4 → 18.4.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +23 -26
  2. package/dist/types/auth/cascade.d.ts +17 -6
  3. package/dist/types/auth/rank.d.ts +7 -1
  4. package/dist/types/auth/refresh.d.ts +8 -0
  5. package/dist/types/auth/types.d.ts +19 -2
  6. package/dist/types/auth-gateway/dispatch.d.ts +7 -7
  7. package/dist/types/auth-gateway/http.d.ts +4 -1
  8. package/dist/types/auth-gateway/types.d.ts +2 -0
  9. package/dist/types/auth-retry.d.ts +3 -1
  10. package/dist/types/index.d.ts +1 -0
  11. package/dist/types/providers/anthropic.d.ts +7 -0
  12. package/dist/types/providers/cursor/interaction-query.d.ts +4 -2
  13. package/dist/types/providers/cursor.d.ts +14 -12
  14. package/dist/types/providers/factory-droid/gemini.d.ts +15 -0
  15. package/dist/types/providers/factory-droid.d.ts +13 -0
  16. package/dist/types/providers/google-shared.d.ts +12 -0
  17. package/dist/types/providers/openai-chat-wire.d.ts +7 -2
  18. package/dist/types/providers/openai-completions.d.ts +1 -1
  19. package/dist/types/registry/oauth/device-code.d.ts +2 -2
  20. package/dist/types/registry/oauth/factory-droid.d.ts +3 -0
  21. package/dist/types/registry/oauth/types.d.ts +13 -3
  22. package/dist/types/types.d.ts +5 -0
  23. package/dist/types/usage/factory-droid.d.ts +11 -0
  24. package/dist/types/usage.d.ts +4 -0
  25. package/dist/types/utils/schema/allowlist.d.ts +2 -0
  26. package/dist/types/utils/schema/index.d.ts +1 -0
  27. package/package.json +6 -6
  28. package/src/api-registry.ts +1 -0
  29. package/src/auth/cascade.ts +51 -11
  30. package/src/auth/health.ts +3 -0
  31. package/src/auth/oauth.ts +4 -0
  32. package/src/auth/rank.ts +11 -1
  33. package/src/auth/refresh.ts +27 -2
  34. package/src/auth/select.ts +52 -24
  35. package/src/auth/types.ts +18 -2
  36. package/src/auth/usage-cache.ts +6 -0
  37. package/src/auth/usage.ts +10 -5
  38. package/src/auth-gateway/dispatch.ts +20 -20
  39. package/src/auth-gateway/http.ts +44 -2
  40. package/src/auth-gateway/routes/embeddings.ts +1 -1
  41. package/src/auth-gateway/routes/images.ts +1 -1
  42. package/src/auth-gateway/routes/rerank.ts +1 -1
  43. package/src/auth-gateway/routes/speech.ts +1 -1
  44. package/src/auth-gateway/routes/systemone.ts +1 -1
  45. package/src/auth-gateway/routes/transcriptions.ts +1 -1
  46. package/src/auth-gateway/routes/video.ts +4 -3
  47. package/src/auth-gateway/server.ts +40 -10
  48. package/src/auth-gateway/types.ts +2 -0
  49. package/src/auth-retry.ts +3 -1
  50. package/src/auth-storage.ts +3 -1
  51. package/src/index.ts +1 -0
  52. package/src/providers/amazon-bedrock.ts +10 -8
  53. package/src/providers/anthropic.ts +126 -16
  54. package/src/providers/cursor/interaction-query.ts +11 -8
  55. package/src/providers/cursor.ts +980 -314
  56. package/src/providers/factory-droid/droid-identity.md +1 -0
  57. package/src/providers/factory-droid/gemini.ts +538 -0
  58. package/src/providers/factory-droid.ts +623 -0
  59. package/src/providers/google-shared.ts +2 -2
  60. package/src/providers/openai-chat-wire.ts +11 -2
  61. package/src/providers/openai-completions.ts +106 -13
  62. package/src/registry/engine/device-code.ts +2 -2
  63. package/src/registry/engine/refresh.ts +1 -0
  64. package/src/registry/hooks/device-code.ts +1 -0
  65. package/src/registry/oauth/device-code.ts +19 -3
  66. package/src/registry/oauth/factory-droid.ts +62 -0
  67. package/src/registry/oauth/types.ts +13 -3
  68. package/src/stream.ts +38 -4
  69. package/src/types.ts +5 -0
  70. package/src/usage/cursor.ts +3 -4
  71. package/src/usage/factory-droid.ts +226 -0
  72. package/src/usage/registry.ts +3 -0
  73. package/src/usage/xai-oauth.ts +21 -8
  74. package/src/usage.ts +4 -0
  75. package/src/utils/schema/CONSTRAINTS.md +15 -0
  76. package/src/utils/schema/allowlist.ts +160 -0
  77. package/src/utils/schema/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.4.4",
3
+ "version": "18.4.6",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.4.4",
159
- "@oh-my-pi/pi-catalog": "18.4.4",
160
- "@oh-my-pi/pi-natives": "18.4.4",
161
- "@oh-my-pi/pi-utils": "18.4.4",
162
- "@oh-my-pi/pi-wire": "18.4.4"
158
+ "@oh-my-pi/omptype": "18.4.6",
159
+ "@oh-my-pi/pi-catalog": "18.4.6",
160
+ "@oh-my-pi/pi-natives": "18.4.6",
161
+ "@oh-my-pi/pi-utils": "18.4.6",
162
+ "@oh-my-pi/pi-wire": "18.4.6"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -29,6 +29,7 @@ const BUILTIN_API_IDS = [
29
29
  "google-vertex",
30
30
  "ollama-chat",
31
31
  "cursor-agent",
32
+ "factory-droid-agent",
32
33
  "gitlab-duo-agent",
33
34
  "devin-agent",
34
35
  "apple-foundation-models",
@@ -8,7 +8,15 @@ import { getEnvApiKey, getEnvApiKeyName } from "../stream";
8
8
  import type { SessionAffinity } from "./affinity";
9
9
  import type { CredentialPool } from "./pool";
10
10
  import type { CredentialSelector } from "./select";
11
- import type { AuthApiKeyOptions, AuthCredential, AuthSource, AuthSourceOptions, KeysApi, LimitsApi } from "./types";
11
+ import type {
12
+ AuthApiKeyOptions,
13
+ AuthCredential,
14
+ AuthSource,
15
+ AuthSourceOptions,
16
+ KeysApi,
17
+ LimitsApi,
18
+ OAuthRequestIdentity,
19
+ } from "./types";
12
20
 
13
21
  /**
14
22
  * Default config value resolver that checks env vars and treats as literal.
@@ -23,6 +31,7 @@ async function defaultConfigValueResolver(config: string): Promise<string | unde
23
31
  export class KeyOverrides {
24
32
  #runtimeOverrides: Map<string, string> = new Map();
25
33
  #configOverrides: Map<string, string> = new Map();
34
+ #configFallbacks: Map<string, string> = new Map();
26
35
  #configValueResolver: (config: string) => Promise<string | undefined>;
27
36
 
28
37
  constructor(resolver?: (config: string) => Promise<string | undefined>) {
@@ -41,6 +50,11 @@ export class KeyOverrides {
41
50
  return this.#configOverrides.get(provider);
42
51
  }
43
52
 
53
+ /** Config value consulted only after stored OAuth/login credentials. */
54
+ fallbackKey(provider: string): string | undefined {
55
+ return this.#configFallbacks.get(provider);
56
+ }
57
+
44
58
  /** Resolve a config value (env var name, "!command", literal) to the secret. */
45
59
  resolve(config: string): Promise<string | undefined> {
46
60
  return this.#configValueResolver(config);
@@ -70,16 +84,28 @@ export class KeyOverrides {
70
84
  *
71
85
  * Lower priority than {@link KeyOverrides.setRuntime} so a CLI `--api-key`
72
86
  * still wins for the duration of a single invocation.
87
+ *
88
+ * `fallback: true` instead ranks the value below stored OAuth and `/login`
89
+ * credentials (at the env-var tier). Providers that own a `/login` flow use
90
+ * this so their default key reference (e.g. an unset env-var name, which
91
+ * resolves to its literal text) cannot shadow the key the user logged in with.
73
92
  */
74
- setConfig(provider: string, apiKeyConfig: string): void {
75
- this.#configOverrides.set(provider, apiKeyConfig);
93
+ setConfig(provider: string, apiKeyConfig: string, options?: { fallback?: boolean }): void {
94
+ if (options?.fallback) {
95
+ this.#configOverrides.delete(provider);
96
+ this.#configFallbacks.set(provider, apiKeyConfig);
97
+ } else {
98
+ this.#configFallbacks.delete(provider);
99
+ this.#configOverrides.set(provider, apiKeyConfig);
100
+ }
76
101
  }
77
102
 
78
103
  /**
79
- * Remove a single config-sourced API key override.
104
+ * Remove a single config-sourced API key (override or fallback).
80
105
  */
81
106
  removeConfig(provider: string): void {
82
107
  this.#configOverrides.delete(provider);
108
+ this.#configFallbacks.delete(provider);
83
109
  }
84
110
 
85
111
  /**
@@ -88,6 +114,7 @@ export class KeyOverrides {
88
114
  */
89
115
  clearConfig(): void {
90
116
  this.#configOverrides.clear();
117
+ this.#configFallbacks.clear();
91
118
  }
92
119
 
93
120
  /**
@@ -169,7 +196,7 @@ export class KeyCascade implements KeysApi {
169
196
  /**
170
197
  * Classify where a provider's auth comes from, following the same precedence
171
198
  * as {@link KeyCascade.get}: runtime override → config override →
172
- * stored OAuth → login-stored api_key → env var → stored api_key.
199
+ * stored OAuth → login-stored api_key → config fallback → env var → stored api_key.
173
200
  * Returns undefined when no auth is configured.
174
201
  *
175
202
  * Compact, structured counterpart to {@link KeyCascade.describe}; `env`
@@ -184,6 +211,7 @@ export class KeyCascade implements KeysApi {
184
211
  if (bearing.some(credential => credential.type === "api_key" && credential.source === "login")) {
185
212
  return { kind: "api_key", concrete: true };
186
213
  }
214
+ if (this.#deps.overrides.fallbackKey(provider) !== undefined) return { kind: "config", concrete: true };
187
215
  if (
188
216
  (env === "dedicated" && this.#hasDedicatedEnvAuth(provider)) ||
189
217
  (env === "aliases" && (this.#hasDedicatedEnvAuth(provider) || Boolean(getEnvApiKey(provider))))
@@ -256,6 +284,9 @@ export class KeyCascade implements KeysApi {
256
284
  return this.#deps.overrides.resolve(loginApiKeySelection.credential.key);
257
285
  }
258
286
 
287
+ const fallbackKey = this.#deps.overrides.fallbackKey(provider);
288
+ if (fallbackKey !== undefined) return this.#deps.overrides.resolve(fallbackKey);
289
+
259
290
  const envKey = getEnvApiKey(provider);
260
291
  if (envKey) return envKey;
261
292
 
@@ -273,10 +304,12 @@ export class KeyCascade implements KeysApi {
273
304
  options?: AuthApiKeyOptions,
274
305
  ): Promise<ResolvedApiKey | undefined> {
275
306
  let credentialId: number | undefined;
276
- const apiKey = await this.get(provider, sessionId, options, id => {
307
+ let oauthIdentity: OAuthRequestIdentity | undefined;
308
+ const apiKey = await this.get(provider, sessionId, options, (id, identity) => {
277
309
  credentialId = id;
310
+ oauthIdentity = identity;
278
311
  });
279
- return apiKey === undefined ? undefined : { apiKey, credentialId };
312
+ return apiKey === undefined ? undefined : { apiKey, credentialId, ...(oauthIdentity ? { oauthIdentity } : {}) };
280
313
  }
281
314
 
282
315
  /**
@@ -288,7 +321,7 @@ export class KeyCascade implements KeysApi {
288
321
  provider: string,
289
322
  sessionId?: string,
290
323
  options?: AuthApiKeyOptions,
291
- onCredentialId?: (id: number) => void,
324
+ onCredentialId?: (id: number, identity?: OAuthRequestIdentity) => void,
292
325
  ): Promise<string | undefined> {
293
326
  // Runtime override takes highest priority
294
327
  const runtimeKey = this.#deps.overrides.runtimeKey(provider);
@@ -310,7 +343,10 @@ export class KeyCascade implements KeysApi {
310
343
  // then a stored static api_key (which may be a stale broker-migrated copy) as a last resort.
311
344
  const oauthResolved = await this.#deps.selector.resolveOAuth(provider, sessionId, options);
312
345
  if (oauthResolved) {
313
- if (oauthResolved.credentialId !== undefined) onCredentialId?.(oauthResolved.credentialId);
346
+ if (onCredentialId && oauthResolved.credentialId !== undefined) {
347
+ const { orgId, region, inferenceRegion } = oauthResolved.credential;
348
+ onCredentialId(oauthResolved.credentialId, { orgId, region, inferenceRegion });
349
+ }
314
350
  return oauthResolved.apiKey;
315
351
  }
316
352
  const loginApiKeySelection = await this.#deps.selector.selectApiKey(
@@ -334,6 +370,9 @@ export class KeyCascade implements KeysApi {
334
370
  // suppresses account_uuid for this session.
335
371
  if (sessionId) this.#deps.affinity.forget(provider, sessionId);
336
372
 
373
+ const fallbackKey = this.#deps.overrides.fallbackKey(provider);
374
+ if (fallbackKey !== undefined) return this.#deps.overrides.resolve(fallbackKey);
375
+
337
376
  const envKey = getEnvApiKey(provider);
338
377
  if (envKey) return envKey;
339
378
  const apiKeySelection = await this.#deps.selector.selectApiKey(
@@ -458,6 +497,7 @@ export class KeyCascade implements KeysApi {
458
497
  !this.isKeylessFallback(provider, credential),
459
498
  );
460
499
  if (loginApiKeySource) return loginApiKeySource;
500
+ if (this.#deps.overrides.fallbackKey(provider) !== undefined) return "provider config (fallback)";
461
501
  if (getEnvApiKey(provider)) return `env (over ${baseLabel})`;
462
502
  const apiKeySource = describeStored(
463
503
  "api_key",
@@ -475,8 +515,8 @@ export class KeyCascade implements KeysApi {
475
515
  this.#deps.overrides.removeRuntime(provider);
476
516
  }
477
517
 
478
- setConfig(provider: string, apiKeyConfig: string): void {
479
- this.#deps.overrides.setConfig(provider, apiKeyConfig);
518
+ setConfig(provider: string, apiKeyConfig: string, options?: { fallback?: boolean }): void {
519
+ this.#deps.overrides.setConfig(provider, apiKeyConfig, options);
480
520
  }
481
521
 
482
522
  removeConfig(provider: string): void {
@@ -76,6 +76,9 @@ export class CredentialHealth implements HealthApi {
76
76
  email: credential.email,
77
77
  enterpriseUrl: credential.enterpriseUrl,
78
78
  apiEndpoint: credential.apiEndpoint,
79
+ orgId: credential.orgId,
80
+ region: credential.region,
81
+ inferenceRegion: credential.inferenceRegion,
79
82
  };
80
83
  }
81
84
 
package/src/auth/oauth.ts CHANGED
@@ -151,6 +151,8 @@ export class OAuthAccounts implements OAuthApi {
151
151
  apiEndpoint: credential.apiEndpoint,
152
152
  orgId: credential.orgId,
153
153
  orgName: credential.orgName,
154
+ region: credential.region,
155
+ inferenceRegion: credential.inferenceRegion,
154
156
  };
155
157
  }
156
158
 
@@ -206,6 +208,8 @@ export class OAuthAccounts implements OAuthApi {
206
208
  enterpriseUrl: credential.enterpriseUrl,
207
209
  orgId: credential.orgId,
208
210
  orgName: credential.orgName,
211
+ region: credential.region,
212
+ inferenceRegion: credential.inferenceRegion,
209
213
  };
210
214
  } catch (error) {
211
215
  return {
package/src/auth/rank.ts CHANGED
@@ -20,6 +20,11 @@ export type UsageCandidate<T extends AuthCredential> = {
20
20
  inReserve?: boolean;
21
21
  /** True only when reserve ranking had a usable remaining-fraction measurement. */
22
22
  reserveMeasured?: boolean;
23
+ /**
24
+ * Present after ranking: the account's renewable allowance for this request is
25
+ * spent, so if it still serves it draws on paid overage such as Codex credits.
26
+ */
27
+ allowanceSpent?: boolean;
23
28
  };
24
29
 
25
30
  /** OAuth credential eligible for usage ranking. */
@@ -39,6 +44,7 @@ export type UsageRankedCandidate<T extends AuthCredential> = UsageCandidate<T> &
39
44
  reserveMeasured?: boolean;
40
45
  accountPriority: number;
41
46
  hasPriorityBoost: boolean;
47
+ allowanceSpent: boolean;
42
48
  usageMeasured: boolean;
43
49
  planPriority: number;
44
50
  secondaryUsed: number;
@@ -90,6 +96,9 @@ function compareUsageRankedCandidatePriority(
90
96
  if (planGated && left.planPriority !== right.planPriority) {
91
97
  return left.planPriority - right.planPriority;
92
98
  }
99
+ // Paid overage (Codex credits) never renews, so an account serving past its
100
+ // allowance yields to any sibling whose renewable allowance is left (#13889).
101
+ if (left.allowanceSpent !== right.allowanceSpent) return left.allowanceSpent ? 1 : -1;
93
102
  if (left.inReserve !== right.inReserve) return left.inReserve ? 1 : -1;
94
103
  if (left.hasPriorityBoost !== right.hasPriorityBoost) return left.hasPriorityBoost ? -1 : 1;
95
104
  // Short-window guard: candidates whose primary (e.g. 5h) window is
@@ -130,7 +139,7 @@ function compareUsageRankedCandidates(
130
139
  return priority !== 0 ? priority : left.orderPos - right.orderPos;
131
140
  }
132
141
 
133
- /** Sort ranked candidates by blocks, plan, reserve, boost, hot window, usage and drain. */
142
+ /** Sort ranked candidates by blocks, plan, spent allowance, reserve, boost, hot window, usage and drain. */
134
143
  export function orderUsageRankedCandidates<T extends AuthCredential>(
135
144
  candidates: UsageRankedCandidate<T>[],
136
145
  planGated: boolean,
@@ -142,5 +151,6 @@ export function orderUsageRankedCandidates<T extends AuthCredential>(
142
151
  usageChecked: candidate.usageChecked,
143
152
  inReserve: candidate.inReserve,
144
153
  reserveMeasured: candidate.reserveMeasured,
154
+ allowanceSpent: candidate.allowanceSpent,
145
155
  }));
146
156
  }
@@ -44,6 +44,32 @@ const OAUTH_REFRESH_LEASE_RENEW_MS = 5_000;
44
44
  const OAUTH_REFRESH_OPERATION_TIMEOUT_MS = 10_000;
45
45
  const DEFAULT_OAUTH_REFRESH_TIMEOUT_MS = 10_000;
46
46
 
47
+ type OrganizationScope = Pick<
48
+ OAuthCredentials,
49
+ "orgId" | "region" | "inferenceRegion" | "activeOrganizationId" | "orgName"
50
+ >;
51
+
52
+ /**
53
+ * Organization-scoped identity after a refresh: refreshed values win; stored
54
+ * org/residency/inference scope carries over only while the organization is
55
+ * unchanged. The WorkOS selection always falls back to the stored one.
56
+ */
57
+ export function mergeRefreshedOrganizationScope(
58
+ current: OrganizationScope,
59
+ refreshed: OrganizationScope,
60
+ ): OrganizationScope {
61
+ const sameOrg =
62
+ (refreshed.orgId === undefined || refreshed.orgId === current.orgId) &&
63
+ (refreshed.activeOrganizationId === undefined || refreshed.activeOrganizationId === current.activeOrganizationId);
64
+ return {
65
+ orgId: refreshed.orgId ?? (sameOrg ? current.orgId : undefined),
66
+ region: refreshed.region ?? (sameOrg ? current.region : undefined),
67
+ inferenceRegion: refreshed.inferenceRegion ?? (sameOrg ? current.inferenceRegion : undefined),
68
+ activeOrganizationId: refreshed.activeOrganizationId ?? current.activeOrganizationId,
69
+ orgName: refreshed.orgName ?? (sameOrg ? current.orgName : undefined),
70
+ };
71
+ }
72
+
47
73
  /** Merge provider refresh bytes with the stored OAuth row, preserving subtype metadata for every refresh path. */
48
74
  export function mergeRefreshedCredential<T extends OAuthCredential>(current: T, refreshed: OAuthCredentials): T {
49
75
  return {
@@ -56,8 +82,7 @@ export function mergeRefreshedCredential<T extends OAuthCredential>(current: T,
56
82
  projectId: refreshed.projectId ?? current.projectId,
57
83
  enterpriseUrl: refreshed.enterpriseUrl ?? current.enterpriseUrl,
58
84
  apiEndpoint: refreshed.apiEndpoint ?? current.apiEndpoint,
59
- orgId: refreshed.orgId ?? current.orgId,
60
- orgName: refreshed.orgName ?? current.orgName,
85
+ ...mergeRefreshedOrganizationScope(current, refreshed),
61
86
  authorizedAt: refreshed.authorizedAt ?? current.authorizedAt,
62
87
  };
63
88
  }
@@ -267,6 +267,7 @@ export class CredentialSelector {
267
267
  blockedUntil,
268
268
  inReserve: false,
269
269
  accountPriority: 0,
270
+ allowanceSpent: remainingUsageFraction(strategy, usage, args.rankingContext, nowMs) === 0,
270
271
  usageMeasured,
271
272
  hasPriorityBoost: strategy.hasPriorityBoost?.(primary, primaryUncapped, args.rankingContext) ?? false,
272
273
  planPriority: 0,
@@ -469,6 +470,7 @@ export class CredentialSelector {
469
470
  reserveFraction !== undefined && remainingFraction !== undefined && remainingFraction <= reserveFraction,
470
471
  reserveMeasured: reserveFraction !== undefined && remainingFraction !== undefined,
471
472
  accountPriority: policy?.priority === undefined || !Number.isFinite(policy.priority) ? 0 : policy.priority,
473
+ allowanceSpent: remainingFraction === 0,
472
474
  usageMeasured,
473
475
  hasPriorityBoost: strategy?.hasPriorityBoost?.(primary, primaryUncapped, args.rankingContext) ?? false,
474
476
  planPriority: planPriority(args.planGate, usage),
@@ -573,12 +575,27 @@ export class CredentialSelector {
573
575
  sessionPreferredCanRefreshOrUse &&
574
576
  !this.#deps.blocks.isBlocked(provider, providerKey, sessionPreferredIndex, blockScopes);
575
577
  const sessionPinIsExplicit = sessionCredential?.type === "oauth" && sessionCredential.explicit === true;
578
+ const rankDespitePin =
579
+ !sessionPreferredIsAvailable ||
580
+ !sessionPreferredIsWarm ||
581
+ hasPlanRequirement ||
582
+ (policyReserveEnabled && !sessionPinIsExplicit);
583
+ // A warm automatic pin whose allowance is spent keeps serving on paid overage
584
+ // (Codex credits) and is never blocked, so check it and rank when spent: a
585
+ // sibling with renewable allowance left must take over (#13889). tryOAuth
586
+ // reads this same report for the pin, so it is handed on below.
587
+ const sessionPreferredUsage =
588
+ checkUsage && !rankDespitePin && !sessionPinIsExplicit && sessionPreferredCredential
589
+ ? await this.#deps.usage.report(provider, sessionPreferredCredential, {
590
+ ...options,
591
+ timeoutMs: this.#deps.usage.requestTimeoutMs,
592
+ })
593
+ : undefined;
576
594
  const shouldRank =
577
595
  checkUsage &&
578
- (!sessionPreferredIsAvailable ||
579
- !sessionPreferredIsWarm ||
580
- hasPlanRequirement ||
581
- (policyReserveEnabled && !sessionPinIsExplicit));
596
+ (rankDespitePin ||
597
+ (sessionPreferredUsage !== undefined &&
598
+ remainingUsageFraction(strategy, sessionPreferredUsage, rankingContext, Date.now()) === 0));
582
599
  // When ranking, seed the pinned credential first in the evaluation order so it wins genuine
583
600
  // ties (the ranked comparator falls back to `orderPos`) without overriding a strictly-better
584
601
  // sibling — this respects the residual value of a same-account shared static prefix that other
@@ -621,11 +638,11 @@ export class CredentialSelector {
621
638
  : policyOrder
622
639
  .map(idx => credentials[idx])
623
640
  .filter((selection): selection is { credential: OAuthCredential; index: number } => Boolean(selection))
624
- .map(selection => ({
625
- selection,
626
- usage: null,
627
- usageChecked: false,
628
- }));
641
+ .map(selection =>
642
+ selection.index === sessionPreferredIndex && sessionPreferredUsage !== undefined
643
+ ? { selection, usage: sessionPreferredUsage, usageChecked: true }
644
+ : { selection, usage: null, usageChecked: false },
645
+ );
629
646
  const preflightFailures = new Set<OAuthCandidate>();
630
647
 
631
648
  const sessionPreferredCandidate = candidates.findIndex(
@@ -634,23 +651,34 @@ export class CredentialSelector {
634
651
  candidate.selection.index === sessionPreferredIndex,
635
652
  );
636
653
  const preferredCandidate = sessionPreferredCandidate === -1 ? undefined : candidates[sessionPreferredCandidate];
637
- const reserveWouldEvictAutomaticPin = (excludePreflightFailures: boolean): boolean =>
654
+ // A warm automatic pin normally wins. Two policies may evict it, each only
655
+ // while a sibling is confirmed better: reserve (sibling measured outside
656
+ // reserve) and spent allowance (unblocked sibling with allowance left).
657
+ const automaticPinWouldBeEvicted = (excludePreflightFailures: boolean): boolean =>
638
658
  !sessionPinIsExplicit &&
639
- preferredCandidate?.inReserve === true &&
640
- candidates.some(
641
- candidate =>
642
- candidate !== preferredCandidate &&
643
- (!excludePreflightFailures || !preflightFailures.has(candidate)) &&
659
+ preferredCandidate !== undefined &&
660
+ candidates.some(candidate => {
661
+ if (candidate === preferredCandidate) return false;
662
+ if (excludePreflightFailures && preflightFailures.has(candidate)) return false;
663
+ if (
664
+ preferredCandidate.inReserve === true &&
644
665
  candidate.reserveMeasured === true &&
645
- candidate.inReserve === false,
646
- );
647
- const reserveWouldEvictBeforePreflight = reserveWouldEvictAutomaticPin(false);
648
- // A warm automatic pin normally wins. Reserve is the one policy allowed
649
- // to evict it, and only while a sibling is confirmed outside reserve.
666
+ candidate.inReserve === false
667
+ ) {
668
+ return true;
669
+ }
670
+ return (
671
+ preferredCandidate.allowanceSpent === true &&
672
+ candidate.usage !== null &&
673
+ candidate.allowanceSpent === false &&
674
+ !this.#deps.blocks.isBlocked(provider, providerKey, candidate.selection.index, blockScopes)
675
+ );
676
+ });
677
+ const pinEvictedBeforePreflight = automaticPinWouldBeEvicted(false);
650
678
  if (
651
679
  !hasPlanRequirement &&
652
680
  sessionPreferredCandidate > 0 &&
653
- (!shouldRank || sessionPinIsExplicit || (sessionPreferredIsWarm && !reserveWouldEvictBeforePreflight))
681
+ (!shouldRank || sessionPinIsExplicit || (sessionPreferredIsWarm && !pinEvictedBeforePreflight))
654
682
  ) {
655
683
  const [preferred] = candidates.splice(sessionPreferredCandidate, 1);
656
684
  candidates.unshift(preferred);
@@ -774,14 +802,14 @@ export class CredentialSelector {
774
802
  }),
775
803
  );
776
804
 
777
- const reserveWouldEvictAfterPreflight = reserveWouldEvictAutomaticPin(true);
805
+ const pinEvictedAfterPreflight = automaticPinWouldBeEvicted(true);
778
806
  if (
779
807
  !hasPlanRequirement &&
780
808
  preferredCandidate !== undefined &&
781
809
  !preflightFailures.has(preferredCandidate) &&
782
810
  sessionPreferredIsWarm &&
783
811
  !sessionPinIsExplicit &&
784
- !reserveWouldEvictAfterPreflight
812
+ !pinEvictedAfterPreflight
785
813
  ) {
786
814
  const preferredIndex = candidates.indexOf(preferredCandidate);
787
815
  if (preferredIndex > 0) {
@@ -803,7 +831,7 @@ export class CredentialSelector {
803
831
  // unenforced and the pin is not known-ineligible) so an active session never
804
832
  // silently migrates accounts mid-conversation; blocked, exhausted, or
805
833
  // known-ineligible pins still fall through to the ranked sibling.
806
- if (hasPlanRequirement && sessionPreferredCandidate > 0 && !reserveWouldEvictAfterPreflight) {
834
+ if (hasPlanRequirement && sessionPreferredCandidate > 0 && !pinEvictedAfterPreflight) {
807
835
  const preferred = candidates[sessionPreferredCandidate]!;
808
836
  const planEligibility = planGate?.(preferred.usage);
809
837
  if (planEligibility === true || (!enforcePlanRequirement && planEligibility !== false)) {
package/src/auth/types.ts CHANGED
@@ -214,6 +214,9 @@ export type CompletionProbeCredential =
214
214
  email?: string;
215
215
  enterpriseUrl?: string;
216
216
  apiEndpoint?: string;
217
+ orgId?: string;
218
+ region?: string;
219
+ inferenceRegion?: "global" | "eu" | "us";
217
220
  };
218
221
 
219
222
  /**
@@ -486,6 +489,13 @@ export type AuthApiKeyOptions = {
486
489
  refreshReason?: OAuthRefreshReason;
487
490
  };
488
491
 
492
+ /** Non-secret identity bound to the OAuth credential selected for one request attempt. */
493
+ export interface OAuthRequestIdentity {
494
+ orgId?: string;
495
+ region?: string;
496
+ inferenceRegion?: "global" | "eu" | "us";
497
+ }
498
+
489
499
  /**
490
500
  * Refreshed OAuth access plus identity metadata returned by
491
501
  * {@link AuthStorage.oauth.access}. Callers that authenticate via a bearer
@@ -505,6 +515,8 @@ export interface OAuthAccess {
505
515
  /** Organization/workspace the credential is scoped to (Anthropic/ChatGPT multi-subscription). */
506
516
  orgId?: string;
507
517
  orgName?: string;
518
+ region?: string;
519
+ inferenceRegion?: "global" | "eu" | "us";
508
520
  }
509
521
 
510
522
  /**
@@ -933,10 +945,14 @@ export interface KeysApi {
933
945
  *
934
946
  * Lower priority than {@link setRuntimeApiKey} so a CLI `--api-key`
935
947
  * still wins for the duration of a single invocation.
948
+ *
949
+ * `fallback: true` ranks the value below stored OAuth and `/login`
950
+ * credentials instead, so a provider's default key reference cannot shadow
951
+ * a key the user logged in with.
936
952
  */
937
- setConfig(provider: string, apiKeyConfig: string): void;
953
+ setConfig(provider: string, apiKeyConfig: string, options?: { fallback?: boolean }): void;
938
954
  /**
939
- * Remove a single config-sourced API key override.
955
+ * Remove a single config-sourced API key (override or fallback).
940
956
  */
941
957
  removeConfig(provider: string): void;
942
958
  /**
@@ -65,6 +65,9 @@ export function buildUsageCredential(credential: AuthCredential): UsageCredentia
65
65
  orgName: credential.orgName,
66
66
  enterpriseUrl: credential.enterpriseUrl,
67
67
  apiEndpoint: credential.apiEndpoint,
68
+ region: credential.region,
69
+ inferenceRegion: credential.inferenceRegion,
70
+ activeOrganizationId: credential.activeOrganizationId,
68
71
  };
69
72
  }
70
73
 
@@ -81,6 +84,9 @@ export function usageCacheIdentity(credential: UsageCredential): string {
81
84
  if (projectId) parts.push(`project:${projectId}`);
82
85
  const enterpriseUrl = credential.enterpriseUrl?.trim().toLowerCase();
83
86
  if (enterpriseUrl) parts.push(`enterprise:${enterpriseUrl}`);
87
+ if (credential.region) parts.push(`region:${credential.region}`);
88
+ if (credential.inferenceRegion) parts.push(`inference:${credential.inferenceRegion}`);
89
+ if (credential.activeOrganizationId) parts.push(`selected-org:${credential.activeOrganizationId}`);
84
90
  // Only fall back to a secret-derived key when a stable account identifier is
85
91
  // unavailable. Including the token hash when accountId/email/orgId are present
86
92
  // causes cache misses on every OAuth refresh — usage data is per-account (or
package/src/auth/usage.ts CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  import type { UsageCache, UsageRequestDescriptor } from "./usage-cache";
35
35
  import type { CredentialPool } from "./pool";
36
36
  import type { RankingStrategyResolver } from "../usage/registry";
37
- import { OAUTH_REFRESH_SKEW_MS } from "./refresh";
37
+ import { mergeRefreshedOrganizationScope, OAUTH_REFRESH_SKEW_MS } from "./refresh";
38
38
  import type { OAuthRefresher } from "./refresh";
39
39
  import { USAGE_REPORT_TTL_MS } from "./sqlite-credential-store";
40
40
  import type { AuthCredentialStore } from "./store";
@@ -64,6 +64,9 @@ export function buildRefreshableOauthCredential(credential: UsageCredential): OA
64
64
  orgName: credential.orgName,
65
65
  enterpriseUrl: credential.enterpriseUrl,
66
66
  apiEndpoint: credential.apiEndpoint,
67
+ region: credential.region,
68
+ inferenceRegion: credential.inferenceRegion,
69
+ activeOrganizationId: credential.activeOrganizationId,
67
70
  };
68
71
  }
69
72
 
@@ -82,8 +85,7 @@ export function mergeRefreshedUsageCredential(
82
85
  email: refreshed.email ?? credential.email,
83
86
  enterpriseUrl: refreshed.enterpriseUrl ?? credential.enterpriseUrl,
84
87
  apiEndpoint: refreshed.apiEndpoint ?? credential.apiEndpoint,
85
- orgId: refreshed.orgId ?? credential.orgId,
86
- orgName: refreshed.orgName ?? credential.orgName,
88
+ ...mergeRefreshedOrganizationScope(credential, refreshed),
87
89
  };
88
90
  }
89
91
 
@@ -189,8 +191,11 @@ export class UsageService implements UsageApi {
189
191
  email: next.email ?? entry.credential.email,
190
192
  enterpriseUrl: next.enterpriseUrl ?? entry.credential.enterpriseUrl,
191
193
  apiEndpoint: next.apiEndpoint ?? entry.credential.apiEndpoint,
192
- orgId: next.orgId ?? entry.credential.orgId,
193
- orgName: next.orgName ?? entry.credential.orgName,
194
+ orgId: next.orgId,
195
+ orgName: next.orgName,
196
+ region: next.region,
197
+ inferenceRegion: next.inferenceRegion,
198
+ activeOrganizationId: next.activeOrganizationId,
194
199
  });
195
200
  }
196
201