@oh-my-pi/pi-ai 16.3.11 → 16.3.12

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,25 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.3.12] - 2026-07-08
6
+
7
+ ### Added
8
+
9
+ - Added `AssistantMessage.toolCallAbortMessages` for per-tool placeholder labels on aborted assistant turns ([#2783](https://github.com/can1357/oh-my-pi/issues/2783)).
10
+
11
+ ### Fixed
12
+
13
+ - Fixed Anthropic replay 400s (`tool_use ids were found without tool_result blocks immediately after`) when a persisted assistant turn carries content after a completed tool call — such as a mid-turn `server-side-fallback` handoff (fallback block plus continued text/tool calls after the primary model's `tool_use`) or trailing text from cross-provider replays — by stable-partitioning assistant content so all `tool_use` blocks trail the non-`tool_use` chain. ([#4781](https://github.com/can1357/oh-my-pi/issues/4781), [#544](https://github.com/can1357/oh-my-pi/issues/544))
14
+ - Fixed access-token-only OAuth credentials attempting token refresh with an empty refresh token after expiry.
15
+ - Fixed gateway usage-limit retries falling through to cross-provider model fallback before trying a sibling credential from the same provider.
16
+ - Fixed Codex usage-limit rotation treating Plus and K-12 accounts as separate quota groups for shared 5-hour/7-day windows.
17
+ - Fixed OpenAI Responses streams that end with `response.done` being misclassified as premature stream closures.
18
+ - Fixed OpenCode Go `/login` credentials being shadowed by an existing `OPENCODE_API_KEY` env fallback after switching accounts. ([#4688](https://github.com/can1357/oh-my-pi/issues/4688))
19
+ - Fixed OpenAI Codex WebSocket continuations to treat proxy stale-anchor codes such as `codex_previous_response_stale` as an expired `previous_response_id` chain — same recovery class as the OpenAI-standard `previous_response_not_found` — so the turn is retried with full context instead of surfacing the error to the user ([#4624](https://github.com/can1357/oh-my-pi/issues/4624)).
20
+ - Fixed Azure Foundry Anthropic utility requests to omit the structured-output beta whenever strict tools are disabled, preventing `structured_outputs not supported in your workspace` failures for Sonnet 5 compaction ([#4679](https://github.com/can1357/oh-my-pi/issues/4679)).
21
+ - Fixed OAuth `launchUrl` advertisement for flows whose redirect never returns to the local callback server: custom-scheme redirects (e.g. GitLab Duo's `vscode://` URI, which `new URL` parses without complaint) and fixed non-loopback hosts no longer receive a `http://localhost:<port>/launch` copy target that misrepresents the callback endpoint and resolves nowhere for remote users.
22
+ - Codex load balancing: clear stale persisted and in-memory usage-limit blocks for an `openai-codex` account when a fresh live usage report shows it is allowed and below all limits, including broker-backed gateway snapshots, so traffic returns to recovered accounts instead of funneling to one sibling.
23
+
5
24
  ## [16.3.11] - 2026-07-06
6
25
 
7
26
  ### Fixed
@@ -21,6 +21,8 @@ export interface ApiKeyResolveContext {
21
21
  lastChance: boolean;
22
22
  /** The auth error that triggered this re-resolution, or `undefined` on the initial resolve. */
23
23
  error: unknown;
24
+ /** Bearer used by the failed attempt, when the caller can expose it. */
25
+ previousKey?: string;
24
26
  /** Caller cancel signal, threaded into any credential refresh / rotation work. */
25
27
  signal?: AbortSignal;
26
28
  }
@@ -56,7 +58,7 @@ export { isAuthRetryableError };
56
58
  */
57
59
  export declare const AUTH_RETRY_STEPS: readonly boolean[];
58
60
  /** Resolve a single retry step, swallowing resolver failures into `undefined`. */
59
- export declare function resolveRetryKey(resolver: ApiKeyResolver, lastChance: boolean, error: unknown, signal?: AbortSignal): Promise<string | undefined>;
61
+ export declare function resolveRetryKey(resolver: ApiKeyResolver, lastChance: boolean, error: unknown, signal?: AbortSignal, previousKey?: string): Promise<string | undefined>;
60
62
  /**
61
63
  * Runs an auth-protected operation through the central a/b/c retry policy.
62
64
  *
@@ -16,6 +16,7 @@ import { type CodexResetConsumeCode, type CodexResetCredit } from "./usage/opena
16
16
  export type ApiKeyCredential = {
17
17
  type: "api_key";
18
18
  key: string;
19
+ source?: "login";
19
20
  };
20
21
  export type OAuthCredential = {
21
22
  type: "oauth";
@@ -678,8 +679,8 @@ export declare class AuthStorage {
678
679
  /**
679
680
  * Classify where a provider's auth comes from, following the same precedence
680
681
  * as {@link AuthStorage.getApiKey}: runtime override → config override →
681
- * stored OAuth → env var → stored api_key → fallback resolver. Returns
682
- * undefined when no auth is configured.
682
+ * stored OAuth → login-stored api_key → env var → stored api_key →
683
+ * fallback resolver. Returns undefined when no auth is configured.
683
684
  *
684
685
  * Compact, structured counterpart to {@link describeCredentialSource}.
685
686
  */
@@ -792,6 +793,7 @@ export declare class AuthStorage {
792
793
  retryAfterMs?: number;
793
794
  baseUrl?: string;
794
795
  modelId?: string;
796
+ apiKey?: string;
795
797
  signal?: AbortSignal;
796
798
  }): Promise<UsageLimitMarkResult>;
797
799
  /**
@@ -807,9 +809,10 @@ export declare class AuthStorage {
807
809
  * 1. Runtime override (CLI --api-key)
808
810
  * 2. Config override (models.yml `providers.<name>.apiKey`)
809
811
  * 3. OAuth token from storage (auto-refreshed)
810
- * 4. Environment variable
811
- * 5. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
812
- * 6. Fallback resolver (models.yml custom providers, last-resort)
812
+ * 4. API key persisted by a successful `/login`
813
+ * 5. Environment variable
814
+ * 6. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
815
+ * 7. Fallback resolver (models.yml custom providers, last-resort)
813
816
  */
814
817
  getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined>;
815
818
  /**
@@ -908,6 +911,7 @@ export declare class AuthStorage {
908
911
  rotateSessionCredential(provider: string, sessionId: string | undefined, options?: {
909
912
  error?: unknown;
910
913
  modelId?: string;
914
+ apiKey?: string;
911
915
  signal?: AbortSignal;
912
916
  }): Promise<boolean>;
913
917
  /**
@@ -986,9 +990,10 @@ export declare class AuthStorage {
986
990
  * 1. Runtime override (`--api-key`).
987
991
  * 2. Config override (`models.yml` `providers.<name>.apiKey`).
988
992
  * 3. Stored OAuth credential.
989
- * 4. Env var overrides a stored static api_key (e.g. a stale broker copy).
990
- * 5. Stored api_key credential.
991
- * 6. Fallback resolver.
993
+ * 4. API key persisted by a successful `/login`.
994
+ * 5. Env var — overrides a stored static api_key (e.g. a stale broker copy).
995
+ * 6. Stored api_key credential.
996
+ * 7. Fallback resolver.
992
997
  *
993
998
  * The string is purely informational; consumers must not parse it.
994
999
  */
@@ -157,6 +157,7 @@ export type AnthropicClientOptionsArgs = {
157
157
  hasTools?: boolean;
158
158
  thinkingEnabled?: boolean;
159
159
  thinkingDisplay?: AnthropicThinkingDisplay;
160
+ disableStrictTools?: boolean;
160
161
  fetch?: FetchImpl;
161
162
  claudeCodeSessionId?: string;
162
163
  };
@@ -400,9 +400,10 @@ export interface ProcessResponsesStreamOptions {
400
400
  onFirstToken?: () => void;
401
401
  onOutputItemDone?: (item: ResponseOutputItem) => void;
402
402
  /**
403
- * Called when a terminal `response.completed` or `response.incomplete` event
404
- * is successfully processed. Only invoked on the successful-completion path;
405
- * thrown failure (`response.failed`) and cancellation paths never call this.
403
+ * Called when a terminal `response.completed`, `response.incomplete`, or
404
+ * `response.done` event is successfully processed. Only invoked on the
405
+ * successful-completion path; thrown failure (`response.failed`) and
406
+ * cancellation paths never call this.
406
407
  * Used by callers to detect premature stream closure (i.e. the stream ended
407
408
  * without a recognized terminal event).
408
409
  */
@@ -537,6 +537,8 @@ export interface AssistantMessage {
537
537
  stopReason: StopReason;
538
538
  stopDetails?: StopDetails | null;
539
539
  errorMessage?: string;
540
+ /** Per-tool abort messages used when an aborted assistant turn needs different placeholder results per tool call. */
541
+ toolCallAbortMessages?: Record<string, string>;
540
542
  /** HTTP status surfaced by the provider when the request failed. Populated by every provider's catch block alongside `errorMessage` so consumers (auth retry, telemetry, UI) can branch without regex-scraping the message. */
541
543
  errorStatus?: number;
542
544
  /** Structured machine-readable error classifier; see `utils/error-id.ts` for bit layout and helpers. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "16.3.11",
4
+ "version": "16.3.12",
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.0",
41
- "@oh-my-pi/pi-catalog": "16.3.11",
42
- "@oh-my-pi/pi-utils": "16.3.11",
43
- "@oh-my-pi/pi-wire": "16.3.11",
41
+ "@oh-my-pi/pi-catalog": "16.3.12",
42
+ "@oh-my-pi/pi-utils": "16.3.12",
43
+ "@oh-my-pi/pi-wire": "16.3.12",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -61,6 +61,41 @@ function toCredentialBlockSnapshot(block: StoredCredentialBlock): CredentialBloc
61
61
  };
62
62
  }
63
63
 
64
+ function credentialBlockSnapshotsEqual(
65
+ left: readonly CredentialBlockSnapshot[] | undefined,
66
+ right: readonly CredentialBlockSnapshot[] | undefined,
67
+ ): boolean {
68
+ const leftBlocks = left ?? [];
69
+ const rightBlocks = right ?? [];
70
+ if (leftBlocks.length !== rightBlocks.length) return false;
71
+ for (let index = 0; index < leftBlocks.length; index += 1) {
72
+ const leftBlock = leftBlocks[index]!;
73
+ const rightBlock = rightBlocks[index]!;
74
+ if (
75
+ leftBlock.providerKey !== rightBlock.providerKey ||
76
+ leftBlock.blockScope !== rightBlock.blockScope ||
77
+ leftBlock.blockedUntilMs !== rightBlock.blockedUntilMs
78
+ ) {
79
+ return false;
80
+ }
81
+ }
82
+ return true;
83
+ }
84
+
85
+ function snapshotBlocksChanged(previous: readonly SnapshotEntry[], next: readonly SnapshotEntry[]): boolean {
86
+ const previousBlocksById = new Map<number, readonly CredentialBlockSnapshot[] | undefined>();
87
+ for (const entry of previous) previousBlocksById.set(entry.id, entry.blocks);
88
+ for (const entry of next) {
89
+ const previousBlocks = previousBlocksById.get(entry.id);
90
+ if (!credentialBlockSnapshotsEqual(previousBlocks, entry.blocks)) return true;
91
+ previousBlocksById.delete(entry.id);
92
+ }
93
+ for (const previousBlocks of previousBlocksById.values()) {
94
+ if (previousBlocks && previousBlocks.length > 0) return true;
95
+ }
96
+ return false;
97
+ }
98
+
64
99
  function credentialEntryWithBlocks(
65
100
  entry: AuthCredentialSnapshotEntry,
66
101
  blocks: readonly CredentialBlockSnapshot[] | undefined,
@@ -173,6 +208,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
173
208
  #cache: Map<string, CacheEntry> = new Map();
174
209
  #usageCache?: UsageCacheEntry;
175
210
  #usageInflight?: Promise<UsageReport[] | null>;
211
+ #usageCacheEpoch = 0;
176
212
  #closed = false;
177
213
  /**
178
214
  * `true` once the SSE consumer received its first frame and hasn't dropped
@@ -202,10 +238,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
202
238
 
203
239
  #applySnapshot(snapshot: SnapshotResponse, generation: number): void {
204
240
  const nowMs = Date.now();
205
- this.#snapshot = {
206
- ...snapshot,
207
- credentials: snapshot.credentials.map(entry => this.#normalizeSnapshotEntryBlocks(entry, nowMs)),
208
- };
241
+ const credentials = snapshot.credentials.map(entry => this.#normalizeSnapshotEntryBlocks(entry, nowMs));
242
+ if (snapshotBlocksChanged(this.#snapshot.credentials, credentials)) this.#invalidateUsageCache();
243
+ this.#snapshot = { ...snapshot, credentials };
209
244
  this.#generation = generation;
210
245
  this.#snapshotReceivedAt = nowMs;
211
246
  const onSnapshot = this.#onSnapshot;
@@ -304,6 +339,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
304
339
  ): void {
305
340
  const incoming = this.#normalizeSnapshotEntryBlocks(entry, Date.now());
306
341
  const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === incoming.id);
342
+ const previousBlocks = index === -1 ? undefined : this.#snapshot.credentials[index]?.blocks;
343
+ if (!credentialBlockSnapshotsEqual(previousBlocks, incoming.blocks)) this.#invalidateUsageCache();
307
344
  const credentials =
308
345
  index === -1
309
346
  ? [...this.#snapshot.credentials, incoming]
@@ -314,6 +351,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
314
351
  }
315
352
 
316
353
  #removeStreamCredential(id: number, refresher: RefresherSchedule, generation: number, serverNowMs: number): void {
354
+ const removed = this.#snapshot.credentials.find(entry => entry.id === id);
355
+ if (removed?.blocks && removed.blocks.length > 0) this.#invalidateUsageCache();
317
356
  const credentials = this.#snapshot.credentials.filter(entry => entry.id !== id);
318
357
  this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
319
358
  this.#generation = generation;
@@ -376,6 +415,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
376
415
 
377
416
  upsertCredentialBlock(block: StoredCredentialBlock): void {
378
417
  this.#upsertSnapshotBlock(block);
418
+ this.#invalidateUsageCache();
379
419
  const body = toCredentialBlockSnapshot(block);
380
420
  void this.#client
381
421
  .upsertCredentialBlock(block.credentialId, body)
@@ -394,6 +434,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
394
434
 
395
435
  deleteCredentialBlocks(credentialId: number): void {
396
436
  this.#deleteSnapshotBlocks(credentialId);
437
+ this.#invalidateUsageCache();
397
438
  void this.#client
398
439
  .deleteCredentialBlocks(credentialId)
399
440
  .then(() => {
@@ -698,6 +739,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
698
739
  }
699
740
  }
700
741
 
742
+ #invalidateUsageCache(): void {
743
+ this.#usageCache = undefined;
744
+ this.#usageInflight = undefined;
745
+ this.#usageCacheEpoch += 1;
746
+ }
747
+
701
748
  /**
702
749
  * Store-level hook consumed by `AuthStorage` — routes refresh through the
703
750
  * broker so the actual refresh token never leaves the broker host. Returns
@@ -834,9 +881,11 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
834
881
  return Promise.resolve(cached.reports);
835
882
  }
836
883
  if (this.#usageInflight) return this.#usageInflight;
884
+ const epoch = this.#usageCacheEpoch;
837
885
  const inflight = this.#client
838
886
  .fetchUsage()
839
887
  .then(body => {
888
+ if (epoch !== this.#usageCacheEpoch) return this.#loadUsageReports();
840
889
  this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
841
890
  return body.reports;
842
891
  })
@@ -845,11 +894,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
845
894
  // Documented 15s TTL fallback: cache the null so sequential callers
846
895
  // don't re-hit the broker while it's still down. See
847
896
  // docs/auth-broker-gateway.md § "Client-side single-flight".
897
+ if (epoch !== this.#usageCacheEpoch) return this.#loadUsageReports();
848
898
  this.#usageCache = { reports: null, fetchedAt: Date.now() };
849
899
  return null;
850
900
  })
851
901
  .finally(() => {
852
- this.#usageInflight = undefined;
902
+ if (this.#usageInflight === inflight) this.#usageInflight = undefined;
853
903
  });
854
904
  this.#usageInflight = inflight;
855
905
  return inflight;
@@ -233,6 +233,7 @@ async function refreshGatewayApiKeyAfterAuthError(
233
233
  retryAfterMs,
234
234
  baseUrl: model.baseUrl,
235
235
  modelId: model.id,
236
+ apiKey: oldKey,
236
237
  signal,
237
238
  });
238
239
  logger.debug("auth-gateway retrying provider request after usage-limit block", {
package/src/auth-retry.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { OAuthAccess } from "./auth-storage";
2
2
  import * as AIError from "./error";
3
3
  import { isAuthRetryableError } from "./error/auth-classify";
4
+ import { isUsageLimit } from "./error/flags";
4
5
 
5
6
  /**
6
7
  * Context passed to an {@link ApiKeyResolver} on each resolution attempt.
@@ -23,6 +24,8 @@ export interface ApiKeyResolveContext {
23
24
  lastChance: boolean;
24
25
  /** The auth error that triggered this re-resolution, or `undefined` on the initial resolve. */
25
26
  error: unknown;
27
+ /** Bearer used by the failed attempt, when the caller can expose it. */
28
+ previousKey?: string;
26
29
  /** Caller cancel signal, threaded into any credential refresh / rotation work. */
27
30
  signal?: AbortSignal;
28
31
  }
@@ -87,9 +90,11 @@ export async function resolveRetryKey(
87
90
  lastChance: boolean,
88
91
  error: unknown,
89
92
  signal?: AbortSignal,
93
+ previousKey?: string,
90
94
  ): Promise<string | undefined> {
91
95
  try {
92
- return (await resolver({ lastChance, error, signal })) || undefined;
96
+ const rotateSibling = lastChance || (!lastChance && isUsageLimit(error));
97
+ return (await resolver({ lastChance: rotateSibling, error, signal, previousKey })) || undefined;
93
98
  } catch {
94
99
  return undefined;
95
100
  }
@@ -136,7 +141,7 @@ export async function withAuth<T>(
136
141
  }
137
142
 
138
143
  for (let i = 0; i < AUTH_RETRY_STEPS.length; i++) {
139
- const nextKey = await resolveRetryKey(resolver, AUTH_RETRY_STEPS[i]!, lastError, signal);
144
+ const nextKey = await resolveRetryKey(resolver, AUTH_RETRY_STEPS[i]!, lastError, signal, lastKey);
140
145
  if (nextKey === undefined || nextKey === lastKey) continue;
141
146
  lastKey = nextKey;
142
147
  try {
@@ -66,6 +66,7 @@ const USAGE_RANKING_METRIC_EPSILON = 1e-9;
66
66
  export type ApiKeyCredential = {
67
67
  type: "api_key";
68
68
  key: string;
69
+ source?: "login";
69
70
  };
70
71
 
71
72
  export type OAuthCredential = {
@@ -967,6 +968,7 @@ export class AuthStorage {
967
968
  #usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
968
969
  #rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
969
970
  #usageCache: UsageCache;
971
+ #usageCacheEpoch = 0;
970
972
  #usageRequestInFlight: Map<string, Promise<UsageReport | null>> = new Map();
971
973
  #usageHeaderIngestAt: Map<string, number> = new Map();
972
974
  #usageReportsInFlight: Map<string, Promise<UsageReport[] | null>> = new Map();
@@ -1390,12 +1392,14 @@ export class AuthStorage {
1390
1392
 
1391
1393
  const credentialId = this.#getStoredCredentials(provider)[credentialIndex]?.id;
1392
1394
  if (credentialId === undefined) return blockedUntil;
1393
- const persistedGlobalBlockedUntil = this.#readPersistedCredentialBlock(credentialId, providerKey, "");
1394
- if (
1395
- persistedGlobalBlockedUntil !== undefined &&
1396
- (blockedUntil === undefined || persistedGlobalBlockedUntil > blockedUntil)
1397
- ) {
1398
- blockedUntil = persistedGlobalBlockedUntil;
1395
+ if (!blockScope || provider !== "openai-codex") {
1396
+ const persistedGlobalBlockedUntil = this.#readPersistedCredentialBlock(credentialId, providerKey, "");
1397
+ if (
1398
+ persistedGlobalBlockedUntil !== undefined &&
1399
+ (blockedUntil === undefined || persistedGlobalBlockedUntil > blockedUntil)
1400
+ ) {
1401
+ blockedUntil = persistedGlobalBlockedUntil;
1402
+ }
1399
1403
  }
1400
1404
  if (blockScope) {
1401
1405
  const persistedScopedBlockedUntil = this.#readPersistedCredentialBlock(credentialId, providerKey, blockScope);
@@ -1433,6 +1437,7 @@ export class AuthStorage {
1433
1437
  const nextBlockedUntil = Math.max(existing, blockedUntilMs);
1434
1438
  backoffMap.set(credentialIndex, nextBlockedUntil);
1435
1439
  this.#credentialBackoff.set(backoffKey, backoffMap);
1440
+ this.#invalidateUsageReportCache(provider);
1436
1441
 
1437
1442
  const upsertCredentialBlock = this.#store.upsertCredentialBlock?.bind(this.#store);
1438
1443
  if (!upsertCredentialBlock) return;
@@ -1554,13 +1559,14 @@ export class AuthStorage {
1554
1559
  provider: string,
1555
1560
  type: T,
1556
1561
  sessionId?: string,
1562
+ filter?: (credential: AuthCredential) => boolean,
1557
1563
  ): { credential: Extract<AuthCredential, { type: T }>; index: number } | undefined {
1558
1564
  const credentials = this.#getCredentialsForProvider(provider)
1559
1565
  .map((credential, index) => ({ credential, index }))
1560
- .filter(
1561
- (entry): entry is { credential: Extract<AuthCredential, { type: T }>; index: number } =>
1562
- entry.credential.type === type,
1563
- );
1566
+ .filter((entry): entry is { credential: Extract<AuthCredential, { type: T }>; index: number } => {
1567
+ if (entry.credential.type !== type) return false;
1568
+ return filter?.(entry.credential) ?? true;
1569
+ });
1564
1570
 
1565
1571
  if (credentials.length === 0) return undefined;
1566
1572
  if (credentials.length === 1) return credentials[0];
@@ -1851,8 +1857,8 @@ export class AuthStorage {
1851
1857
  /**
1852
1858
  * Classify where a provider's auth comes from, following the same precedence
1853
1859
  * as {@link AuthStorage.getApiKey}: runtime override → config override →
1854
- * stored OAuth → env var → stored api_key → fallback resolver. Returns
1855
- * undefined when no auth is configured.
1860
+ * stored OAuth → login-stored api_key → env var → stored api_key →
1861
+ * fallback resolver. Returns undefined when no auth is configured.
1856
1862
  *
1857
1863
  * Compact, structured counterpart to {@link describeCredentialSource}.
1858
1864
  */
@@ -1861,6 +1867,9 @@ export class AuthStorage {
1861
1867
  if (this.#configOverrides.has(provider)) return { kind: "config" };
1862
1868
  const stored = this.#getCredentialsForProvider(provider);
1863
1869
  if (stored.some(credential => credential.type === "oauth")) return { kind: "oauth" };
1870
+ if (stored.some(credential => credential.type === "api_key" && credential.source === "login")) {
1871
+ return { kind: "api_key" };
1872
+ }
1864
1873
  if (getEnvApiKey(provider)) return { kind: "env", envVar: getEnvApiKeyName(provider) };
1865
1874
  if (stored.some(credential => credential.type === "api_key")) return { kind: "api_key" };
1866
1875
  if (this.#fallbackResolver?.(provider)) return { kind: "fallback" };
@@ -2005,7 +2014,7 @@ export class AuthStorage {
2005
2014
  if (!result) {
2006
2015
  return;
2007
2016
  }
2008
- const newCredential: ApiKeyCredential = { type: "api_key", key: result };
2017
+ const newCredential: ApiKeyCredential = { type: "api_key", key: result, source: "login" };
2009
2018
  const stored = this.#store.upsertAuthCredentialRemote
2010
2019
  ? await this.#store.upsertAuthCredentialRemote(provider, newCredential)
2011
2020
  : this.#store.upsertAuthCredentialForProvider(provider, newCredential);
@@ -2336,8 +2345,10 @@ export class AuthStorage {
2336
2345
  const inFlight = this.#usageRequestInFlight.get(cacheKey);
2337
2346
  if (inFlight) return inFlight;
2338
2347
 
2348
+ const usageCacheEpoch = this.#usageCacheEpoch;
2339
2349
  const promise = (async () => {
2340
2350
  const report = await this.#fetchUsageUncached(request, timeoutMs);
2351
+ if (usageCacheEpoch !== this.#usageCacheEpoch) return report;
2341
2352
  const ttlJitter = USAGE_REPORT_TTL_MS * (Math.random() * 0.5 - 0.25);
2342
2353
  if (report !== null) {
2343
2354
  // Success: stagger per-credential cache expiry so all accounts don't
@@ -2347,6 +2358,7 @@ export class AuthStorage {
2347
2358
  // times decorrelate within a few cycles.
2348
2359
  this.#usageCache.set(cacheKey, { value: report, expiresAt: Date.now() + USAGE_REPORT_TTL_MS + ttlJitter });
2349
2360
  this.#recordUsageHistory(request, report);
2361
+ this.#reconcileCodexUsageBlock(request, report);
2350
2362
  return report;
2351
2363
  }
2352
2364
  // Failure: apply a short jittered cool-down so the credential doesn't
@@ -2761,7 +2773,14 @@ export class AuthStorage {
2761
2773
  // whole point of routing through it.
2762
2774
  const storeHook = this.#store.getUsageReport?.bind(this.#store);
2763
2775
  if (storeHook) {
2764
- return storeHook(provider, credential, options?.signal);
2776
+ const report = await storeHook(provider, credential, options?.signal);
2777
+ if (report) {
2778
+ this.#reconcileCodexUsageBlock(
2779
+ this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
2780
+ report,
2781
+ );
2782
+ }
2783
+ return report;
2765
2784
  }
2766
2785
  return this.#fetchUsageCached(
2767
2786
  this.#buildUsageRequestForOauth(provider, credential, options?.baseUrl),
@@ -2789,7 +2808,10 @@ export class AuthStorage {
2789
2808
  // `RemoteAuthCredentialStore` implements the store hook so a gateway
2790
2809
  // backed by a broker automatically routes usage to the broker without
2791
2810
  // needing the caller to wire it explicitly.
2792
- const override = this.#fetchUsageReportsOverride ?? this.#store.fetchUsageReports?.bind(this.#store);
2811
+ const storeOverride = this.#store.fetchUsageReports?.bind(this.#store);
2812
+ const override = this.#fetchUsageReportsOverride ?? storeOverride;
2813
+ const shouldReconcileStoreHookReports =
2814
+ this.#fetchUsageReportsOverride === undefined && storeOverride !== undefined;
2793
2815
  if (override) {
2794
2816
  // Reuse the in-flight map so concurrent callers (widget poll + format
2795
2817
  // dispatch + credential selection) coalesce into one upstream call.
@@ -2805,7 +2827,9 @@ export class AuthStorage {
2805
2827
  });
2806
2828
  this.#usageReportsInFlight.set(OVERRIDE_KEY, shared);
2807
2829
  }
2808
- return raceUsageWithSignal(shared, options?.signal);
2830
+ const reports = await raceUsageWithSignal(shared, options?.signal);
2831
+ if (shouldReconcileStoreHookReports && reports) this.#reconcileCodexUsageBlocksFromReports(reports);
2832
+ return reports;
2809
2833
  }
2810
2834
  if (!this.#usageProviderResolver) return null;
2811
2835
 
@@ -3057,9 +3081,20 @@ export class AuthStorage {
3057
3081
  async markUsageLimitReached(
3058
3082
  provider: string,
3059
3083
  sessionId: string | undefined,
3060
- options?: { retryAfterMs?: number; baseUrl?: string; modelId?: string; signal?: AbortSignal },
3084
+ options?: { retryAfterMs?: number; baseUrl?: string; modelId?: string; apiKey?: string; signal?: AbortSignal },
3061
3085
  ): Promise<UsageLimitMarkResult> {
3062
- const sessionCredential = this.#getSessionCredential(provider, sessionId);
3086
+ let sessionCredential: { type: AuthCredential["type"]; index: number } | undefined;
3087
+ if (options?.apiKey) {
3088
+ const stored = this.#getStoredCredentials(provider);
3089
+ for (let index = 0; index < stored.length; index++) {
3090
+ const entry = stored[index];
3091
+ if (entry && (await this.#credentialMatchesApiKey(entry.credential, options.apiKey))) {
3092
+ sessionCredential = { type: entry.credential.type, index };
3093
+ break;
3094
+ }
3095
+ }
3096
+ }
3097
+ sessionCredential ??= this.#getSessionCredential(provider, sessionId);
3063
3098
  if (!sessionCredential) return { switched: false };
3064
3099
 
3065
3100
  const providerKey = this.#getProviderTypeKey(provider, sessionCredential.type);
@@ -3387,12 +3422,21 @@ export class AuthStorage {
3387
3422
  const checkUsage = strategy !== undefined && (credentials.length > 1 || requiresProModel);
3388
3423
  const sessionCredential = this.#getSessionCredential(provider, sessionId);
3389
3424
  const sessionPreferredIndex = sessionCredential?.type === "oauth" ? sessionCredential.index : undefined;
3425
+ const sessionPreferredCredential =
3426
+ sessionPreferredIndex !== undefined
3427
+ ? credentials.find(entry => entry.index === sessionPreferredIndex)?.credential
3428
+ : undefined;
3429
+ const sessionPreferredCanRefreshOrUse =
3430
+ sessionPreferredCredential !== undefined &&
3431
+ (sessionPreferredCredential.refresh.trim().length > 0 ||
3432
+ Date.now() + OAUTH_REFRESH_SKEW_MS < sessionPreferredCredential.expires);
3390
3433
  // Skip ranking only when the session already has a working preferred credential — re-ranking
3391
3434
  // mid-session causes account switches that cold-start the server-side prompt cache. New sessions
3392
3435
  // (no preference) and sessions whose preferred is blocked still rank, so we pick the account
3393
3436
  // with the most headroom proactively and fall back intelligently when rate-limited.
3394
3437
  const sessionPreferredIsAvailable =
3395
3438
  sessionPreferredIndex !== undefined &&
3439
+ sessionPreferredCanRefreshOrUse &&
3396
3440
  !this.#isCredentialBlocked(provider, providerKey, sessionPreferredIndex, blockScope);
3397
3441
  const shouldRank = checkUsage && (!sessionPreferredIsAvailable || requiresProModel);
3398
3442
  const rankingOrder = shouldRank && sessionId ? credentials.map((_credential, index) => index) : order;
@@ -3899,8 +3943,8 @@ export class AuthStorage {
3899
3943
  return configKey;
3900
3944
  }
3901
3945
 
3902
- // Precedence: a deliberate OAuth login wins, then an explicit env var, then a stored
3903
- // static api_key (which may be a stale broker-migrated copy) as a last resort.
3946
+ // Precedence: a deliberate OAuth/login credential wins, then an explicit env var,
3947
+ // then a stored static api_key (which may be a stale broker-migrated copy) as a last resort.
3904
3948
  const oauthSelection = this.#selectCredentialByType(provider, "oauth");
3905
3949
  if (oauthSelection) {
3906
3950
  const expiresAt = oauthSelection.credential.expires;
@@ -3916,6 +3960,16 @@ export class AuthStorage {
3916
3960
  }
3917
3961
  }
3918
3962
 
3963
+ const loginApiKeySelection = this.#selectCredentialByType(
3964
+ provider,
3965
+ "api_key",
3966
+ undefined,
3967
+ credential => credential.type === "api_key" && credential.source === "login",
3968
+ );
3969
+ if (loginApiKeySelection) {
3970
+ return this.#configValueResolver(loginApiKeySelection.credential.key);
3971
+ }
3972
+
3919
3973
  const envKey = getEnvApiKey(provider);
3920
3974
  if (envKey) return envKey;
3921
3975
 
@@ -3933,9 +3987,10 @@ export class AuthStorage {
3933
3987
  * 1. Runtime override (CLI --api-key)
3934
3988
  * 2. Config override (models.yml `providers.<name>.apiKey`)
3935
3989
  * 3. OAuth token from storage (auto-refreshed)
3936
- * 4. Environment variable
3937
- * 5. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
3938
- * 6. Fallback resolver (models.yml custom providers, last-resort)
3990
+ * 4. API key persisted by a successful `/login`
3991
+ * 5. Environment variable
3992
+ * 6. Stored API key (e.g. a broker-migrated copy) — last resort, so an explicit env var wins
3993
+ * 7. Fallback resolver (models.yml custom providers, last-resort)
3939
3994
  */
3940
3995
  async getApiKey(provider: string, sessionId?: string, options?: AuthApiKeyOptions): Promise<string | undefined> {
3941
3996
  // Runtime override takes highest priority
@@ -3954,13 +4009,24 @@ export class AuthStorage {
3954
4009
  return configKey;
3955
4010
  }
3956
4011
 
3957
- // Precedence: a deliberate OAuth login wins, then an explicit env var, then a stored
3958
- // static api_key (which may be a stale broker-migrated copy) as a last resort.
4012
+ // Precedence: a deliberate OAuth/login credential wins, then an explicit env var,
4013
+ // then a stored static api_key (which may be a stale broker-migrated copy) as a last resort.
3959
4014
  const oauthResolved = await this.#resolveOAuthSelection(provider, sessionId, options);
3960
4015
  if (oauthResolved) {
3961
4016
  return oauthResolved.apiKey;
3962
4017
  }
3963
4018
 
4019
+ const loginApiKeySelection = this.#selectCredentialByType(
4020
+ provider,
4021
+ "api_key",
4022
+ sessionId,
4023
+ credential => credential.type === "api_key" && credential.source === "login",
4024
+ );
4025
+ if (loginApiKeySelection) {
4026
+ this.#recordSessionCredential(provider, sessionId, "api_key", loginApiKeySelection.index);
4027
+ return this.#configValueResolver(loginApiKeySelection.credential.key);
4028
+ }
4029
+
3964
4030
  // Past OAuth: the session sticky (if any) is stale — the request authenticates via
3965
4031
  // env/api_key/fallback, not OAuth, so clear it now so getOAuthAccountId() correctly
3966
4032
  // suppresses account_uuid for this session.
@@ -3969,7 +4035,12 @@ export class AuthStorage {
3969
4035
  const envKey = getEnvApiKey(provider);
3970
4036
  if (envKey) return envKey;
3971
4037
 
3972
- const apiKeySelection = this.#selectCredentialByType(provider, "api_key", sessionId);
4038
+ const apiKeySelection = this.#selectCredentialByType(
4039
+ provider,
4040
+ "api_key",
4041
+ sessionId,
4042
+ credential => credential.type !== "api_key" || credential.source !== "login",
4043
+ );
3973
4044
  if (apiKeySelection) {
3974
4045
  this.#recordSessionCredential(provider, sessionId, "api_key", apiKeySelection.index);
3975
4046
  return this.#configValueResolver(apiKeySelection.credential.key);
@@ -4255,6 +4326,7 @@ export class AuthStorage {
4255
4326
  * `/usage` reflects a freshly-redeemed reset instead of stale numbers.
4256
4327
  */
4257
4328
  #invalidateUsageReportCache(provider: string, baseUrl?: string): void {
4329
+ this.#usageCacheEpoch += 1;
4258
4330
  const expired = Date.now() - 1;
4259
4331
  for (const entry of this.#getStoredCredentials(provider)) {
4260
4332
  if (entry.credential.type !== "oauth") continue;
@@ -4266,6 +4338,12 @@ export class AuthStorage {
4266
4338
  }
4267
4339
  }
4268
4340
 
4341
+ #invalidateUsageReportCacheForProviderKey(providerKey: string): void {
4342
+ const oauthSuffix = ":oauth";
4343
+ if (!providerKey.endsWith(oauthSuffix)) return;
4344
+ this.#invalidateUsageReportCache(providerKey.slice(0, -oauthSuffix.length));
4345
+ }
4346
+
4269
4347
  /**
4270
4348
  * Lift any temporary backoff blocks on one credential (across the bare
4271
4349
  * `provider:oauth` key and its scoped `\0`-suffixed derivatives). Called
@@ -4274,13 +4352,10 @@ export class AuthStorage {
4274
4352
  * that `markUsageLimitReached` set for the now-obsolete reset time.
4275
4353
  */
4276
4354
  #clearCredentialBlocks(provider: string, credentialId: number): void {
4277
- const deleteCredentialBlocks = this.#store.deleteCredentialBlocks?.bind(this.#store);
4278
- if (deleteCredentialBlocks) {
4279
- try {
4280
- deleteCredentialBlocks(credentialId);
4281
- } catch (err) {
4282
- logger.debug("Failed to clear persisted credential blocks", { err, provider, credentialId });
4283
- }
4355
+ try {
4356
+ this.deleteCredentialBlocks(credentialId);
4357
+ } catch (err) {
4358
+ logger.debug("Failed to clear persisted credential blocks", { err, provider, credentialId });
4284
4359
  }
4285
4360
 
4286
4361
  const index = this.#getStoredCredentials(provider).findIndex(entry => entry.id === credentialId);
@@ -4294,6 +4369,80 @@ export class AuthStorage {
4294
4369
  }
4295
4370
  }
4296
4371
 
4372
+ /**
4373
+ * Self-heal a stale Codex usage-limit block: when a fresh live usage report
4374
+ * shows the account is allowed and below every limit, drop the persisted and
4375
+ * in-memory `openai-codex:oauth` blocks so the balancer re-includes it. Only
4376
+ * Codex — its ranking strategy uses the single model-independent `"shared"`
4377
+ * scope, so clearing every block for the credential id is exact.
4378
+ */
4379
+ #isHealthyCodexUsageReport(report: UsageReport): boolean {
4380
+ const metadata = report.metadata;
4381
+ return (
4382
+ report.provider === "openai-codex" &&
4383
+ metadata?.allowed === true &&
4384
+ metadata.limitReached === false &&
4385
+ !this.#isUsageLimitReached(report.limits)
4386
+ );
4387
+ }
4388
+
4389
+ #reconcileCodexUsageBlockForCredential(provider: Provider, credentialId: number, report: UsageReport): void {
4390
+ if (!this.#isHealthyCodexUsageReport(report)) return;
4391
+ const providerKey = this.#getProviderTypeKey(provider, "oauth");
4392
+ const credentialIndex = this.#getStoredCredentials(provider).findIndex(entry => entry.id === credentialId);
4393
+ if (credentialIndex < 0) return;
4394
+ // Mirror selection: consult the same strategy scope `markUsageLimitReached`
4395
+ // persists under, else a scoped block is invisible here and never healed.
4396
+ const blockScope = this.#rankingStrategyResolver?.(provider)?.blockScope?.({});
4397
+ const blockedUntilMs = this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex, blockScope);
4398
+ if (blockedUntilMs === undefined) return;
4399
+ this.#clearCredentialBlocks(provider, credentialId);
4400
+ logger.info("Cleared stale Codex usage-limit block after healthy live usage report", {
4401
+ credentialId,
4402
+ provider,
4403
+ clearedBlockedUntilMs: blockedUntilMs,
4404
+ });
4405
+ }
4406
+
4407
+ #reconcileCodexUsageBlock(request: UsageRequestDescriptor, report: UsageReport): void {
4408
+ if (request.provider !== "openai-codex") return;
4409
+ const credentialId = this.#findStoredCredentialIdForUsageCredential(request.provider, request.credential);
4410
+ if (credentialId === undefined) return;
4411
+ this.#reconcileCodexUsageBlockForCredential(request.provider, credentialId, report);
4412
+ }
4413
+
4414
+ #findStoredCredentialIdsForUsageReport(report: UsageReport): number[] {
4415
+ if (report.provider !== "openai-codex") return [];
4416
+ const email = this.#getUsageReportMetadataValue(report, "email")?.toLowerCase();
4417
+ const accountId = (
4418
+ this.#getUsageReportMetadataValue(report, "accountId") ?? this.#getUsageReportScopeAccountId(report)
4419
+ )?.toLowerCase();
4420
+ if (!email && !accountId) return [];
4421
+ const matches: number[] = [];
4422
+ for (const entry of this.#getStoredCredentials(report.provider)) {
4423
+ const credential = entry.credential;
4424
+ if (credential.type !== "oauth") continue;
4425
+ const credentialEmail = credential.email?.trim().toLowerCase();
4426
+ const credentialAccountId = credential.accountId?.trim().toLowerCase();
4427
+ if ((email && credentialEmail === email) || (accountId && credentialAccountId === accountId)) {
4428
+ matches.push(entry.id);
4429
+ }
4430
+ }
4431
+ return matches;
4432
+ }
4433
+
4434
+ #reconcileCodexUsageBlocksFromReports(reports: UsageReport[]): void {
4435
+ const reconciled = new Set<number>();
4436
+ for (const report of reports) {
4437
+ if (!this.#isHealthyCodexUsageReport(report)) continue;
4438
+ for (const credentialId of this.#findStoredCredentialIdsForUsageReport(report)) {
4439
+ if (reconciled.has(credentialId)) continue;
4440
+ reconciled.add(credentialId);
4441
+ this.#reconcileCodexUsageBlockForCredential(report.provider, credentialId, report);
4442
+ }
4443
+ }
4444
+ }
4445
+
4297
4446
  #extractStructuredApiKeyToken(apiKey: string): string | undefined {
4298
4447
  if (!apiKey.startsWith("{")) return undefined;
4299
4448
  try {
@@ -4381,7 +4530,7 @@ export class AuthStorage {
4381
4530
  async rotateSessionCredential(
4382
4531
  provider: string,
4383
4532
  sessionId: string | undefined,
4384
- options?: { error?: unknown; modelId?: string; signal?: AbortSignal },
4533
+ options?: { error?: unknown; modelId?: string; apiKey?: string; signal?: AbortSignal },
4385
4534
  ): Promise<boolean> {
4386
4535
  const sessionCredential = this.#getSessionCredential(provider, sessionId);
4387
4536
  if (!sessionCredential) return false;
@@ -4393,6 +4542,7 @@ export class AuthStorage {
4393
4542
  return (
4394
4543
  await this.markUsageLimitReached(provider, sessionId, {
4395
4544
  modelId: options?.modelId,
4545
+ apiKey: options?.apiKey,
4396
4546
  signal: options?.signal,
4397
4547
  })
4398
4548
  ).switched;
@@ -4654,6 +4804,7 @@ export class AuthStorage {
4654
4804
  const upsertCredentialBlock = this.#store.upsertCredentialBlock?.bind(this.#store);
4655
4805
  if (!upsertCredentialBlock) return;
4656
4806
  upsertCredentialBlock(block);
4807
+ this.#invalidateUsageReportCacheForProviderKey(block.providerKey);
4657
4808
  this.#bumpGeneration("credential-block");
4658
4809
  }
4659
4810
 
@@ -4674,9 +4825,10 @@ export class AuthStorage {
4674
4825
  * 1. Runtime override (`--api-key`).
4675
4826
  * 2. Config override (`models.yml` `providers.<name>.apiKey`).
4676
4827
  * 3. Stored OAuth credential.
4677
- * 4. Env var overrides a stored static api_key (e.g. a stale broker copy).
4678
- * 5. Stored api_key credential.
4679
- * 6. Fallback resolver.
4828
+ * 4. API key persisted by a successful `/login`.
4829
+ * 5. Env var — overrides a stored static api_key (e.g. a stale broker copy).
4830
+ * 6. Stored api_key credential.
4831
+ * 7. Fallback resolver.
4680
4832
  *
4681
4833
  * The string is purely informational; consumers must not parse it.
4682
4834
  */
@@ -4691,14 +4843,16 @@ export class AuthStorage {
4691
4843
  const baseLabel = this.#sourceLabel ?? "local store";
4692
4844
  const stored = this.#getStoredCredentials(provider);
4693
4845
  const session = sessionId ? this.#sessionLastCredential.get(provider)?.get(sessionId) : undefined;
4694
- // Describe the stored credential of a given type, honoring the session sticky index.
4695
- const describeStored = (type: AuthCredential["type"]): string | undefined => {
4846
+ const describeStored = (
4847
+ type: AuthCredential["type"],
4848
+ filter?: (credential: AuthCredential) => boolean,
4849
+ ): string | undefined => {
4696
4850
  const typed = stored
4697
4851
  .map((entry, index) => ({ entry, index }))
4698
- .filter(({ entry }) => entry.credential.type === type);
4852
+ .filter(({ entry }) => entry.credential.type === type && (filter?.(entry.credential) ?? true));
4699
4853
  if (typed.length === 0) return undefined;
4700
- const index = session?.type === type ? session.index : typed[0].index;
4701
- const chosen = stored[index] ?? typed[0].entry;
4854
+ const sticky = session?.type === type ? typed.find(entry => entry.index === session.index) : undefined;
4855
+ const chosen = sticky?.entry ?? typed[0].entry;
4702
4856
  const credential = chosen.credential;
4703
4857
  const identity =
4704
4858
  credential.type === "oauth"
@@ -4707,11 +4861,19 @@ export class AuthStorage {
4707
4861
  return `${baseLabel} · ${type} #${chosen.id} (${identity})`;
4708
4862
  };
4709
4863
 
4710
- // A deliberate OAuth login wins; then an explicit env var; then a stored static api_key.
4864
+ // Deliberate login credentials win; then an explicit env var; then a stored static api_key.
4711
4865
  const oauthSource = describeStored("oauth");
4712
4866
  if (oauthSource) return oauthSource;
4867
+ const loginApiKeySource = describeStored(
4868
+ "api_key",
4869
+ credential => credential.type === "api_key" && credential.source === "login",
4870
+ );
4871
+ if (loginApiKeySource) return loginApiKeySource;
4713
4872
  if (getEnvApiKey(provider)) return `env (over ${baseLabel})`;
4714
- const apiKeySource = describeStored("api_key");
4873
+ const apiKeySource = describeStored(
4874
+ "api_key",
4875
+ credential => credential.type !== "api_key" || credential.source !== "login",
4876
+ );
4715
4877
  if (apiKeySource) return apiKeySource;
4716
4878
  if (this.#fallbackResolver?.(provider) !== undefined) return "fallback resolver";
4717
4879
  return undefined;
@@ -4776,9 +4938,10 @@ function normalizeStoredIdentityKey(identityKey: string | null | undefined): str
4776
4938
 
4777
4939
  function serializeCredential(provider: string, credential: AuthCredential): SerializedCredentialRecord | null {
4778
4940
  if (credential.type === "api_key") {
4941
+ const data = credential.source === "login" ? { key: credential.key, source: "login" } : { key: credential.key };
4779
4942
  return {
4780
4943
  credentialType: "api_key",
4781
- data: JSON.stringify({ key: credential.key }),
4944
+ data: JSON.stringify(data),
4782
4945
  identityKey: null,
4783
4946
  };
4784
4947
  }
@@ -4806,7 +4969,8 @@ function deserializeCredential(row: AuthRow): AuthCredential | null {
4806
4969
  if (row.credential_type === "api_key") {
4807
4970
  const data = parsed as Record<string, unknown>;
4808
4971
  if (typeof data.key === "string") {
4809
- return { type: "api_key", key: data.key };
4972
+ const source = data.source === "login" ? "login" : undefined;
4973
+ return source ? { type: "api_key", key: data.key, source } : { type: "api_key", key: data.key };
4810
4974
  }
4811
4975
  }
4812
4976
  if (row.credential_type === "oauth") {
@@ -127,12 +127,13 @@ export function buildBetaHeader(baseBetas: readonly string[], extraBetas: readon
127
127
 
128
128
  const midConversationSystemBeta = "mid-conversation-system-2026-04-07";
129
129
  const contextManagementBeta = "context-management-2025-06-27";
130
+ const structuredOutputsBeta = "structured-outputs-2025-12-15";
130
131
  const claudeCodeUtilityBetaDefaults = [
131
132
  "oauth-2025-04-20",
132
133
  "interleaved-thinking-2025-05-14",
133
134
  contextManagementBeta,
134
135
  "prompt-caching-scope-2026-01-05",
135
- "structured-outputs-2025-12-15",
136
+ structuredOutputsBeta,
136
137
  ] as const;
137
138
  const claudeCodeAgentBetaDefaults = [
138
139
  "claude-code-20250219",
@@ -159,10 +160,12 @@ function buildClaudeCodeBetas(
159
160
  agentRequest: boolean,
160
161
  thinkingRequest: boolean,
161
162
  redactThinking: boolean,
163
+ disableStrictTools = false,
162
164
  ): readonly string[] {
163
- if (!agentRequest && !redactThinking) return claudeCodeUtilityBetaDefaults;
165
+ if (!agentRequest && !redactThinking && !disableStrictTools) return claudeCodeUtilityBetaDefaults;
164
166
  const betas: string[] = [];
165
167
  for (const beta of agentRequest ? claudeCodeAgentBetaDefaults : claudeCodeUtilityBetaDefaults) {
168
+ if (disableStrictTools && beta === structuredOutputsBeta) continue;
166
169
  betas.push(beta);
167
170
  // Match CC's header order: redact-thinking immediately follows interleaved-thinking.
168
171
  if (redactThinking && beta === interleavedThinkingBeta) betas.push(redactThinkingBeta);
@@ -1098,6 +1101,7 @@ export type AnthropicClientOptionsArgs = {
1098
1101
  hasTools?: boolean;
1099
1102
  thinkingEnabled?: boolean;
1100
1103
  thinkingDisplay?: AnthropicThinkingDisplay;
1104
+ disableStrictTools?: boolean;
1101
1105
  fetch?: FetchImpl;
1102
1106
  claudeCodeSessionId?: string;
1103
1107
  };
@@ -1833,6 +1837,7 @@ const streamAnthropicOnce = (
1833
1837
  thinkingDisplay: options?.thinkingDisplay,
1834
1838
  fetch: options?.fetch,
1835
1839
  claudeCodeSessionId: options?.sessionId ?? extractClaudeMetadataSessionId(options?.metadata?.user_id),
1840
+ disableStrictTools,
1836
1841
  });
1837
1842
  client = created.client;
1838
1843
  isOAuthToken = created.isOAuthToken;
@@ -2648,8 +2653,10 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
2648
2653
  thinkingDisplay,
2649
2654
  isOAuth,
2650
2655
  claudeCodeSessionId,
2656
+ disableStrictTools: disableStrictToolsOverride,
2651
2657
  } = args;
2652
2658
  const compat = model.compat;
2659
+ const disableStrictTools = disableStrictToolsOverride ?? compat.disableStrictTools;
2653
2660
  const needsInterleavedBeta = interleavedThinking && !model.thinking?.supportsDisplay;
2654
2661
  const needsFineGrainedToolStreamingBeta = hasTools && !compat.supportsEagerToolInputStreaming;
2655
2662
  const oauthToken = isOAuth ?? isAnthropicOAuthToken(apiKey);
@@ -2722,7 +2729,12 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
2722
2729
  isCloudflareAiGateway: model.provider === "cloudflare-ai-gateway",
2723
2730
  claudeCodeSessionId,
2724
2731
  claudeCodeBetas: oauthToken
2725
- ? buildClaudeCodeBetas(hasTools || thinkingEnabled, thinkingEnabled, thinkingDisplay === "omitted")
2732
+ ? buildClaudeCodeBetas(
2733
+ hasTools || thinkingEnabled,
2734
+ thinkingEnabled,
2735
+ thinkingDisplay === "omitted",
2736
+ disableStrictTools,
2737
+ )
2726
2738
  : [],
2727
2739
  });
2728
2740
 
@@ -3529,6 +3541,40 @@ export function convertAnthropicMessages(
3529
3541
  });
3530
3542
  }
3531
3543
  }
3544
+ // Anthropic's replay validator rejects any non-`tool_use` block that
3545
+ // appears after a `tool_use` inside an assistant turn (400:
3546
+ // "tool_use ids were found without tool_result blocks immediately
3547
+ // after: <id>"). A persisted turn can violate this when a mid-turn
3548
+ // server-side fallback handoff lands after the primary model already
3549
+ // emitted a tool_use — the replayed content is then e.g.
3550
+ // [thinking, text, tool_use, fallback, text, tool_use] — and also for
3551
+ // the older cross-provider [text, tool_use, text] shape (issue #544).
3552
+ // Stable-partition into [...non-tool_use, ...tool_use], preserving each
3553
+ // side's relative order: the non-tool_use chain (thinking → text →
3554
+ // fallback → text) carries thinking signatures and the fallback
3555
+ // boundary marker whose order Anthropic verifies, while tool_use blocks
3556
+ // are unsigned and safe to defer to the tail. Fast-path untouched when
3557
+ // already in order so prompt-cache prefixes stay byte-identical.
3558
+ let sawToolUse = false;
3559
+ let needsPartition = false;
3560
+ for (const block of blocks) {
3561
+ if (block.type === "tool_use") {
3562
+ sawToolUse = true;
3563
+ } else if (sawToolUse) {
3564
+ needsPartition = true;
3565
+ break;
3566
+ }
3567
+ }
3568
+ if (needsPartition) {
3569
+ const nonToolUse: ContentBlockParam[] = [];
3570
+ const toolUse: ContentBlockParam[] = [];
3571
+ for (const block of blocks) {
3572
+ if (block.type === "tool_use") toolUse.push(block);
3573
+ else nonToolUse.push(block);
3574
+ }
3575
+ blocks.length = 0;
3576
+ blocks.push(...nonToolUse, ...toolUse);
3577
+ }
3532
3578
  if (blocks.length === 0) continue;
3533
3579
  params.push({
3534
3580
  role: "assistant",
@@ -1232,12 +1232,27 @@ function getOutputBlockStartEventType(block: CodexOutputBlock): "thinking_start"
1232
1232
  return "toolcall_start";
1233
1233
  }
1234
1234
 
1235
+ const CODEX_STALE_PREVIOUS_RESPONSE_CODES: Record<string, true> = {
1236
+ // OpenAI-standard code for an expired/missing `previous_response_id` chain.
1237
+ previous_response_not_found: true,
1238
+ // Proxy-specific: upstream response anchor expired. Same recovery class —
1239
+ // retry the turn with full context and no `previous_response_id`.
1240
+ codex_previous_response_stale: true,
1241
+ };
1242
+
1235
1243
  function isCodexStalePreviousResponseError(error: unknown): boolean {
1236
- if (error instanceof CodexProviderStreamError) return error.code === "previous_response_not_found";
1237
1244
  if (!(error instanceof Error)) return false;
1238
- if ((error as { code?: string }).code === "previous_response_not_found") return true;
1239
- // "unsupported": the backend intermittently rejects the parameter outright
1240
- // with `{"detail":"Unsupported parameter: previous_response_id"}` (no
1245
+ if (
1246
+ "code" in error &&
1247
+ typeof error.code === "string" &&
1248
+ Object.hasOwn(CODEX_STALE_PREVIOUS_RESPONSE_CODES, error.code)
1249
+ ) {
1250
+ return true;
1251
+ }
1252
+ // Message-based fallback for providers/proxies that report the condition
1253
+ // without a canonical code. Also covers "unsupported": the backend
1254
+ // intermittently rejects the parameter outright with
1255
+ // `{"detail":"Unsupported parameter: previous_response_id"}` (no
1241
1256
  // `error.code`); treat it like a stale chain so the turn replays with full
1242
1257
  // context instead of surfacing the 400.
1243
1258
  return (
@@ -668,7 +668,7 @@ const streamOpenAIResponsesOnce = (
668
668
  }
669
669
 
670
670
  // Detect premature stream closure: the HTTP stream ended without the
671
- // provider sending `response.completed` or `response.incomplete`.
671
+ // provider sending a recognized terminal response event.
672
672
  // Custom/proxy providers may drop the connection mid-stream; without
673
673
  // this guard the incomplete output is silently surfaced as a successful
674
674
  // "stop".
@@ -80,6 +80,7 @@ import {
80
80
  import type { ChatCompletionCreateParamsStreaming } from "./openai-chat-wire";
81
81
  import type { InputItem } from "./openai-codex/request-transformer";
82
82
  import type {
83
+ Response as OpenAIResponse,
83
84
  ResponseContentPartAddedEvent,
84
85
  ResponseCreateParamsStreaming,
85
86
  ResponseCustomToolCall,
@@ -1798,13 +1799,25 @@ export function finalizeCustomToolCallInputDone(block: ResponsesToolCallBlock, i
1798
1799
  block.arguments = { input };
1799
1800
  }
1800
1801
 
1802
+ type OpenAIResponsesTerminalStreamEvent =
1803
+ | Extract<ResponseStreamEvent, { type: "response.completed" | "response.incomplete" }>
1804
+ | { type: "response.done"; response?: Partial<OpenAIResponse> };
1805
+
1806
+ function getOpenAIResponsesTerminalEvent(event: ResponseStreamEvent): OpenAIResponsesTerminalStreamEvent | undefined {
1807
+ const type = (event as { type?: unknown }).type;
1808
+ return type === "response.completed" || type === "response.incomplete" || type === "response.done"
1809
+ ? (event as OpenAIResponsesTerminalStreamEvent)
1810
+ : undefined;
1811
+ }
1812
+
1801
1813
  export interface ProcessResponsesStreamOptions {
1802
1814
  onFirstToken?: () => void;
1803
1815
  onOutputItemDone?: (item: ResponseOutputItem) => void;
1804
1816
  /**
1805
- * Called when a terminal `response.completed` or `response.incomplete` event
1806
- * is successfully processed. Only invoked on the successful-completion path;
1807
- * thrown failure (`response.failed`) and cancellation paths never call this.
1817
+ * Called when a terminal `response.completed`, `response.incomplete`, or
1818
+ * `response.done` event is successfully processed. Only invoked on the
1819
+ * successful-completion path; thrown failure (`response.failed`) and
1820
+ * cancellation paths never call this.
1808
1821
  * Used by callers to detect premature stream closure (i.e. the stream ended
1809
1822
  * without a recognized terminal event).
1810
1823
  */
@@ -2039,6 +2052,7 @@ export async function processResponsesStream<TApi extends Api>(
2039
2052
  let sawFirstToken = false;
2040
2053
 
2041
2054
  for await (const event of openaiStream) {
2055
+ const terminalEvent = getOpenAIResponsesTerminalEvent(event);
2042
2056
  if (event.type === "response.created") {
2043
2057
  output.responseId = event.response.id;
2044
2058
  } else if (event.type === "response.output_item.added") {
@@ -2297,8 +2311,8 @@ export async function processResponsesStream<TApi extends Api>(
2297
2311
  closeOpenItem(event.output_index, item.id, entry, item.call_id, prefixedFunctionCallItemKey(item.call_id));
2298
2312
  stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
2299
2313
  }
2300
- } else if (event.type === "response.completed" || event.type === "response.incomplete") {
2301
- const response = event.response;
2314
+ } else if (terminalEvent) {
2315
+ const response = terminalEvent.response;
2302
2316
  finalizePendingResponsesToolCalls(output);
2303
2317
  if (response?.id) {
2304
2318
  output.responseId = response.id;
@@ -2336,7 +2350,7 @@ export async function processResponsesStream<TApi extends Api>(
2336
2350
  }
2337
2351
  promoteResponsesToolUseStopReason(output, (response as { end_turn?: boolean } | undefined)?.end_turn);
2338
2352
  options?.onCompleted?.();
2339
- // `response.completed`/`response.incomplete` is the last event of a
2353
+ // `response.completed`/`response.incomplete`/`response.done` is the last event of a
2340
2354
  // Responses stream. Stop pulling instead of waiting for the server to
2341
2355
  // close the connection: misbehaving providers keep the socket open
2342
2356
  // after the terminal event, which would park this loop until the idle
@@ -229,20 +229,32 @@ export abstract class OAuthCallbackFlow {
229
229
 
230
230
  /**
231
231
  * Build the `/launch` URL served by the callback server bound to `port`, or
232
- * `undefined` when the configured `callbackPath` (or a `redirectUri` whose
233
- * pathname resolves to {@link LAUNCH_PATH}) would collide with the launch
234
- * route. Kept short (~30 chars) so UIs can advertise it as a
232
+ * `undefined` when it must not be advertised:
233
+ * - the configured `callbackPath` (or a `redirectUri` whose pathname
234
+ * resolves to {@link LAUNCH_PATH}) would collide with the launch route;
235
+ * - the flow's `redirectUri` never returns to this loopback server: fixed
236
+ * non-loopback hosts, or custom schemes like GitLab Duo's `vscode://`
237
+ * URI — which `new URL` parses without complaint, so a scheme/host check
238
+ * is required, not just the parse failure path. Advertising a localhost
239
+ * `/launch` target for such flows misrepresents the callback endpoint
240
+ * and hands remote users a URL that resolves nowhere.
241
+ * Kept short (~30 chars) so UIs can advertise it as a
235
242
  * viewport-truncation-safe copy target for the full authorization URL.
236
243
  */
237
244
  #launchUrlIfSafe(port: number): string | undefined {
238
245
  if (this.callbackPath === LAUNCH_PATH) return undefined;
239
246
  if (this.redirectUri) {
240
247
  try {
241
- if (new URL(this.redirectUri).pathname === LAUNCH_PATH) return undefined;
248
+ const parsed = new URL(this.redirectUri);
249
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return undefined;
250
+ if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "[::1]") {
251
+ return undefined;
252
+ }
253
+ if (parsed.pathname === LAUNCH_PATH) return undefined;
242
254
  } catch {
243
- // A non-parseable redirectUri (e.g. `vscode://...` handled elsewhere)
244
- // can't collide with an HTTP `/launch` route fall through and
245
- // advertise the launch URL against the loopback server.
255
+ // A redirectUri even WHATWG URL cannot parse certainly does not
256
+ // return to this server never advertise a launch URL for it.
257
+ return undefined;
246
258
  }
247
259
  }
248
260
  return `http://${this.callbackHostname}:${port}${LAUNCH_PATH}`;
package/src/stream.ts CHANGED
@@ -1106,7 +1106,13 @@ export function streamSimple<TApi extends Api>(
1106
1106
  // Caller aborted between attempts: don't mint a fresh token or fire
1107
1107
  // another doomed request — emit the captured failure instead.
1108
1108
  if (signal?.aborted) break;
1109
- const nextKey = await resolveRetryKey(apiKeyResolver, AUTH_RETRY_STEPS[step]!, failure.error, signal);
1109
+ const nextKey = await resolveRetryKey(
1110
+ apiKeyResolver,
1111
+ AUTH_RETRY_STEPS[step]!,
1112
+ failure.error,
1113
+ signal,
1114
+ lastKey,
1115
+ );
1110
1116
  if (nextKey === undefined || nextKey === lastKey) continue;
1111
1117
  lastKey = nextKey;
1112
1118
  const isLastStep = step === AUTH_RETRY_STEPS.length - 1;
package/src/types.ts CHANGED
@@ -715,6 +715,8 @@ export interface AssistantMessage {
715
715
  stopReason: StopReason;
716
716
  stopDetails?: StopDetails | null;
717
717
  errorMessage?: string;
718
+ /** Per-tool abort messages used when an aborted assistant turn needs different placeholder results per tool call. */
719
+ toolCallAbortMessages?: Record<string, string>;
718
720
  /** HTTP status surfaced by the provider when the request failed. Populated by every provider's catch block alongside `errorMessage` so consumers (auth retry, telemetry, UI) can branch without regex-scraping the message. */
719
721
  errorStatus?: number;
720
722
  /** Structured machine-readable error classifier; see `utils/error-id.ts` for bit layout and helpers. */
@@ -285,8 +285,6 @@ function buildUsageLimit(args: {
285
285
  label: usageWindow.label,
286
286
  scope: {
287
287
  provider: "openai-codex",
288
- accountId: args.accountId,
289
- tier: args.planType,
290
288
  windowId: usageWindow.id,
291
289
  shared: true,
292
290
  },
@@ -507,6 +505,9 @@ export const openaiCodexUsageProvider: UsageProvider = {
507
505
  const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
508
506
 
509
507
  export const codexRankingStrategy: CredentialRankingStrategy = {
508
+ blockScope() {
509
+ return "shared";
510
+ },
510
511
  findWindowLimits(report) {
511
512
  const findLimit = (key: "primary" | "secondary"): UsageLimit | undefined => {
512
513
  const direct = report.limits.find(l => l.id === `openai-codex:${key}`);