@oh-my-pi/pi-ai 16.3.15 → 16.4.1

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 (43) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +5 -2
  3. package/dist/types/auth-broker/remote-store.d.ts +1 -0
  4. package/dist/types/auth-broker/wire-schemas.d.ts +8 -0
  5. package/dist/types/auth-storage.d.ts +5 -0
  6. package/dist/types/providers/azure-openai-responses.d.ts +1 -1
  7. package/dist/types/providers/ollama.d.ts +1 -1
  8. package/dist/types/providers/openai-chat-server-schema.d.ts +1 -1
  9. package/dist/types/providers/openai-chat-wire.d.ts +1 -1
  10. package/dist/types/providers/openai-codex/request-transformer.d.ts +42 -6
  11. package/dist/types/providers/openai-codex-responses.d.ts +51 -8
  12. package/dist/types/providers/openai-completions.d.ts +1 -1
  13. package/dist/types/providers/openai-responses-wire.d.ts +9 -7
  14. package/dist/types/providers/openai-responses.d.ts +1 -1
  15. package/dist/types/providers/openai-shared.d.ts +30 -3
  16. package/dist/types/registry/novita.d.ts +6 -0
  17. package/dist/types/registry/registry.d.ts +4 -0
  18. package/dist/types/types.d.ts +23 -0
  19. package/package.json +4 -4
  20. package/src/auth-broker/remote-store.ts +60 -5
  21. package/src/auth-broker/server.ts +1 -0
  22. package/src/auth-broker/wire-schemas.ts +1 -0
  23. package/src/auth-storage.ts +94 -20
  24. package/src/error/flags.ts +4 -1
  25. package/src/error/rate-limit.ts +7 -1
  26. package/src/providers/amazon-bedrock.ts +1 -0
  27. package/src/providers/anthropic-messages-server.ts +18 -0
  28. package/src/providers/azure-openai-responses.ts +1 -1
  29. package/src/providers/ollama.ts +1 -1
  30. package/src/providers/openai-chat-server-schema.ts +1 -1
  31. package/src/providers/openai-chat-server.ts +8 -1
  32. package/src/providers/openai-chat-wire.ts +1 -1
  33. package/src/providers/openai-codex/request-transformer.ts +92 -45
  34. package/src/providers/openai-codex-responses.ts +468 -32
  35. package/src/providers/openai-completions.ts +29 -11
  36. package/src/providers/openai-responses-server.ts +8 -1
  37. package/src/providers/openai-responses-wire.ts +9 -7
  38. package/src/providers/openai-responses.ts +8 -2
  39. package/src/providers/openai-shared.ts +104 -3
  40. package/src/registry/novita.ts +22 -0
  41. package/src/registry/registry.ts +2 -0
  42. package/src/stream.ts +7 -1
  43. package/src/types.ts +26 -0
@@ -39,6 +39,7 @@ import type {
39
39
  * one broker call instead of N.
40
40
  */
41
41
  const USAGE_CACHE_TTL_MS = 15_000;
42
+ const CREDENTIAL_BLOCK_RECONCILE_DELAY_MS = 5 * 60_000;
42
43
  const WAIT_THRESHOLD_MS = 1_000;
43
44
  const MAX_WAIT_MS = 5_000;
44
45
  const BACKGROUND_WAIT_MS = 30_000;
@@ -50,7 +51,9 @@ function compareCredentialBlockSnapshots(a: CredentialBlockSnapshot, b: Credenti
50
51
  if (provider !== 0) return provider;
51
52
  const scope = a.blockScope.localeCompare(b.blockScope);
52
53
  if (scope !== 0) return scope;
53
- return a.blockedUntilMs - b.blockedUntilMs;
54
+ const blockedUntil = a.blockedUntilMs - b.blockedUntilMs;
55
+ if (blockedUntil !== 0) return blockedUntil;
56
+ return (a.updatedAtMs ?? 0) - (b.updatedAtMs ?? 0);
54
57
  }
55
58
 
56
59
  function toCredentialBlockSnapshot(block: StoredCredentialBlock): CredentialBlockSnapshot {
@@ -58,6 +61,7 @@ function toCredentialBlockSnapshot(block: StoredCredentialBlock): CredentialBloc
58
61
  providerKey: block.providerKey,
59
62
  blockScope: block.blockScope,
60
63
  blockedUntilMs: block.blockedUntilMs,
64
+ ...(block.updatedAtMs !== undefined ? { updatedAtMs: block.updatedAtMs } : {}),
61
65
  };
62
66
  }
63
67
 
@@ -74,7 +78,8 @@ function credentialBlockSnapshotsEqual(
74
78
  if (
75
79
  leftBlock.providerKey !== rightBlock.providerKey ||
76
80
  leftBlock.blockScope !== rightBlock.blockScope ||
77
- leftBlock.blockedUntilMs !== rightBlock.blockedUntilMs
81
+ leftBlock.blockedUntilMs !== rightBlock.blockedUntilMs ||
82
+ leftBlock.updatedAtMs !== rightBlock.updatedAtMs
78
83
  ) {
79
84
  return false;
80
85
  }
@@ -208,6 +213,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
208
213
  #cache: Map<string, CacheEntry> = new Map();
209
214
  #usageCache?: UsageCacheEntry;
210
215
  #usageInflight?: Promise<UsageReport[] | null>;
216
+ #credentialBlockReconcileAfter: Map<string, number> = new Map();
211
217
  #usageCacheEpoch = 0;
212
218
  #closed = false;
213
219
  /**
@@ -236,10 +242,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
236
242
  return this.#snapshot;
237
243
  }
238
244
 
239
- #applySnapshot(snapshot: SnapshotResponse, generation: number): void {
245
+ #applySnapshot(snapshot: SnapshotResponse, generation: number, protectNewBlocks = true): void {
240
246
  const nowMs = Date.now();
247
+ const previousCredentials = this.#snapshot.credentials;
241
248
  const credentials = snapshot.credentials.map(entry => this.#normalizeSnapshotEntryBlocks(entry, nowMs));
242
- if (snapshotBlocksChanged(this.#snapshot.credentials, credentials)) this.#invalidateUsageCache();
249
+ if (snapshotBlocksChanged(previousCredentials, credentials)) this.#invalidateUsageCache();
250
+ if (protectNewBlocks) this.#protectNewSnapshotBlocks(previousCredentials, credentials, nowMs);
243
251
  this.#snapshot = { ...snapshot, credentials };
244
252
  this.#generation = generation;
245
253
  this.#snapshotReceivedAt = nowMs;
@@ -251,6 +259,34 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
251
259
  logger.debug("auth-broker snapshot callback failed", { error: String(error) });
252
260
  }
253
261
  }
262
+ #protectNewSnapshotBlocks(previous: readonly SnapshotEntry[], next: readonly SnapshotEntry[], nowMs: number): void {
263
+ const previousBlocksByKey = new Map<string, string>();
264
+ for (const entry of previous) {
265
+ for (const block of entry.blocks ?? []) {
266
+ previousBlocksByKey.set(
267
+ `${entry.id}\0${block.providerKey}\0${block.blockScope}`,
268
+ `${block.blockedUntilMs}\0${block.updatedAtMs ?? ""}`,
269
+ );
270
+ }
271
+ }
272
+ const activeKeys = new Set<string>();
273
+ for (const entry of next) {
274
+ for (const block of entry.blocks ?? []) {
275
+ const key = `${entry.id}\0${block.providerKey}\0${block.blockScope}`;
276
+ activeKeys.add(key);
277
+ const signature = `${block.blockedUntilMs}\0${block.updatedAtMs ?? ""}`;
278
+ if (previousBlocksByKey.get(key) === signature) continue;
279
+ const updatedAtMs = block.updatedAtMs ?? nowMs;
280
+ this.#credentialBlockReconcileAfter.set(
281
+ key,
282
+ Math.min(block.blockedUntilMs, updatedAtMs + CREDENTIAL_BLOCK_RECONCILE_DELAY_MS),
283
+ );
284
+ }
285
+ }
286
+ for (const key of this.#credentialBlockReconcileAfter.keys()) {
287
+ if (!activeKeys.has(key)) this.#credentialBlockReconcileAfter.delete(key);
288
+ }
289
+ }
254
290
 
255
291
  async #runBackground(): Promise<void> {
256
292
  let backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
@@ -340,11 +376,13 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
340
376
  const incoming = this.#normalizeSnapshotEntryBlocks(entry, Date.now());
341
377
  const index = this.#snapshot.credentials.findIndex(candidate => candidate.id === incoming.id);
342
378
  const previousBlocks = index === -1 ? undefined : this.#snapshot.credentials[index]?.blocks;
343
- if (!credentialBlockSnapshotsEqual(previousBlocks, incoming.blocks)) this.#invalidateUsageCache();
379
+ const blocksChanged = !credentialBlockSnapshotsEqual(previousBlocks, incoming.blocks);
380
+ if (blocksChanged) this.#invalidateUsageCache();
344
381
  const credentials =
345
382
  index === -1
346
383
  ? [...this.#snapshot.credentials, incoming]
347
384
  : this.#snapshot.credentials.map((candidate, i) => (i === index ? incoming : candidate));
385
+ if (blocksChanged) this.#protectNewSnapshotBlocks(this.#snapshot.credentials, credentials, Date.now());
348
386
  this.#snapshot = { ...this.#snapshot, generation, serverNowMs, refresher, credentials };
349
387
  this.#generation = generation;
350
388
  this.#snapshotReceivedAt = Date.now();
@@ -392,6 +430,11 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
392
430
  return block.blockedUntilMs;
393
431
  }
394
432
 
433
+ getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined {
434
+ if (this.getCredentialBlock(credentialId, providerKey, blockScope) === undefined) return undefined;
435
+ return this.#credentialBlockReconcileAfter.get(`${credentialId}\0${providerKey}\0${blockScope}`);
436
+ }
437
+
395
438
  listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] {
396
439
  const nowMs = Date.now();
397
440
  this.cleanExpiredCredentialBlocks(nowMs);
@@ -406,6 +449,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
406
449
  providerKey: block.providerKey,
407
450
  blockScope: block.blockScope,
408
451
  blockedUntilMs: block.blockedUntilMs,
452
+ updatedAtMs: block.updatedAtMs,
409
453
  });
410
454
  }
411
455
  }
@@ -416,6 +460,10 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
416
460
  upsertCredentialBlock(block: StoredCredentialBlock): void {
417
461
  this.#upsertSnapshotBlock(block);
418
462
  this.#invalidateUsageCache();
463
+ this.#credentialBlockReconcileAfter.set(
464
+ `${block.credentialId}\0${block.providerKey}\0${block.blockScope}`,
465
+ Math.min(block.blockedUntilMs, Date.now() + CREDENTIAL_BLOCK_RECONCILE_DELAY_MS),
466
+ );
419
467
  const body = toCredentialBlockSnapshot(block);
420
468
  void this.#client
421
469
  .upsertCredentialBlock(block.credentialId, body)
@@ -434,6 +482,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
434
482
 
435
483
  deleteCredentialBlocks(credentialId: number): void {
436
484
  this.#deleteSnapshotBlocks(credentialId);
485
+ for (const key of this.#credentialBlockReconcileAfter.keys()) {
486
+ if (key.startsWith(`${credentialId}\0`)) this.#credentialBlockReconcileAfter.delete(key);
487
+ }
437
488
  this.#invalidateUsageCache();
438
489
  void this.#client
439
490
  .deleteCredentialBlocks(credentialId)
@@ -450,6 +501,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
450
501
 
451
502
  cleanExpiredCredentialBlocks(nowMs: number): void {
452
503
  this.#pruneExpiredCredentialBlocks(nowMs);
504
+ for (const [key, reconcileAfterMs] of this.#credentialBlockReconcileAfter) {
505
+ if (reconcileAfterMs <= nowMs) this.#credentialBlockReconcileAfter.delete(key);
506
+ }
453
507
  }
454
508
 
455
509
  /**
@@ -647,6 +701,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
647
701
  providerKey: block.providerKey,
648
702
  blockScope: block.blockScope,
649
703
  blockedUntilMs: block.blockedUntilMs,
704
+ ...(block.updatedAtMs !== undefined ? { updatedAtMs: block.updatedAtMs } : {}),
650
705
  }))
651
706
  .sort(compareCredentialBlockSnapshots);
652
707
  if (blocks.length > 0) return { ...entry, blocks };
@@ -290,6 +290,7 @@ function buildCredentialBlockGroups(
290
290
  providerKey: block.providerKey,
291
291
  blockScope: block.blockScope,
292
292
  blockedUntilMs: block.blockedUntilMs,
293
+ updatedAtMs: block.updatedAtMs,
293
294
  };
294
295
  const existing = byCredentialId.get(block.credentialId);
295
296
  if (existing) {
@@ -74,6 +74,7 @@ export const credentialBlockSnapshotSchema = type({
74
74
  providerKey: type("string").atLeastLength(1),
75
75
  blockScope: "string",
76
76
  blockedUntilMs: "number",
77
+ "updatedAtMs?": "number",
77
78
  });
78
79
 
79
80
  export const snapshotEntrySchema = type({
@@ -134,6 +134,8 @@ export interface StoredCredentialBlock {
134
134
  blockScope: string;
135
135
  /** Epoch milliseconds. */
136
136
  blockedUntilMs: number;
137
+ /** Last row update timestamp in epoch milliseconds, when provided by the backing store. */
138
+ updatedAtMs?: number;
137
139
  }
138
140
 
139
141
  /**
@@ -320,6 +322,8 @@ export interface AuthCredentialStore {
320
322
  cleanExpiredCache(): void;
321
323
  /** Non-expired block for one (credential, providerKey, scope) key, or undefined. */
322
324
  getCredentialBlock?(credentialId: number, providerKey: string, blockScope: string): number | undefined;
325
+ /** Earliest time a shared-store block should be eligible for live-usage reconciliation. */
326
+ getCredentialBlockReconcileAfter?(credentialId: number, providerKey: string, blockScope: string): number | undefined;
323
327
  /** Upsert with MAX semantics: keep the later blockedUntilMs on conflict. */
324
328
  upsertCredentialBlock?(block: StoredCredentialBlock): void;
325
329
  /** Drop every block row for a credential (all providerKeys/scopes). */
@@ -817,6 +821,8 @@ function getUsagePlanType(report: UsageReport | null): string | undefined {
817
821
  function classifyOpenAICodexPlan(report: UsageReport | null): OpenAICodexPlanClass {
818
822
  const planType = getUsagePlanType(report);
819
823
  if (!planType) return "unknown";
824
+ // Pro Lite is a paid Codex tier, but does not imply full Pro-only model access.
825
+ if (planType === "prolite" || planType === "pro_lite") return "paid";
820
826
  const tokens = planType.split("_");
821
827
  if (tokens.some(token => OPENAI_CODEX_PRO_PLAN_TOKENS[token] === true)) return "pro";
822
828
  if (tokens.some(token => OPENAI_CODEX_PAID_PLAN_TOKENS[token] === true)) return "paid";
@@ -1022,6 +1028,8 @@ export class AuthStorage {
1022
1028
  #sessionLastCredential: Map<string, Map<string, { type: AuthCredential["type"]; index: number }>> = new Map();
1023
1029
  /** Maps provider:type -> credentialIndex -> blockedUntilMs for temporary backoff. */
1024
1030
  #credentialBackoff: Map<string, Map<number, number>> = new Map();
1031
+ /** Earliest time a freshly-set in-memory block may be cleared by live usage reconciliation. */
1032
+ #credentialBackoffProbeAfter: Map<string, Map<number, number>> = new Map();
1025
1033
  #usageProviderResolver?: (provider: Provider) => UsageProvider | undefined;
1026
1034
  #rankingStrategyResolver?: (provider: Provider) => CredentialRankingStrategy | undefined;
1027
1035
  #usageCache: UsageCache;
@@ -1402,6 +1410,9 @@ export class AuthStorage {
1402
1410
  if (backoffMap.size === 0) {
1403
1411
  this.#credentialBackoff.delete(backoffKey);
1404
1412
  }
1413
+ const probeAfterMap = this.#credentialBackoffProbeAfter.get(backoffKey);
1414
+ probeAfterMap?.delete(credentialIndex);
1415
+ if (probeAfterMap?.size === 0) this.#credentialBackoffProbeAfter.delete(backoffKey);
1405
1416
  return undefined;
1406
1417
  }
1407
1418
  return blockedUntil;
@@ -1494,6 +1505,9 @@ export class AuthStorage {
1494
1505
  const nextBlockedUntil = Math.max(existing, blockedUntilMs);
1495
1506
  backoffMap.set(credentialIndex, nextBlockedUntil);
1496
1507
  this.#credentialBackoff.set(backoffKey, backoffMap);
1508
+ const probeAfterMap = this.#credentialBackoffProbeAfter.get(backoffKey) ?? new Map<number, number>();
1509
+ probeAfterMap.set(credentialIndex, Math.min(nextBlockedUntil, Date.now() + USAGE_REPORT_TTL_MS));
1510
+ this.#credentialBackoffProbeAfter.set(backoffKey, probeAfterMap);
1497
1511
  this.#invalidateUsageReportCache(provider);
1498
1512
 
1499
1513
  const upsertCredentialBlock = this.#store.upsertCredentialBlock?.bind(this.#store);
@@ -3371,18 +3385,36 @@ export class AuthStorage {
3371
3385
  args.order.map(async idx => {
3372
3386
  const selection = args.credentials[idx];
3373
3387
  if (!selection) return null;
3374
- const blockedUntil = this.#getCredentialBlockedUntil(
3388
+ let blockedUntil = this.#getCredentialBlockedUntil(
3375
3389
  args.provider,
3376
3390
  args.providerKey,
3377
3391
  selection.index,
3378
3392
  args.blockScope,
3379
3393
  );
3380
- if (blockedUntil !== undefined) return { selection, usage: null, usageChecked: false, blockedUntil };
3381
- const usage = await this.#getUsageReport(args.provider, selection.credential, {
3382
- ...args.options,
3383
- timeoutMs: this.#usageRequestTimeoutMs,
3384
- });
3385
- return { selection, usage, usageChecked: true, blockedUntil: undefined as number | undefined };
3394
+ let usage: UsageReport | null = null;
3395
+ let usageChecked = false;
3396
+ if (blockedUntil !== undefined && args.provider === "openai-codex") {
3397
+ usage = await this.#getUsageReport(args.provider, selection.credential, {
3398
+ ...args.options,
3399
+ timeoutMs: this.#usageRequestTimeoutMs,
3400
+ });
3401
+ usageChecked = true;
3402
+ blockedUntil = this.#getCredentialBlockedUntil(
3403
+ args.provider,
3404
+ args.providerKey,
3405
+ selection.index,
3406
+ args.blockScope,
3407
+ );
3408
+ }
3409
+ if (blockedUntil !== undefined) return { selection, usage, usageChecked, blockedUntil };
3410
+ if (!usageChecked) {
3411
+ usage = await this.#getUsageReport(args.provider, selection.credential, {
3412
+ ...args.options,
3413
+ timeoutMs: this.#usageRequestTimeoutMs,
3414
+ });
3415
+ usageChecked = true;
3416
+ }
3417
+ return { selection, usage, usageChecked, blockedUntil: undefined as number | undefined };
3386
3418
  }),
3387
3419
  );
3388
3420
  const timeoutSignal = Promise.withResolvers<null>();
@@ -4431,23 +4463,24 @@ export class AuthStorage {
4431
4463
  backoffMap.delete(index);
4432
4464
  if (backoffMap.size === 0) this.#credentialBackoff.delete(key);
4433
4465
  }
4466
+ for (const [key, probeAfterMap] of this.#credentialBackoffProbeAfter) {
4467
+ if (key !== providerKey && !key.startsWith(scopedPrefix)) continue;
4468
+ probeAfterMap.delete(index);
4469
+ if (probeAfterMap.size === 0) this.#credentialBackoffProbeAfter.delete(key);
4470
+ }
4434
4471
  }
4435
4472
 
4436
4473
  /**
4437
4474
  * Self-heal a stale Codex usage-limit block: when a fresh live usage report
4438
- * shows the account is allowed and below every limit, drop the persisted and
4439
- * in-memory `openai-codex:oauth` blocks so the balancer re-includes it. Only
4440
- * Codex its ranking strategy uses the single model-independent `"shared"`
4441
- * scope, so clearing every block for the credential id is exact.
4475
+ * says the account is allowed and below every reported limit, drop the
4476
+ * persisted and in-memory `openai-codex:oauth` blocks so credential selection
4477
+ * can re-include recovered seats before a stale block naturally expires.
4442
4478
  */
4443
4479
  #isHealthyCodexUsageReport(report: UsageReport): boolean {
4480
+ if (report.provider !== "openai-codex") return false;
4444
4481
  const metadata = report.metadata;
4445
- return (
4446
- report.provider === "openai-codex" &&
4447
- metadata?.allowed === true &&
4448
- metadata.limitReached === false &&
4449
- !this.#isUsageLimitReached(report.limits)
4450
- );
4482
+ if (metadata?.allowed !== true || metadata.limitReached !== false) return false;
4483
+ return !this.#isUsageLimitReached(report.limits);
4451
4484
  }
4452
4485
 
4453
4486
  #reconcileCodexUsageBlockForCredential(provider: Provider, credentialId: number, report: UsageReport): void {
@@ -4460,6 +4493,19 @@ export class AuthStorage {
4460
4493
  const blockScope = this.#rankingStrategyResolver?.(provider)?.blockScope?.({});
4461
4494
  const blockedUntilMs = this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex, blockScope);
4462
4495
  if (blockedUntilMs === undefined) return;
4496
+ // `/usage` can lag the request path that just returned 429. Fresh local or
4497
+ // broker-sourced blocks get one usage-cache window before healthy reports may
4498
+ // clear them.
4499
+ const nowMs = Date.now();
4500
+ const scopedBackoffKey = this.#toScopedBackoffKey(providerKey, blockScope);
4501
+ const globalProbeAfterMs = this.#credentialBackoffProbeAfter.get(providerKey)?.get(credentialIndex) ?? 0;
4502
+ const scopedProbeAfterMs = this.#credentialBackoffProbeAfter.get(scopedBackoffKey)?.get(credentialIndex) ?? 0;
4503
+ const getStoreReconcileAfter = this.#store.getCredentialBlockReconcileAfter?.bind(this.#store);
4504
+ const storeGlobalProbeAfterMs = getStoreReconcileAfter?.(credentialId, providerKey, "") ?? 0;
4505
+ const storeScopedProbeAfterMs = getStoreReconcileAfter?.(credentialId, providerKey, blockScope ?? "") ?? 0;
4506
+ if (Math.max(globalProbeAfterMs, scopedProbeAfterMs, storeGlobalProbeAfterMs, storeScopedProbeAfterMs) > nowMs) {
4507
+ return;
4508
+ }
4463
4509
  this.#clearCredentialBlocks(provider, credentialId);
4464
4510
  logger.info("Cleared stale Codex usage-limit block after healthy live usage report", {
4465
4511
  credentialId,
@@ -4963,6 +5009,7 @@ type CredentialBlockRow = {
4963
5009
  provider_key: string;
4964
5010
  block_scope: string;
4965
5011
  blocked_until_ms: number;
5012
+ updated_at: number;
4966
5013
  };
4967
5014
 
4968
5015
  type SerializedCredentialRecord = {
@@ -5178,6 +5225,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5178
5225
  #upsertCredentialBlockStmt: Statement;
5179
5226
  #deleteCredentialBlocksStmt: Statement;
5180
5227
  #deleteExpiredCredentialBlocksStmt: Statement;
5228
+ #credentialBlockReconcileAfter: Map<string, number> = new Map();
5181
5229
  #insertUsageHistoryStmt: Statement;
5182
5230
  #insertUsageCostStmt: Statement;
5183
5231
  #listUsageCostsStmt: Statement;
@@ -5224,10 +5272,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5224
5272
  );
5225
5273
  this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
5226
5274
  this.#getCredentialBlockStmt = this.#db.prepare(
5227
- "SELECT blocked_until_ms FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ? AND blocked_until_ms > ?",
5275
+ "SELECT blocked_until_ms, updated_at FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ? AND blocked_until_ms > ?",
5228
5276
  );
5229
5277
  this.#listCredentialBlocksByCredentialStmt = this.#db.prepare(
5230
- "SELECT credential_id, provider_key, block_scope, blocked_until_ms FROM auth_credential_blocks WHERE credential_id = ? AND blocked_until_ms > ? ORDER BY provider_key ASC, block_scope ASC",
5278
+ "SELECT credential_id, provider_key, block_scope, blocked_until_ms, updated_at FROM auth_credential_blocks WHERE credential_id = ? AND blocked_until_ms > ? ORDER BY provider_key ASC, block_scope ASC",
5231
5279
  );
5232
5280
  this.#upsertCredentialBlockStmt = this.#db.prepare(
5233
5281
  `INSERT INTO auth_credential_blocks (credential_id, provider_key, block_scope, blocked_until_ms, updated_at)
@@ -5841,11 +5889,26 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5841
5889
  const nowMs = Date.now();
5842
5890
  this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
5843
5891
  const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as
5844
- | { blocked_until_ms?: number }
5892
+ | { blocked_until_ms?: number; updated_at?: number }
5845
5893
  | undefined;
5846
5894
  return typeof row?.blocked_until_ms === "number" ? row.blocked_until_ms : undefined;
5847
5895
  }
5848
5896
 
5897
+ getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined {
5898
+ const nowMs = Date.now();
5899
+ this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
5900
+ const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as
5901
+ | { blocked_until_ms?: number; updated_at?: number }
5902
+ | undefined;
5903
+ if (typeof row?.blocked_until_ms !== "number") return undefined;
5904
+ const memoryReconcileAfter =
5905
+ this.#credentialBlockReconcileAfter.get(`${credentialId}\0${providerKey}\0${blockScope}`) ?? 0;
5906
+ const persistedReconcileAfter =
5907
+ typeof row.updated_at === "number" ? row.updated_at * 1000 + USAGE_REPORT_TTL_MS : 0;
5908
+ const reconcileAfter = Math.max(memoryReconcileAfter, persistedReconcileAfter);
5909
+ return reconcileAfter > nowMs ? Math.min(row.blocked_until_ms, reconcileAfter) : undefined;
5910
+ }
5911
+
5849
5912
  upsertCredentialBlock(block: StoredCredentialBlock): void {
5850
5913
  this.#upsertCredentialBlockStmt.run(
5851
5914
  block.credentialId,
@@ -5853,14 +5916,24 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5853
5916
  block.blockScope,
5854
5917
  block.blockedUntilMs,
5855
5918
  );
5919
+ this.#credentialBlockReconcileAfter.set(
5920
+ `${block.credentialId}\0${block.providerKey}\0${block.blockScope}`,
5921
+ Math.min(block.blockedUntilMs, Date.now() + USAGE_REPORT_TTL_MS),
5922
+ );
5856
5923
  }
5857
5924
 
5858
5925
  deleteCredentialBlocks(credentialId: number): void {
5859
5926
  this.#deleteCredentialBlocksStmt.run(credentialId);
5927
+ for (const key of this.#credentialBlockReconcileAfter.keys()) {
5928
+ if (key.startsWith(`${credentialId}\0`)) this.#credentialBlockReconcileAfter.delete(key);
5929
+ }
5860
5930
  }
5861
5931
 
5862
5932
  cleanExpiredCredentialBlocks(nowMs: number): void {
5863
5933
  this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
5934
+ for (const [key, reconcileAfterMs] of this.#credentialBlockReconcileAfter) {
5935
+ if (reconcileAfterMs <= nowMs) this.#credentialBlockReconcileAfter.delete(key);
5936
+ }
5864
5937
  }
5865
5938
 
5866
5939
  listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] {
@@ -5879,6 +5952,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5879
5952
  providerKey: row.provider_key,
5880
5953
  blockScope: row.block_scope,
5881
5954
  blockedUntilMs: row.blocked_until_ms,
5955
+ updatedAtMs: row.updated_at * 1000,
5882
5956
  });
5883
5957
  }
5884
5958
  }
@@ -1,5 +1,6 @@
1
1
  import { isUnexpectedSocketCloseMessage } from "@oh-my-pi/pi-utils";
2
2
  import type { Api, AssistantMessage } from "../types";
3
+ import { AwsCredentialsError } from "./aws";
3
4
  import {
4
5
  AnthropicConnectionError,
5
6
  AnthropicConnectionTimeoutError,
@@ -346,7 +347,9 @@ export function classify(error: unknown, api?: Api): number {
346
347
  }
347
348
  }
348
349
 
349
- if (link instanceof AnthropicConnectionTimeoutError) {
350
+ if (link instanceof AwsCredentialsError) {
351
+ kinds |= Flag.AuthFailed;
352
+ } else if (link instanceof AnthropicConnectionTimeoutError) {
350
353
  kinds |= Flag.Timeout | Flag.Transient;
351
354
  } else if (link instanceof AnthropicConnectionError) {
352
355
  kinds |= Flag.Transient;
@@ -67,6 +67,12 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
67
67
  lower.includes("exhausted") ||
68
68
  lower.includes("quota") ||
69
69
  lower.includes("usage limit") ||
70
+ // xAI SuperGrok: HTTP 403 "run out of credits" / spending-limit is an
71
+ // account-local cap — rotate, don't treat as auth failure.
72
+ lower.includes("run out of credits") ||
73
+ lower.includes("out of credits") ||
74
+ lower.includes("spending-limit") ||
75
+ lower.includes("spending limit") ||
70
76
  INSUFFICIENT_BALANCE_PATTERN.test(errorMessage)
71
77
  ) {
72
78
  return "QUOTA_EXHAUSTED";
@@ -100,7 +106,7 @@ export function calculateRateLimitBackoffMs(reason: RateLimitReason): number {
100
106
 
101
107
  /** Detect usage/quota limit errors in error messages (persistent, requires credential switch). */
102
108
  const USAGE_LIMIT_PATTERN =
103
- /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?(?:exceeded|reached|insufficient)|额度不足|额度耗尽|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?(?:balance|quota)/i;
109
+ /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?(?:exceeded|reached|insufficient)|额度不足|额度耗尽|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?(?:balance|quota)|run out of credits|out of credits|spending[- _]?limit|personal-team-blocked/i;
104
110
 
105
111
  /**
106
112
  * HTTP status codes that, absent richer body classification, represent an
@@ -997,6 +997,7 @@ function buildAdditionalModelRequestFields(
997
997
  medium: 8192,
998
998
  high: 16384,
999
999
  xhigh: 32768,
1000
+ max: 32768,
1000
1001
  };
1001
1002
  const budget = options.thinkingBudgets?.[level] ?? defaultBudgets[level];
1002
1003
 
@@ -1,3 +1,4 @@
1
+ import { Effort } from "@oh-my-pi/pi-catalog/effort";
1
2
  import { logger } from "@oh-my-pi/pi-utils";
2
3
  import { type } from "arktype";
3
4
  import { captureRequestHeaders, resolvePromptCacheKey } from "../auth-gateway/http";
@@ -291,6 +292,19 @@ function deriveCacheRetention(data: {
291
292
  return strongest;
292
293
  }
293
294
 
295
+ /**
296
+ * Inbound `output_config.effort` wire literal → catalog `Effort` (1:1).
297
+ * Values outside this table (none exist in the schema today) are ignored
298
+ * rather than guessed at.
299
+ */
300
+ const REASONING_EFFORT_BY_WIRE: Partial<Record<string, Effort>> = {
301
+ low: Effort.Low,
302
+ medium: Effort.Medium,
303
+ high: Effort.High,
304
+ xhigh: Effort.XHigh,
305
+ max: Effort.Max,
306
+ };
307
+
294
308
  export function parseRequest(body: unknown, headers?: Headers): ParsedRequest {
295
309
  const data = anthropicMessagesRequestSchema(body);
296
310
  if (data instanceof type.errors) {
@@ -351,6 +365,10 @@ export function parseRequest(body: unknown, headers?: Headers): ParsedRequest {
351
365
  if (data.output_config?.task_budget) {
352
366
  options.taskBudget = data.output_config.task_budget;
353
367
  }
368
+ if (data.output_config?.effort) {
369
+ const mapped = REASONING_EFFORT_BY_WIRE[data.output_config.effort];
370
+ if (mapped !== undefined) options.reasoning = mapped;
371
+ }
354
372
  const cacheRetention = deriveCacheRetention(data);
355
373
  if (cacheRetention !== undefined) options.cacheRetention = cacheRetention;
356
374
  // Anthropic clients commonly send `metadata: { user_id }`; forward verbatim
@@ -56,7 +56,7 @@ function resolveDeploymentName(model: Model<"azure-openai-responses">, options?:
56
56
 
57
57
  // Azure OpenAI Responses-specific options
58
58
  export interface AzureOpenAIResponsesOptions extends StreamOptions {
59
- reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh";
59
+ reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
60
60
  reasoningSummary?: "auto" | "detailed" | "concise" | null;
61
61
  azureApiVersion?: string;
62
62
  azureResourceName?: string;
@@ -35,7 +35,7 @@ import { transformMessages } from "./transform-messages";
35
35
  import { joinTextWithImagePlaceholder, partitionVisionContent } from "./vision-guard";
36
36
 
37
37
  export interface OllamaChatOptions extends StreamOptions {
38
- reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh";
38
+ reasoning?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
39
39
  disableReasoning?: boolean;
40
40
  toolChoice?: ToolChoice;
41
41
  }
@@ -209,7 +209,7 @@ export const openaiChatRequestSchema = type({
209
209
  "frequency_penalty?": "number",
210
210
  "logit_bias?": type({ "[string]": "number" }),
211
211
  "user?": "string",
212
- "reasoning_effort?": "'minimal' | 'low' | 'medium' | 'high' | 'xhigh'",
212
+ "reasoning_effort?": "'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max'",
213
213
  "parallel_tool_calls?": "boolean",
214
214
  "service_tier?": "'auto' | 'default' | 'flex' | 'scale' | 'priority'",
215
215
  "metadata?": type({ "[string]": "unknown" }),
@@ -35,7 +35,14 @@ export type { ParsedRequest };
35
35
  type ReasoningEffort = NonNullable<ParsedRequest["options"]["reasoning"]>;
36
36
 
37
37
  function isReasoningEffort(value: unknown): value is ReasoningEffort {
38
- return value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh";
38
+ return (
39
+ value === "minimal" ||
40
+ value === "low" ||
41
+ value === "medium" ||
42
+ value === "high" ||
43
+ value === "xhigh" ||
44
+ value === "max"
45
+ );
39
46
  }
40
47
 
41
48
  function isServiceTier(value: unknown): value is ServiceTier {
@@ -116,7 +116,7 @@ export type Metadata = {
116
116
  };
117
117
 
118
118
  /** Constrains effort on reasoning for reasoning models. */
119
- export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | null;
119
+ export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | null;
120
120
 
121
121
  /** JSON object response format (older JSON mode). */
122
122
  export interface ResponseFormatJSONObject {