@oh-my-pi/pi-ai 18.3.4 → 18.4.0

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 (62) hide show
  1. package/CHANGELOG.md +35 -48
  2. package/dist/types/auth/blocks.d.ts +10 -19
  3. package/dist/types/auth/cascade.d.ts +1 -1
  4. package/dist/types/auth/rotation.d.ts +6 -3
  5. package/dist/types/auth/types.d.ts +23 -3
  6. package/dist/types/auth/usage-cache.d.ts +14 -2
  7. package/dist/types/auth/usage.d.ts +2 -2
  8. package/dist/types/auth-broker/client.d.ts +2 -1
  9. package/dist/types/auth-broker/remote-store.d.ts +5 -6
  10. package/dist/types/auth-broker/types.d.ts +3 -1
  11. package/dist/types/auth-broker/wire-schemas.d.ts +2 -1
  12. package/dist/types/auth-retry.d.ts +22 -13
  13. package/dist/types/images/types.d.ts +9 -0
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/judgment/types.d.ts +3 -2
  16. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  17. package/dist/types/providers/cursor-pi-args.d.ts +10 -0
  18. package/dist/types/providers/cursor.d.ts +6 -2
  19. package/dist/types/providers/google-shared.d.ts +13 -2
  20. package/dist/types/types.d.ts +0 -10
  21. package/dist/types/usage/claude-reset.d.ts +6 -0
  22. package/dist/types/usage/claude.d.ts +6 -0
  23. package/dist/types/usage.d.ts +2 -0
  24. package/dist/types/utils/proxy.d.ts +2 -2
  25. package/package.json +6 -6
  26. package/src/auth/blocks.ts +79 -37
  27. package/src/auth/cascade.ts +5 -4
  28. package/src/auth/resets.ts +104 -40
  29. package/src/auth/rotation.ts +62 -21
  30. package/src/auth/select.ts +9 -2
  31. package/src/auth/types.ts +28 -3
  32. package/src/auth/usage-cache.ts +45 -2
  33. package/src/auth/usage.ts +52 -25
  34. package/src/auth-broker/client.ts +13 -0
  35. package/src/auth-broker/remote-store.ts +128 -27
  36. package/src/auth-broker/server.ts +20 -0
  37. package/src/auth-broker/types.ts +4 -1
  38. package/src/auth-broker/wire-schemas.ts +7 -0
  39. package/src/auth-retry.ts +50 -28
  40. package/src/images/openai-hosted.ts +18 -3
  41. package/src/images/types.ts +9 -0
  42. package/src/index.ts +1 -0
  43. package/src/judgment/types.ts +10 -6
  44. package/src/judgment/typesafe.ts +2 -2
  45. package/src/providers/anthropic.ts +54 -123
  46. package/src/providers/cursor/exec-modern.ts +1 -0
  47. package/src/providers/cursor/proto/agent.proto +5 -0
  48. package/src/providers/cursor-pi-args.ts +14 -0
  49. package/src/providers/cursor.ts +66 -28
  50. package/src/providers/google-gemini-cli.ts +2 -19
  51. package/src/providers/google-shared.ts +30 -22
  52. package/src/providers/ollama.ts +2 -0
  53. package/src/registry/oauth/oauth.html +206 -185
  54. package/src/stream.ts +2 -284
  55. package/src/types.ts +0 -10
  56. package/src/usage/claude-reset.ts +45 -11
  57. package/src/usage/claude.ts +79 -49
  58. package/src/usage.ts +2 -0
  59. package/src/utils/proxy.ts +3 -3
  60. package/src/utils/schema/CONSTRAINTS.md +30 -28
  61. package/src/utils/schema/normalize.ts +1 -1
  62. package/src/utils/schema/wire.ts +3 -3
@@ -161,6 +161,11 @@ interface UsageCacheEntry {
161
161
  fetchedAt: number;
162
162
  }
163
163
 
164
+ /** Identity of one credential block row: credential, provider key, and scope. */
165
+ function blockKey(credentialId: number, block: { providerKey: string; blockScope: string }): string {
166
+ return `${credentialId}\0${block.providerKey}\0${block.blockScope}`;
167
+ }
168
+
164
169
  function usageOverlayKey(
165
170
  provider: Provider,
166
171
  ids: { accountId?: string; email?: string; projectId?: string; orgId?: string },
@@ -278,6 +283,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
278
283
  #usageCache?: UsageCacheEntry;
279
284
  #usageInflight?: Promise<UsageReport[] | null>;
280
285
  #credentialBlockReconcileAfter: Map<string, number> = new Map();
286
+ /** Exact deleted rows suppressed until their old deadline, including snapshots racing the DELETE acknowledgement. */
287
+ #deletedCredentialBlocks: Map<string, CredentialBlockSnapshot> = new Map();
288
+ /** Local block writes not yet reflected by the broker; see {@link #pendingBlocksFor}. */
289
+ #pendingCredentialBlocks: Map<string, CredentialBlockSnapshot> = new Map();
281
290
  #usageCacheEpoch = 0;
282
291
  /** Raw broker credentials retained to size aggregate usage requests before account-pool filtering. */
283
292
  #brokerUsageProviderByCredentialId = new Map<number, Provider>();
@@ -379,16 +388,13 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
379
388
  const previousBlocksByKey = new Map<string, string>();
380
389
  for (const entry of previous) {
381
390
  for (const block of entry.blocks ?? []) {
382
- previousBlocksByKey.set(
383
- `${entry.id}\0${block.providerKey}\0${block.blockScope}`,
384
- `${block.blockedUntilMs}\0${block.updatedAtMs ?? ""}`,
385
- );
391
+ previousBlocksByKey.set(blockKey(entry.id, block), `${block.blockedUntilMs}\0${block.updatedAtMs ?? ""}`);
386
392
  }
387
393
  }
388
394
  const activeKeys = new Set<string>();
389
395
  for (const entry of next) {
390
396
  for (const block of entry.blocks ?? []) {
391
- const key = `${entry.id}\0${block.providerKey}\0${block.blockScope}`;
397
+ const key = blockKey(entry.id, block);
392
398
  activeKeys.add(key);
393
399
  const signature = `${block.blockedUntilMs}\0${block.updatedAtMs ?? ""}`;
394
400
  if (previousBlocksByKey.get(key) === signature) continue;
@@ -675,7 +681,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
675
681
 
676
682
  getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined {
677
683
  if (this.getCredentialBlock(credentialId, providerKey, blockScope) === undefined) return undefined;
678
- return this.#credentialBlockReconcileAfter.get(`${credentialId}\0${providerKey}\0${blockScope}`);
684
+ return this.#credentialBlockReconcileAfter.get(blockKey(credentialId, { providerKey, blockScope }));
679
685
  }
680
686
 
681
687
  listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] {
@@ -705,11 +711,14 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
705
711
  this.#noteActivity();
706
712
  this.#upsertSnapshotBlock(block);
707
713
  this.#invalidateUsageCache();
714
+ const key = blockKey(block.credentialId, block);
708
715
  this.#credentialBlockReconcileAfter.set(
709
- `${block.credentialId}\0${block.providerKey}\0${block.blockScope}`,
716
+ key,
710
717
  Math.min(block.blockedUntilMs, Date.now() + CREDENTIAL_BLOCK_RECONCILE_DELAY_MS),
711
718
  );
712
719
  const body = toCredentialBlockSnapshot(block);
720
+ const pending = this.#pendingCredentialBlocks.get(key);
721
+ if (!pending || pending.blockedUntilMs < body.blockedUntilMs) this.#pendingCredentialBlocks.set(key, body);
713
722
  void this.#client
714
723
  .upsertCredentialBlock(block.credentialId, body)
715
724
  .then(() => {
@@ -725,14 +734,39 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
725
734
  });
726
735
  }
727
736
 
728
- deleteCredentialBlock(_credentialId: number, _providerKey: string, _blockScope: string): void {
729
- // The broker protocol only supports deleting every block for a credential.
730
- // Keep scoped blocks until expiry rather than risk deleting unrelated or
731
- // newer broker state through that broader operation.
737
+ deleteCredentialBlock(credentialId: number, providerKey: string, blockScope: string): void {
738
+ this.#noteActivity();
739
+ const key = blockKey(credentialId, { providerKey, blockScope });
740
+ this.#pendingCredentialBlocks.delete(key);
741
+ const deleted = this.#snapshot.credentials
742
+ .find(entry => entry.id === credentialId)
743
+ ?.blocks?.find(block => block.providerKey === providerKey && block.blockScope === blockScope);
744
+ if (deleted) this.#deletedCredentialBlocks.set(key, deleted);
745
+ this.#deleteSnapshotBlocks(credentialId, { providerKey, blockScope });
746
+ this.#credentialBlockReconcileAfter.delete(key);
747
+ this.#invalidateUsageCache();
748
+ void this.#client
749
+ .deleteCredentialBlock(credentialId, { providerKey, blockScope })
750
+ .then(() => {
751
+ this.#maybeRefreshSnapshot("credential block delete");
752
+ })
753
+ .catch(error => {
754
+ if (this.#deletedCredentialBlocks.get(key) === deleted) this.#deletedCredentialBlocks.delete(key);
755
+ this.#maybeRefreshSnapshot("credential block delete failed");
756
+ logger.warn("auth-broker credential block delete propagation failed", {
757
+ id: credentialId,
758
+ providerKey,
759
+ blockScope,
760
+ error: String(error),
761
+ });
762
+ });
732
763
  }
733
764
 
734
765
  deleteCredentialBlocks(credentialId: number): void {
735
766
  this.#noteActivity();
767
+ for (const key of this.#pendingCredentialBlocks.keys()) {
768
+ if (key.startsWith(`${credentialId}\0`)) this.#pendingCredentialBlocks.delete(key);
769
+ }
736
770
  this.#deleteSnapshotBlocks(credentialId);
737
771
  for (const key of this.#credentialBlockReconcileAfter.keys()) {
738
772
  if (key.startsWith(`${credentialId}\0`)) this.#credentialBlockReconcileAfter.delete(key);
@@ -753,6 +787,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
753
787
 
754
788
  cleanExpiredCredentialBlocks(nowMs: number): void {
755
789
  this.#pruneExpiredCredentialBlocks(nowMs);
790
+ for (const [key, block] of this.#deletedCredentialBlocks) {
791
+ if (block.blockedUntilMs <= nowMs) this.#deletedCredentialBlocks.delete(key);
792
+ }
793
+ for (const [key, block] of this.#pendingCredentialBlocks) {
794
+ if (block.blockedUntilMs <= nowMs) this.#pendingCredentialBlocks.delete(key);
795
+ }
756
796
  for (const [key, reconcileAfterMs] of this.#credentialBlockReconcileAfter) {
757
797
  if (reconcileAfterMs <= nowMs) this.#credentialBlockReconcileAfter.delete(key);
758
798
  }
@@ -938,9 +978,29 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
938
978
  }
939
979
 
940
980
  #normalizeSnapshotEntryBlocks(entry: SnapshotEntry, nowMs: number): SnapshotEntry {
941
- if (!entry.blocks || entry.blocks.length === 0) return entry;
942
- const blocks = entry.blocks
943
- .filter(block => block.blockedUntilMs > nowMs)
981
+ const pending = this.#pendingBlocksFor(entry, nowMs);
982
+ if ((!entry.blocks || entry.blocks.length === 0) && pending.length === 0) return entry;
983
+ // A pending row replaces a shorter reported row for the same scope.
984
+ const merged = [
985
+ ...(entry.blocks ?? []).filter(
986
+ block =>
987
+ !pending.some(
988
+ candidate => candidate.providerKey === block.providerKey && candidate.blockScope === block.blockScope,
989
+ ),
990
+ ),
991
+ ...pending,
992
+ ];
993
+ const blocks = merged
994
+ .filter(block => {
995
+ if (block.blockedUntilMs <= nowMs) return false;
996
+ const deleted = this.#deletedCredentialBlocks.get(blockKey(entry.id, block));
997
+ // Suppress only the exact row we deleted. A fresh block in this
998
+ // scope, including one with the same deadline but a newer update,
999
+ // remains authoritative while the DELETE propagates.
1000
+ return (
1001
+ !deleted || deleted.blockedUntilMs !== block.blockedUntilMs || deleted.updatedAtMs !== block.updatedAtMs
1002
+ );
1003
+ })
944
1004
  .map(block => ({
945
1005
  providerKey: block.providerKey,
946
1006
  blockScope: block.blockScope,
@@ -954,7 +1014,32 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
954
1014
  return next;
955
1015
  }
956
1016
 
1017
+ /**
1018
+ * Locally written blocks the incoming entry does not yet carry. The broker
1019
+ * write is asynchronous: a snapshot racing it, or a failed write, must not
1020
+ * erase a block this process still depends on. A pending row retires once the
1021
+ * broker reports it (or a longer deadline) or when it expires.
1022
+ */
1023
+ #pendingBlocksFor(entry: SnapshotEntry, nowMs: number): CredentialBlockSnapshot[] {
1024
+ if (this.#pendingCredentialBlocks.size === 0) return [];
1025
+ const pending: CredentialBlockSnapshot[] = [];
1026
+ const prefix = `${entry.id}\0`;
1027
+ for (const [key, block] of this.#pendingCredentialBlocks) {
1028
+ if (!key.startsWith(prefix)) continue;
1029
+ const reported = entry.blocks?.find(
1030
+ candidate => candidate.providerKey === block.providerKey && candidate.blockScope === block.blockScope,
1031
+ );
1032
+ if (block.blockedUntilMs <= nowMs || (reported && reported.blockedUntilMs >= block.blockedUntilMs)) {
1033
+ this.#pendingCredentialBlocks.delete(key);
1034
+ continue;
1035
+ }
1036
+ pending.push(block);
1037
+ }
1038
+ return pending;
1039
+ }
1040
+
957
1041
  #upsertSnapshotBlock(block: StoredCredentialBlock): void {
1042
+ this.#deletedCredentialBlocks.delete(blockKey(block.credentialId, block));
958
1043
  const index = this.#snapshot.credentials.findIndex(entry => entry.id === block.credentialId);
959
1044
  if (index === -1) return;
960
1045
  const entry = this.#snapshot.credentials[index]!;
@@ -978,13 +1063,20 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
978
1063
  this.#snapshot = { ...this.#snapshot, credentials };
979
1064
  }
980
1065
 
981
- #deleteSnapshotBlocks(credentialId: number): void {
1066
+ #deleteSnapshotBlocks(credentialId: number, target?: { providerKey: string; blockScope: string }): void {
982
1067
  const index = this.#snapshot.credentials.findIndex(entry => entry.id === credentialId);
983
1068
  if (index === -1) return;
984
1069
  const entry = this.#snapshot.credentials[index]!;
985
1070
  if (!entry.blocks || entry.blocks.length === 0) return;
1071
+ const blocks = target
1072
+ ? entry.blocks.filter(
1073
+ block => block.providerKey !== target.providerKey || block.blockScope !== target.blockScope,
1074
+ )
1075
+ : [];
1076
+ if (blocks.length === entry.blocks.length) return;
986
1077
  const next: SnapshotEntry = { ...entry };
987
- delete next.blocks;
1078
+ if (blocks.length > 0) next.blocks = blocks;
1079
+ else delete next.blocks;
988
1080
  const credentials = [...this.#snapshot.credentials];
989
1081
  credentials[index] = next;
990
1082
  this.#snapshot = { ...this.#snapshot, credentials };
@@ -1050,14 +1142,22 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1050
1142
  async invalidateUsageCache(signal?: AbortSignal): Promise<void> {
1051
1143
  this.#noteActivity();
1052
1144
  this.#invalidateUsageCache();
1053
- await this.#client.notifyUsageStale(signal).catch(err => {
1145
+ try {
1146
+ await this.#client.notifyUsageStale(signal);
1147
+ } catch (err) {
1054
1148
  logger.warn("auth-broker notification of stale usage failed", { error: String(err) });
1055
- });
1149
+ } finally {
1150
+ // A concurrent read may have reached the broker before it processed the
1151
+ // notification. That response cannot seed the post-notification cache.
1152
+ this.#invalidateUsageCache();
1153
+ }
1056
1154
  }
1057
1155
 
1058
1156
  #invalidateUsageCache(): void {
1059
- this.#usageCache = undefined;
1060
- this.#usageInflight = undefined;
1157
+ // Snapshot/block updates invalidate quota evidence, not a failed broker
1158
+ // connection's cooldown. Keep the flight too: its completion will queue
1159
+ // one current-generation successor instead of overlapping broker calls.
1160
+ if (this.#usageCache?.reports !== null) this.#usageCache = undefined;
1061
1161
  this.#usageCacheEpoch += 1;
1062
1162
  }
1063
1163
 
@@ -1102,9 +1202,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1102
1202
 
1103
1203
  /**
1104
1204
  * Store-level hook consumed by `AuthStorage.usage.reports()` — proxies
1105
- * to the broker's `/v1/usage` endpoint. The broker's egress IP isn't
1106
- * rate-limited by Anthropic's per-IP `/usage` cap the way a heavy
1107
- * residential laptop is, so all credentials surface every cycle.
1205
+ * to the broker's `/v1/usage` endpoint. Shared per-credential caches and
1206
+ * cooldowns keep separate clients from multiplying provider probes.
1108
1207
  */
1109
1208
  async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
1110
1209
  this.#noteActivity();
@@ -1119,8 +1218,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1119
1218
  * same response (coalesced + cached), then overlays any client-observed
1120
1219
  * header hints for the matching credential.
1121
1220
  *
1122
- * The broker already aggregates with its own 30s TTL on the server side; our
1123
- * 15s client TTL is below that so we usually re-use the broker's cache too.
1221
+ * The broker caches each credential independently; the short client TTL
1222
+ * also folds sequential consumers into one broker round-trip.
1124
1223
  */
1125
1224
  async getUsageReport(
1126
1225
  provider: Provider,
@@ -1280,7 +1379,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1280
1379
  const inflight = this.#client
1281
1380
  .fetchUsage({ maxAccountsPerProvider: this.#maxBrokerUsageAccounts() })
1282
1381
  .then(body => {
1283
- if (epoch !== this.#usageCacheEpoch) return this.#loadUsageReports();
1382
+ if (epoch !== this.#usageCacheEpoch) {
1383
+ if (this.#usageInflight === inflight) this.#usageInflight = undefined;
1384
+ return this.#loadUsageReports();
1385
+ }
1284
1386
  this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
1285
1387
  return body.reports;
1286
1388
  })
@@ -1289,7 +1391,6 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1289
1391
  // Documented 15s TTL fallback: cache the null so sequential callers
1290
1392
  // don't re-hit the broker while it's still down. See
1291
1393
  // docs/auth-broker-gateway.md § "Client-side single-flight".
1292
- if (epoch !== this.#usageCacheEpoch) return this.#loadUsageReports();
1293
1394
  this.#usageCache = { reports: null, fetchedAt: Date.now() };
1294
1395
  return null;
1295
1396
  })
@@ -43,6 +43,7 @@ import {
43
43
  } from "./types";
44
44
  import {
45
45
  clientUsageReportRequestSchema,
46
+ credentialBlockDeleteRequestSchema,
46
47
  credentialBlockRequestSchema,
47
48
  credentialDisableRequestSchema,
48
49
  credentialUploadRequestSchema,
@@ -832,6 +833,25 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
832
833
  return json(status, { error: message });
833
834
  }
834
835
  }
836
+ const blockDeleteMatch = req.method === "DELETE" ? pathname.match(BLOCK_ROUTE) : null;
837
+ if (blockDeleteMatch) {
838
+ const id = Number.parseInt(blockDeleteMatch[1], 10);
839
+ const parsed = await parseBody(req, credentialBlockDeleteRequestSchema);
840
+ if (!parsed.ok) return parsed.response;
841
+ if (!opts.storage.credentials.snapshot().credentials.some(entry => entry.id === id)) {
842
+ return json(404, { error: `No credential with id=${id}` });
843
+ }
844
+ try {
845
+ opts.storage.blocks.delete(id, parsed.data.providerKey, parsed.data.blockScope);
846
+ const response: CredentialBlocksDeleteResponse = { ok: true };
847
+ logger.info("auth-broker credential block deleted", { id, peer, ...parsed.data });
848
+ return json(200, response);
849
+ } catch (error) {
850
+ const message = error instanceof Error ? error.message : String(error);
851
+ logger.warn("auth-broker credential block delete failed", { id, peer, error: message });
852
+ return json(500, { error: message });
853
+ }
854
+ }
835
855
  const blocksDeleteMatch = req.method === "DELETE" ? pathname.match(BLOCKS_ROUTE) : null;
836
856
  if (blocksDeleteMatch) {
837
857
  const id = Number.parseInt(blocksDeleteMatch[1], 10);
@@ -96,12 +96,15 @@ export interface DisabledCredentialsResponse {
96
96
  /** POST /v1/credential/:id/block request body. */
97
97
  export type CredentialBlockRequest = CredentialBlockSnapshot;
98
98
 
99
+ /** DELETE /v1/credential/:id/block request body; empty scope targets only the global row. */
100
+ export type CredentialBlockDeleteRequest = Pick<CredentialBlockSnapshot, "providerKey" | "blockScope">;
101
+
99
102
  /** POST /v1/credential/:id/block response body. */
100
103
  export interface CredentialBlockResponse {
101
104
  ok: boolean;
102
105
  }
103
106
 
104
- /** DELETE /v1/credential/:id/blocks response body. */
107
+ /** DELETE /v1/credential/:id/block or /blocks response body. */
105
108
  export interface CredentialBlocksDeleteResponse {
106
109
  ok: boolean;
107
110
  }
@@ -27,6 +27,7 @@ import type {
27
27
  ClientUsageReportRequest,
28
28
  ClientUsageReportResponse,
29
29
  ClientUsageSummaryResponse,
30
+ CredentialBlockDeleteRequest,
30
31
  CredentialBlockRequest,
31
32
  CredentialBlockResponse,
32
33
  CredentialBlockSnapshot,
@@ -393,6 +394,12 @@ export const disabledCredentialsResponseSchema: FluentType<DisabledCredentialsRe
393
394
 
394
395
  export const credentialBlockRequestSchema: FluentType<CredentialBlockRequest> = credentialBlockSnapshotSchema;
395
396
 
397
+ export const credentialBlockDeleteRequestSchema: FluentType<CredentialBlockDeleteRequest> = type({
398
+ "+": "reject",
399
+ providerKey: type("string").atLeastLength(1),
400
+ blockScope: "string",
401
+ });
402
+
396
403
  export const credentialBlockResponseSchema: FluentType<CredentialBlockResponse> = type({
397
404
  "+": "reject",
398
405
  ok: "boolean",
package/src/auth-retry.ts CHANGED
@@ -18,10 +18,10 @@ import { isConcurrencyCapExclusion, isUsageLimitOutcome } from "./error/rate-lim
18
18
  * - `error !== undefined && lastChance` → **step (c): switch account**
19
19
  * (invalidate/usage-limit the current credential and rotate to a sibling).
20
20
  *
21
- * Current drivers preserve that bounded a/b/c sequence for ordinary 401/auth
22
- * failures. Account-scoped policy denials, 403s, and usage-limit failures skip
23
- * refresh and may repeat step (c) until the resolver returns `undefined`,
24
- * cycles, or hits {@link AUTH_RETRY_MAX_ATTEMPTS}.
21
+ * Current drivers give an ordinary 401/auth failure one step (b) before
22
+ * repeating step (c) through distinct siblings. Account-scoped policy denials,
23
+ * 403s, and usage-limit failures skip refresh. Rotation stops when the resolver
24
+ * returns `undefined`, cycles, or hits {@link AUTH_RETRY_MAX_ATTEMPTS}.
25
25
  */
26
26
  export interface ApiKeyResolveContext {
27
27
  /** True when the resolver should rotate to a sibling credential. */
@@ -42,6 +42,12 @@ export interface ResolvedApiKey {
42
42
  apiKey: string;
43
43
  /** Durable row id of the credential that supplied this bearer, when known. */
44
44
  credentialId?: number;
45
+ /**
46
+ * Resolved after `LimitsApi.rotate` slept out a sibling's short block
47
+ * (`afterSiblingWait`): the driver accepts this bearer even if the request
48
+ * already sent it before that block.
49
+ */
50
+ afterSiblingWait?: boolean;
45
51
  }
46
52
 
47
53
  export type ApiKeyResolution = string | ResolvedApiKey | undefined;
@@ -53,6 +59,19 @@ export function resolvedApiKeyBearer(resolved: ApiKeyResolution): string | undef
53
59
  return (typeof resolved === "string" ? resolved : resolved?.apiKey) || undefined;
54
60
  }
55
61
 
62
+ /**
63
+ * Mark a post-rotation resolution as following a sibling-unblock wait so the
64
+ * retry driver may resend a bearer it already tried. Used by the rotating
65
+ * resolvers (`KeyCascade.resolver`, coding-agent `createApiKeyResolver`).
66
+ */
67
+ export function markAfterSiblingWait(resolved: ApiKeyResolution): ApiKeyResolution {
68
+ const apiKey = resolvedApiKeyBearer(resolved);
69
+ if (apiKey === undefined) return resolved;
70
+ return typeof resolved === "string"
71
+ ? { apiKey, afterSiblingWait: true }
72
+ : { ...resolved, apiKey, afterSiblingWait: true };
73
+ }
74
+
56
75
  /** A static bearer string, or a {@link ApiKeyResolver} that mints/rotates one. */
57
76
  export type ApiKey = string | ApiKeyResolver;
58
77
 
@@ -105,9 +124,8 @@ export function seedApiKeyResolver(seed: ApiKeyResolution, resolver: ApiKeyResol
105
124
  export { isAuthRetryableError };
106
125
 
107
126
  /**
108
- * Legacy bounded a/b/c retry sequence retained for public compatibility:
109
- * `false` → refresh-same, `true` → rotate/switch. Current drivers consume it
110
- * once for ordinary 401/auth failures; usage/account-limit failures may repeat
127
+ * Legacy a/b/c retry sequence retained for public compatibility:
128
+ * `false` → refresh-same, `true` → rotate/switch. Current drivers may repeat
111
129
  * sibling rotation until a termination guard fires.
112
130
  */
113
131
  export const AUTH_RETRY_STEPS: readonly boolean[] = [false, true];
@@ -155,8 +173,6 @@ export interface AuthRetryKeyState {
155
173
  lastKey: string;
156
174
  /** Whether the current credential already consumed its 401 refresh-same retry. */
157
175
  refreshedCurrent: boolean;
158
- /** Whether the legacy non-usage auth path already switched to one sibling. */
159
- legacyAuthSwitchUsed: boolean;
160
176
  /** Whether this operation already replayed once after an explicit token-refresh request. */
161
177
  tokenRefreshReplayUsed?: boolean;
162
178
  /** Total outbound attempts accepted for this logical operation, including the initial request. */
@@ -168,14 +184,20 @@ export function createAuthRetryKeyState(initialKey: string): AuthRetryKeyState {
168
184
  attemptedKeys: new Set([initialKey]),
169
185
  lastKey: initialKey,
170
186
  refreshedCurrent: false,
171
- legacyAuthSwitchUsed: false,
172
187
  tokenRefreshReplayUsed: false,
173
188
  attempts: 1,
174
189
  };
175
190
  }
176
191
 
177
- function acceptRetryKey(state: AuthRetryKeyState, key: string, refreshedCurrent: boolean): string | undefined {
178
- if (state.attemptedKeys.has(key) || state.attempts >= AUTH_RETRY_MAX_ATTEMPTS) return undefined;
192
+ function acceptRetryKey(
193
+ state: AuthRetryKeyState,
194
+ key: string,
195
+ refreshedCurrent: boolean,
196
+ afterSiblingWait = false,
197
+ ): string | undefined {
198
+ if ((!afterSiblingWait && state.attemptedKeys.has(key)) || state.attempts >= AUTH_RETRY_MAX_ATTEMPTS) {
199
+ return undefined;
200
+ }
179
201
  state.attemptedKeys.add(key);
180
202
  state.attempts += 1;
181
203
  state.lastKey = key;
@@ -202,7 +224,6 @@ export async function resolveNextAuthRetryKey(
202
224
  }
203
225
  const directRotation = isDirectCredentialRotationError(error);
204
226
  if (!directRotation) {
205
- if (state.legacyAuthSwitchUsed) return undefined;
206
227
  if (!state.refreshedCurrent) {
207
228
  const refreshed = await resolveRetryKey(resolver, false, error, signal, state.lastKey, onResolved);
208
229
  state.refreshedCurrent = true;
@@ -215,11 +236,13 @@ export async function resolveNextAuthRetryKey(
215
236
  }
216
237
 
217
238
  if (signal?.aborted) return undefined;
218
- const rotated = await resolveRetryKey(resolver, true, error, signal, state.lastKey, onResolved);
239
+ let afterSiblingWait = false;
240
+ const rotated = await resolveRetryKey(resolver, true, error, signal, state.lastKey, resolved => {
241
+ afterSiblingWait = typeof resolved === "object" && resolved.afterSiblingWait === true;
242
+ onResolved?.(resolved);
243
+ });
219
244
  if (signal?.aborted || rotated === undefined) return undefined;
220
- const accepted = acceptRetryKey(state, rotated, !directRotation);
221
- if (accepted !== undefined && !directRotation) state.legacyAuthSwitchUsed = true;
222
- return accepted;
245
+ return acceptRetryKey(state, rotated, !directRotation, afterSiblingWait);
223
246
  }
224
247
 
225
248
  function oauthCredentialIdentity(access: OAuthAccess): string {
@@ -248,9 +271,9 @@ async function runOAuthAttempt<T>(
248
271
  * applicable policy is exhausted, the resolver declines or cycles, or the
249
272
  * operation reaches {@link AUTH_RETRY_MAX_ATTEMPTS}. An explicit typed
250
273
  * token-refresh request gets exactly one refresh-current replay and never
251
- * enters sibling rotation. Ordinary 401/auth failures retain one
252
- * refresh-same plus one sibling switch; 403/usage-limit failures rotate
253
- * directly through distinct siblings.
274
+ * enters sibling rotation. Ordinary 401/auth failures get one refresh-same,
275
+ * then rotate through distinct siblings; 403/usage-limit failures skip the
276
+ * refresh and rotate directly.
254
277
  *
255
278
  * Used by non-streaming consumers (image generation, web search, completion
256
279
  * helpers). The streaming driver in `stream.ts` implements the same policy with
@@ -331,7 +354,7 @@ export interface WithOAuthAccessOptions {
331
354
  * - initial → `getOAuthAccess` (or `opts.seed`).
332
355
  * - typed token-refresh request → one forced refresh-current replay, then stop.
333
356
  * - 401/auth failure → one `getOAuthAccess` with `forceRefresh: true` for the
334
- * current account, then sibling rotation.
357
+ * current account, then sibling rotation through distinct credentials.
335
358
  * - 403/usage-limit failure → `rotateSessionCredential` directly, without a
336
359
  * force-refresh detour.
337
360
  *
@@ -363,7 +386,6 @@ export async function withOAuthAccess<T>(
363
386
  const attemptedBearers = new Set([lastAccess.accessToken]);
364
387
  const attemptedCredentialIdentities = new Set([oauthCredentialIdentity(lastAccess)]);
365
388
  let attemptCount = 1;
366
- let legacyAuthSwitchUsed = false;
367
389
  let refreshedCurrent = false;
368
390
  let tokenRefreshReplayUsed = false;
369
391
  let attemptResult = await runOAuthAttempt(lastAccess, attempt, isAuthError);
@@ -398,7 +420,6 @@ export async function withOAuthAccess<T>(
398
420
 
399
421
  const directRotation = isDirectCredentialRotationError(lastError);
400
422
  if (!directRotation) {
401
- if (legacyAuthSwitchUsed) break;
402
423
  if (!refreshedCurrent) {
403
424
  refreshedCurrent = true;
404
425
  try {
@@ -424,14 +445,16 @@ export async function withOAuthAccess<T>(
424
445
  }
425
446
 
426
447
  if (signal?.aborted || attemptCount >= AUTH_RETRY_MAX_ATTEMPTS) break;
448
+ let afterSiblingWait = false;
427
449
  try {
428
- const rotated = await storage.limits.rotate(provider, sessionId, {
450
+ const rotation = await storage.limits.rotate(provider, sessionId, {
429
451
  error: lastError,
430
452
  signal,
431
453
  apiKey: lastAccess.accessToken,
432
454
  credentialId: lastAccess.credentialId,
433
455
  });
434
- if (!rotated) break;
456
+ if (!rotation.switched) break;
457
+ afterSiblingWait = rotation.afterSiblingWait === true;
435
458
  next = await storage.oauth.access(provider, sessionId, { signal });
436
459
  } catch {
437
460
  next = undefined;
@@ -439,8 +462,8 @@ export async function withOAuthAccess<T>(
439
462
  if (signal?.aborted || !next) break;
440
463
  const credentialIdentity = oauthCredentialIdentity(next);
441
464
  if (
442
- attemptedCredentialIdentities.has(credentialIdentity) ||
443
- attemptedBearers.has(next.accessToken) ||
465
+ (!afterSiblingWait &&
466
+ (attemptedCredentialIdentities.has(credentialIdentity) || attemptedBearers.has(next.accessToken))) ||
444
467
  attemptCount >= AUTH_RETRY_MAX_ATTEMPTS
445
468
  ) {
446
469
  break;
@@ -450,7 +473,6 @@ export async function withOAuthAccess<T>(
450
473
  attemptCount += 1;
451
474
  lastAccess = next;
452
475
  refreshedCurrent = !directRotation;
453
- if (!directRotation) legacyAuthSwitchUsed = true;
454
476
  attemptResult = await runOAuthAttempt(next, attempt, isAuthError);
455
477
  if (attemptResult.ok) return attemptResult.result;
456
478
  lastError = attemptResult.error;
@@ -20,11 +20,15 @@ const IMAGE_SYSTEM_INSTRUCTION =
20
20
  interface HostedOutput {
21
21
  type: "image_generation_call" | "message";
22
22
  result?: string;
23
+ size?: string;
24
+ quality?: string;
23
25
  content?: Array<{ type?: string; text?: string; refusal?: string }>;
24
26
  }
25
27
 
26
28
  interface HostedResponse {
27
29
  output?: HostedOutput[];
30
+ /** Resolved tool configuration echoed by the backend, including the image model that actually ran. */
31
+ tools?: Array<{ type?: string; model?: string }>;
28
32
  usage?: { input_tokens?: number; output_tokens?: number };
29
33
  error?: { message?: string };
30
34
  }
@@ -83,7 +87,12 @@ function collectResponse(response: HostedResponse): ImageGenerationResult {
83
87
  for (const output of response.output ?? []) {
84
88
  if (output.type === "image_generation_call" && output.result) {
85
89
  const bytes = Buffer.from(output.result, "base64");
86
- images.push({ data: output.result, mimeType: parseImageMetadata(bytes)?.mimeType ?? "image/webp" });
90
+ images.push({
91
+ data: output.result,
92
+ mimeType: parseImageMetadata(bytes)?.mimeType ?? "image/webp",
93
+ ...(output.size ? { size: output.size } : {}),
94
+ ...(output.quality ? { quality: output.quality } : {}),
95
+ });
87
96
  }
88
97
  if (output.type === "message") {
89
98
  for (const part of output.content ?? []) {
@@ -93,7 +102,13 @@ function collectResponse(response: HostedResponse): ImageGenerationResult {
93
102
  }
94
103
  }
95
104
  const text = texts.join("\n").trim();
96
- return { images, ...(text ? { text } : {}), usage: usageFromWire(response.usage) };
105
+ const model = response.tools?.find(tool => tool.type === "image_generation")?.model;
106
+ return {
107
+ images,
108
+ ...(text ? { text } : {}),
109
+ usage: usageFromWire(response.usage),
110
+ ...(model ? { model } : {}),
111
+ };
97
112
  }
98
113
 
99
114
  async function parseSse(response: Response, signal?: AbortSignal): Promise<ImageGenerationResult> {
@@ -116,7 +131,7 @@ async function parseSse(response: Response, signal?: AbortSignal): Promise<Image
116
131
  completed = event.response;
117
132
  }
118
133
  }
119
- return collectResponse(completed?.output?.length ? completed : { output: fallbackOutput, usage: completed?.usage });
134
+ return collectResponse(completed?.output?.length ? completed : { ...completed, output: fallbackOutput });
120
135
  }
121
136
 
122
137
  export async function generateHostedImage(
@@ -17,12 +17,21 @@ export interface ImageGenerationRequest {
17
17
  export interface GeneratedImage {
18
18
  data: string;
19
19
  mimeType: string;
20
+ /** Output dimensions (`WIDTHxHEIGHT`) the provider reports for this image, which may differ from the request. */
21
+ size?: string;
22
+ /** Rendering quality the provider reports for this image. */
23
+ quality?: string;
20
24
  }
21
25
 
22
26
  export interface ImageGenerationResult {
23
27
  images: GeneratedImage[];
24
28
  text?: string;
25
29
  usage: Usage;
30
+ /**
31
+ * Image model the provider reports having run. Hosted backends may substitute their own model for the
32
+ * selected catalog entry (the ChatGPT/Codex backend does), so callers should prefer this over the catalog id.
33
+ */
34
+ model?: string;
26
35
  }
27
36
 
28
37
  export interface ImageGenerationOptions {
package/src/index.ts CHANGED
@@ -39,6 +39,7 @@ export type * from "./providers/openai-completions";
39
39
  export type * from "./providers/openai-responses";
40
40
  export type * from "./providers/synthetic";
41
41
  export * from "./registry";
42
+ export { resolveCacheRetention } from "./utils";
42
43
  export * from "./stream";
43
44
  export * from "./types";
44
45
  export * from "./usage";
@@ -117,15 +117,19 @@ export class JudgmentParseError extends Error {
117
117
 
118
118
  /**
119
119
  * Usage from a backend that reports token counts and, optionally, one billed
120
- * USD amount. Judgment pricing is input-only, so the amount lands on `input`.
120
+ * USD amount. Missing or non-finite amounts contribute zero to session totals.
121
+ * Judgment pricing is input-only, so the amount lands on `input`.
121
122
  */
122
- export function tokenUsage(input: number, output: number, cost = 0): Usage {
123
+ export function tokenUsage(input: number | undefined, output: number | undefined, cost = 0): Usage {
124
+ const inputTokens = typeof input === "number" && Number.isFinite(input) ? input : 0;
125
+ const outputTokens = typeof output === "number" && Number.isFinite(output) ? output : 0;
126
+ const billedCost = Number.isFinite(cost) ? cost : 0;
123
127
  return {
124
- input,
125
- output,
128
+ input: inputTokens,
129
+ output: outputTokens,
126
130
  cacheRead: 0,
127
131
  cacheWrite: 0,
128
- totalTokens: input + output,
129
- cost: { input: cost, output: 0, cacheRead: 0, cacheWrite: 0, total: cost },
132
+ totalTokens: inputTokens + outputTokens,
133
+ cost: { input: billedCost, output: 0, cacheRead: 0, cacheWrite: 0, total: billedCost },
130
134
  };
131
135
  }