@oh-my-pi/pi-ai 16.3.5 → 16.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -123,6 +123,18 @@ export interface StoredAuthCredential {
123
123
  disabledCause: string | null;
124
124
  }
125
125
 
126
+ /** One persisted rate-limit block: credential row id + provider-type key + optional scope. */
127
+ export interface StoredCredentialBlock {
128
+ /** SQLite row id of the credential (auth_credentials.id). */
129
+ credentialId: number;
130
+ /** `${provider}:${credentialType}` — same value as AuthStorage's in-memory providerKey. */
131
+ providerKey: string;
132
+ /** Block scope (e.g. "tier:fable"); empty string = unscoped. Never NUL-delimited. */
133
+ blockScope: string;
134
+ /** Epoch milliseconds. */
135
+ blockedUntilMs: number;
136
+ }
137
+
126
138
  /**
127
139
  * Per-credential health record returned by {@link AuthStorage.checkCredentials}.
128
140
  *
@@ -305,6 +317,16 @@ export interface AuthCredentialStore {
305
317
  getCache(key: string, options?: { includeExpired?: boolean }): string | null;
306
318
  setCache(key: string, value: string, expiresAtSec: number): void;
307
319
  cleanExpiredCache(): void;
320
+ /** Non-expired block for one (credential, providerKey, scope) key, or undefined. */
321
+ getCredentialBlock?(credentialId: number, providerKey: string, blockScope: string): number | undefined;
322
+ /** Upsert with MAX semantics: keep the later blockedUntilMs on conflict. */
323
+ upsertCredentialBlock?(block: StoredCredentialBlock): void;
324
+ /** Drop every block row for a credential (all providerKeys/scopes). */
325
+ deleteCredentialBlocks?(credentialId: number): void;
326
+ /** Prune rows with blocked_until_ms <= nowMs. */
327
+ cleanExpiredCredentialBlocks?(nowMs: number): void;
328
+ /** List non-expired blocks for broker snapshots. */
329
+ listCredentialBlocks?(credentialIds: readonly number[]): StoredCredentialBlock[];
308
330
  /**
309
331
  * Append usage-limit snapshots for trend history. Optional: stores without
310
332
  * durable storage (e.g. the broker remote store) omit it and recording is
@@ -987,6 +1009,11 @@ export class AuthStorage {
987
1009
  } catch {
988
1010
  // Best-effort.
989
1011
  }
1012
+ try {
1013
+ this.#store.cleanExpiredCredentialBlocks?.(Date.now());
1014
+ } catch {
1015
+ // Best-effort.
1016
+ }
990
1017
  this.#usageFetch = options.usageFetch ?? fetch;
991
1018
  this.#usageRequestTimeoutMs = options.usageRequestTimeoutMs ?? DEFAULT_USAGE_REQUEST_TIMEOUT_MS;
992
1019
  this.#refreshOAuthCredentialOverride = options.refreshOAuthCredential;
@@ -1305,13 +1332,13 @@ export class AuthStorage {
1305
1332
  return blockScope ? `${providerKey}\0${blockScope}` : providerKey;
1306
1333
  }
1307
1334
 
1308
- /** Returns block expiry timestamp for a credential/key pair, cleaning up expired entries. */
1309
- #getCredentialBlockedUntilForKey(backoffKey: string, credentialIndex: number): number | undefined {
1335
+ /** Returns in-memory block expiry timestamp for a credential/key pair, cleaning up expired entries. */
1336
+ #getCredentialBlockedUntilForKey(backoffKey: string, credentialIndex: number, nowMs: number): number | undefined {
1310
1337
  const backoffMap = this.#credentialBackoff.get(backoffKey);
1311
1338
  if (!backoffMap) return undefined;
1312
1339
  const blockedUntil = backoffMap.get(credentialIndex);
1313
1340
  if (!blockedUntil) return undefined;
1314
- if (blockedUntil <= Date.now()) {
1341
+ if (blockedUntil <= nowMs) {
1315
1342
  backoffMap.delete(credentialIndex);
1316
1343
  if (backoffMap.size === 0) {
1317
1344
  this.#credentialBackoff.delete(backoffKey);
@@ -1321,28 +1348,80 @@ export class AuthStorage {
1321
1348
  return blockedUntil;
1322
1349
  }
1323
1350
 
1324
- /** Returns block expiry timestamp for a credential, checking global then scoped blocks. */
1351
+ #readPersistedCredentialBlock(
1352
+ credentialId: number,
1353
+ providerKey: string,
1354
+ blockScope: string | undefined,
1355
+ ): number | undefined {
1356
+ const getCredentialBlock = this.#store.getCredentialBlock?.bind(this.#store);
1357
+ if (!getCredentialBlock) return undefined;
1358
+ try {
1359
+ return getCredentialBlock(credentialId, providerKey, blockScope ?? "");
1360
+ } catch (err) {
1361
+ logger.debug("Failed to read credential block from persistent store", {
1362
+ err,
1363
+ credentialId,
1364
+ providerKey,
1365
+ blockScope,
1366
+ });
1367
+ return undefined;
1368
+ }
1369
+ }
1370
+
1371
+ /** Returns block expiry timestamp for a credential, checking unscoped and scoped blocks. */
1325
1372
  #getCredentialBlockedUntil(
1373
+ provider: string,
1326
1374
  providerKey: string,
1327
1375
  credentialIndex: number,
1328
1376
  blockScope: string | undefined = undefined,
1329
1377
  ): number | undefined {
1330
- const globalBlockedUntil = this.#getCredentialBlockedUntilForKey(providerKey, credentialIndex);
1331
- if (globalBlockedUntil !== undefined || !blockScope) return globalBlockedUntil;
1332
- return this.#getCredentialBlockedUntilForKey(this.#toScopedBackoffKey(providerKey, blockScope), credentialIndex);
1378
+ const nowMs = Date.now();
1379
+ let blockedUntil = this.#getCredentialBlockedUntilForKey(providerKey, credentialIndex, nowMs);
1380
+ if (blockScope) {
1381
+ const scopedBlockedUntil = this.#getCredentialBlockedUntilForKey(
1382
+ this.#toScopedBackoffKey(providerKey, blockScope),
1383
+ credentialIndex,
1384
+ nowMs,
1385
+ );
1386
+ if (scopedBlockedUntil !== undefined && (blockedUntil === undefined || scopedBlockedUntil > blockedUntil)) {
1387
+ blockedUntil = scopedBlockedUntil;
1388
+ }
1389
+ }
1390
+
1391
+ const credentialId = this.#getStoredCredentials(provider)[credentialIndex]?.id;
1392
+ 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;
1399
+ }
1400
+ if (blockScope) {
1401
+ const persistedScopedBlockedUntil = this.#readPersistedCredentialBlock(credentialId, providerKey, blockScope);
1402
+ if (
1403
+ persistedScopedBlockedUntil !== undefined &&
1404
+ (blockedUntil === undefined || persistedScopedBlockedUntil > blockedUntil)
1405
+ ) {
1406
+ blockedUntil = persistedScopedBlockedUntil;
1407
+ }
1408
+ }
1409
+ return blockedUntil;
1333
1410
  }
1334
1411
 
1335
1412
  /** Checks if a credential is temporarily blocked due to usage limits. */
1336
1413
  #isCredentialBlocked(
1414
+ provider: string,
1337
1415
  providerKey: string,
1338
1416
  credentialIndex: number,
1339
1417
  blockScope: string | undefined = undefined,
1340
1418
  ): boolean {
1341
- return this.#getCredentialBlockedUntil(providerKey, credentialIndex, blockScope) !== undefined;
1419
+ return this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex, blockScope) !== undefined;
1342
1420
  }
1343
1421
 
1344
1422
  /** Marks a credential as blocked until the specified time. */
1345
1423
  #markCredentialBlocked(
1424
+ provider: string,
1346
1425
  providerKey: string,
1347
1426
  credentialIndex: number,
1348
1427
  blockedUntilMs: number,
@@ -1351,8 +1430,31 @@ export class AuthStorage {
1351
1430
  const backoffKey = this.#toScopedBackoffKey(providerKey, blockScope);
1352
1431
  const backoffMap = this.#credentialBackoff.get(backoffKey) ?? new Map<number, number>();
1353
1432
  const existing = backoffMap.get(credentialIndex) ?? 0;
1354
- backoffMap.set(credentialIndex, Math.max(existing, blockedUntilMs));
1433
+ const nextBlockedUntil = Math.max(existing, blockedUntilMs);
1434
+ backoffMap.set(credentialIndex, nextBlockedUntil);
1355
1435
  this.#credentialBackoff.set(backoffKey, backoffMap);
1436
+
1437
+ const upsertCredentialBlock = this.#store.upsertCredentialBlock?.bind(this.#store);
1438
+ if (!upsertCredentialBlock) return;
1439
+ const credentialId = this.#getStoredCredentials(provider)[credentialIndex]?.id;
1440
+ if (credentialId === undefined) return;
1441
+ try {
1442
+ upsertCredentialBlock({
1443
+ credentialId,
1444
+ providerKey,
1445
+ blockScope: blockScope ?? "",
1446
+ blockedUntilMs: nextBlockedUntil,
1447
+ });
1448
+ } catch (err) {
1449
+ logger.debug("Failed to persist credential block", {
1450
+ err,
1451
+ credentialId,
1452
+ provider,
1453
+ providerKey,
1454
+ blockScope,
1455
+ blockedUntilMs: nextBlockedUntil,
1456
+ });
1457
+ }
1356
1458
  }
1357
1459
 
1358
1460
  /** Records which credential was used for a session (for rate-limit switching). */
@@ -1469,7 +1571,7 @@ export class AuthStorage {
1469
1571
 
1470
1572
  for (const idx of order) {
1471
1573
  const candidate = credentials[idx];
1472
- if (!this.#isCredentialBlocked(providerKey, candidate.index)) {
1574
+ if (!this.#isCredentialBlocked(provider, providerKey, candidate.index)) {
1473
1575
  return candidate;
1474
1576
  }
1475
1577
  }
@@ -2983,7 +3085,7 @@ export class AuthStorage {
2983
3085
  }
2984
3086
  }
2985
3087
 
2986
- this.#markCredentialBlocked(providerKey, sessionCredential.index, blockedUntil, blockScope);
3088
+ this.#markCredentialBlocked(provider, providerKey, sessionCredential.index, blockedUntil, blockScope);
2987
3089
 
2988
3090
  const remainingCredentials = this.#getCredentialsForProvider(provider)
2989
3091
  .map((credential, index) => ({ credential, index }))
@@ -2994,7 +3096,12 @@ export class AuthStorage {
2994
3096
 
2995
3097
  let retryAtMs: number | undefined;
2996
3098
  for (const candidate of remainingCredentials) {
2997
- const candidateBlockedUntil = this.#getCredentialBlockedUntil(providerKey, candidate.index, blockScope);
3099
+ const candidateBlockedUntil = this.#getCredentialBlockedUntil(
3100
+ provider,
3101
+ providerKey,
3102
+ candidate.index,
3103
+ blockScope,
3104
+ );
2998
3105
  if (candidateBlockedUntil === undefined) return { switched: true };
2999
3106
  if (retryAtMs === undefined || candidateBlockedUntil < retryAtMs) retryAtMs = candidateBlockedUntil;
3000
3107
  }
@@ -3174,7 +3281,12 @@ export class AuthStorage {
3174
3281
  args.order.map(async idx => {
3175
3282
  const selection = args.credentials[idx];
3176
3283
  if (!selection) return null;
3177
- const blockedUntil = this.#getCredentialBlockedUntil(args.providerKey, selection.index, args.blockScope);
3284
+ const blockedUntil = this.#getCredentialBlockedUntil(
3285
+ args.provider,
3286
+ args.providerKey,
3287
+ selection.index,
3288
+ args.blockScope,
3289
+ );
3178
3290
  if (blockedUntil !== undefined) return { selection, usage: null, usageChecked: false, blockedUntil };
3179
3291
  const usage = await this.#getUsageReport(args.provider, selection.credential, {
3180
3292
  ...args.options,
@@ -3211,7 +3323,13 @@ export class AuthStorage {
3211
3323
  if (!blocked && scopedLimits && this.#isUsageLimitReached(scopedLimits)) {
3212
3324
  const resetAtMs = this.#getUsageResetAtMs(scopedLimits, nowMs);
3213
3325
  blockedUntil = resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs;
3214
- this.#markCredentialBlocked(args.providerKey, selection.index, blockedUntil, args.blockScope);
3326
+ this.#markCredentialBlocked(
3327
+ args.provider,
3328
+ args.providerKey,
3329
+ selection.index,
3330
+ blockedUntil,
3331
+ args.blockScope,
3332
+ );
3215
3333
  blocked = true;
3216
3334
  }
3217
3335
  const windows = usage ? strategy.findWindowLimits(usage, args.rankingContext) : undefined;
@@ -3275,7 +3393,7 @@ export class AuthStorage {
3275
3393
  // with the most headroom proactively and fall back intelligently when rate-limited.
3276
3394
  const sessionPreferredIsAvailable =
3277
3395
  sessionPreferredIndex !== undefined &&
3278
- !this.#isCredentialBlocked(providerKey, sessionPreferredIndex, blockScope);
3396
+ !this.#isCredentialBlocked(provider, providerKey, sessionPreferredIndex, blockScope);
3279
3397
  const shouldRank = checkUsage && (!sessionPreferredIsAvailable || requiresProModel);
3280
3398
  const rankingOrder = shouldRank && sessionId ? credentials.map((_credential, index) => index) : order;
3281
3399
  const candidates = shouldRank
@@ -3298,7 +3416,7 @@ export class AuthStorage {
3298
3416
  if (sessionPreferredIndex !== undefined && !requiresProModel) {
3299
3417
  const sessionPreferredCandidate = candidates.findIndex(
3300
3418
  candidate =>
3301
- !this.#isCredentialBlocked(providerKey, candidate.selection.index, blockScope) &&
3419
+ !this.#isCredentialBlocked(provider, providerKey, candidate.selection.index, blockScope) &&
3302
3420
  candidate.selection.index === sessionPreferredIndex,
3303
3421
  );
3304
3422
  if (sessionPreferredCandidate > 0) {
@@ -3403,7 +3521,7 @@ export class AuthStorage {
3403
3521
  if (resolved) return resolved;
3404
3522
  }
3405
3523
 
3406
- if (fallback && this.#isCredentialBlocked(providerKey, fallback.selection.index, blockScope)) {
3524
+ if (fallback && this.#isCredentialBlocked(provider, providerKey, fallback.selection.index, blockScope)) {
3407
3525
  return this.#tryOAuthCredential(provider, fallback.selection, providerKey, sessionId, options, {
3408
3526
  checkUsage,
3409
3527
  allowBlocked: true,
@@ -3566,7 +3684,7 @@ export class AuthStorage {
3566
3684
  blockScope,
3567
3685
  allowFallback = true,
3568
3686
  } = usageOptions;
3569
- if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index, blockScope)) {
3687
+ if (!allowBlocked && this.#isCredentialBlocked(provider, providerKey, selection.index, blockScope)) {
3570
3688
  return undefined;
3571
3689
  }
3572
3690
 
@@ -3603,6 +3721,7 @@ export class AuthStorage {
3603
3721
  if (this.#isUsageLimitReached(scopedLimits)) {
3604
3722
  const resetAtMs = this.#getUsageResetAtMs(scopedLimits, Date.now());
3605
3723
  this.#markCredentialBlocked(
3724
+ provider,
3606
3725
  providerKey,
3607
3726
  selection.index,
3608
3727
  resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs,
@@ -3679,6 +3798,7 @@ export class AuthStorage {
3679
3798
  if (this.#isUsageLimitReached(scopedLimits)) {
3680
3799
  const resetAtMs = this.#getUsageResetAtMs(scopedLimits, Date.now());
3681
3800
  this.#markCredentialBlocked(
3801
+ provider,
3682
3802
  providerKey,
3683
3803
  selection.index,
3684
3804
  resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs,
@@ -3755,7 +3875,7 @@ export class AuthStorage {
3755
3875
  }
3756
3876
  } else {
3757
3877
  // Block temporarily for transient failures (5 minutes)
3758
- this.#markCredentialBlocked(providerKey, selection.index, Date.now() + 5 * 60 * 1000);
3878
+ this.#markCredentialBlocked(provider, providerKey, selection.index, Date.now() + 5 * 60 * 1000);
3759
3879
  }
3760
3880
  }
3761
3881
 
@@ -4154,6 +4274,15 @@ export class AuthStorage {
4154
4274
  * that `markUsageLimitReached` set for the now-obsolete reset time.
4155
4275
  */
4156
4276
  #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
+ }
4284
+ }
4285
+
4157
4286
  const index = this.#getStoredCredentials(provider).findIndex(entry => entry.id === credentialId);
4158
4287
  if (index < 0) return;
4159
4288
  const providerKey = this.#getProviderTypeKey(provider, "oauth");
@@ -4213,6 +4342,7 @@ export class AuthStorage {
4213
4342
 
4214
4343
  this.#clearSessionCredential(provider, sessionId);
4215
4344
  this.#markCredentialBlocked(
4345
+ provider,
4216
4346
  this.#getProviderTypeKey(provider, matched.type),
4217
4347
  matched.index,
4218
4348
  Date.now() + AuthStorage.#defaultBackoffMs,
@@ -4275,11 +4405,16 @@ export class AuthStorage {
4275
4405
  (credential, index) =>
4276
4406
  credential.type === sessionCredential.type &&
4277
4407
  index !== sessionCredential.index &&
4278
- !this.#isCredentialBlocked(providerKey, index),
4408
+ !this.#isCredentialBlocked(provider, providerKey, index),
4279
4409
  );
4280
4410
  const target = this.#getStoredCredentials(provider)[sessionCredential.index];
4281
4411
  this.#clearSessionCredential(provider, sessionId);
4282
- this.#markCredentialBlocked(providerKey, sessionCredential.index, Date.now() + AuthStorage.#defaultBackoffMs);
4412
+ this.#markCredentialBlocked(
4413
+ provider,
4414
+ providerKey,
4415
+ sessionCredential.index,
4416
+ Date.now() + AuthStorage.#defaultBackoffMs,
4417
+ );
4283
4418
 
4284
4419
  if (target) {
4285
4420
  const markSuspect = this.#store.markCredentialSuspect?.bind(this.#store);
@@ -4505,6 +4640,33 @@ export class AuthStorage {
4505
4640
  });
4506
4641
  }
4507
4642
 
4643
+ /**
4644
+ * Broker-server seam: list non-expired persisted blocks for snapshot entries.
4645
+ */
4646
+ listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] {
4647
+ return this.#store.listCredentialBlocks?.(credentialIds) ?? [];
4648
+ }
4649
+
4650
+ /**
4651
+ * Broker-server seam: persist one credential block and notify snapshot waiters.
4652
+ */
4653
+ upsertCredentialBlock(block: StoredCredentialBlock): void {
4654
+ const upsertCredentialBlock = this.#store.upsertCredentialBlock?.bind(this.#store);
4655
+ if (!upsertCredentialBlock) return;
4656
+ upsertCredentialBlock(block);
4657
+ this.#bumpGeneration("credential-block");
4658
+ }
4659
+
4660
+ /**
4661
+ * Broker-server seam: clear all persisted blocks for one credential and notify snapshot waiters.
4662
+ */
4663
+ deleteCredentialBlocks(credentialId: number): void {
4664
+ const deleteCredentialBlocks = this.#store.deleteCredentialBlocks?.bind(this.#store);
4665
+ if (!deleteCredentialBlocks) return;
4666
+ deleteCredentialBlocks(credentialId);
4667
+ this.#bumpGeneration("credential-block");
4668
+ }
4669
+
4508
4670
  /**
4509
4671
  * Describe where the active credential for a provider came from.
4510
4672
  *
@@ -4570,13 +4732,20 @@ type AuthRow = {
4570
4732
  identity_key: string | null;
4571
4733
  };
4572
4734
 
4735
+ type CredentialBlockRow = {
4736
+ credential_id: number;
4737
+ provider_key: string;
4738
+ block_scope: string;
4739
+ blocked_until_ms: number;
4740
+ };
4741
+
4573
4742
  type SerializedCredentialRecord = {
4574
4743
  credentialType: AuthCredential["type"];
4575
4744
  data: string;
4576
4745
  identityKey: string | null;
4577
4746
  };
4578
4747
 
4579
- const AUTH_SCHEMA_VERSION = 4;
4748
+ const AUTH_SCHEMA_VERSION = 5;
4580
4749
  const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
4581
4750
 
4582
4751
  /**
@@ -4776,6 +4945,11 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4776
4945
  #getCacheIncludingExpiredStmt: Statement;
4777
4946
  #upsertCacheStmt: Statement;
4778
4947
  #deleteExpiredCacheStmt: Statement;
4948
+ #getCredentialBlockStmt: Statement;
4949
+ #listCredentialBlocksByCredentialStmt: Statement;
4950
+ #upsertCredentialBlockStmt: Statement;
4951
+ #deleteCredentialBlocksStmt: Statement;
4952
+ #deleteExpiredCredentialBlocksStmt: Statement;
4779
4953
  #insertUsageHistoryStmt: Statement;
4780
4954
  #insertUsageCostStmt: Statement;
4781
4955
  #listUsageCostsStmt: Statement;
@@ -4821,6 +4995,23 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4821
4995
  "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
4822
4996
  );
4823
4997
  this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
4998
+ this.#getCredentialBlockStmt = this.#db.prepare(
4999
+ "SELECT blocked_until_ms FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ? AND blocked_until_ms > ?",
5000
+ );
5001
+ this.#listCredentialBlocksByCredentialStmt = this.#db.prepare(
5002
+ "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",
5003
+ );
5004
+ this.#upsertCredentialBlockStmt = this.#db.prepare(
5005
+ `INSERT INTO auth_credential_blocks (credential_id, provider_key, block_scope, blocked_until_ms, updated_at)
5006
+ VALUES (?, ?, ?, ?, ${SQLITE_NOW_EPOCH})
5007
+ ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
5008
+ blocked_until_ms = MAX(blocked_until_ms, excluded.blocked_until_ms),
5009
+ updated_at = excluded.updated_at`,
5010
+ );
5011
+ this.#deleteCredentialBlocksStmt = this.#db.prepare("DELETE FROM auth_credential_blocks WHERE credential_id = ?");
5012
+ this.#deleteExpiredCredentialBlocksStmt = this.#db.prepare(
5013
+ "DELETE FROM auth_credential_blocks WHERE blocked_until_ms <= ?",
5014
+ );
4824
5015
  this.#insertUsageHistoryStmt = this.#db.prepare(
4825
5016
  "INSERT INTO usage_history (recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
4826
5017
  );
@@ -4932,6 +5123,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4932
5123
 
4933
5124
  if (!this.#authCredentialsTableExists()) {
4934
5125
  this.#createAuthCredentialsTable();
5126
+ this.#createAuthCredentialBlocksTable();
4935
5127
  this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION);
4936
5128
  return;
4937
5129
  }
@@ -4948,6 +5140,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4948
5140
  }
4949
5141
 
4950
5142
  this.#createAuthCredentialIndexes();
5143
+ this.#createAuthCredentialBlocksTable();
4951
5144
  this.#backfillCredentialIdentityKeys();
4952
5145
  // Rewriting an already-current version row is a no-op write transaction
4953
5146
  // on every boot; only persist when the recorded version actually changes.
@@ -5031,6 +5224,20 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5031
5224
  `);
5032
5225
  }
5033
5226
 
5227
+ #createAuthCredentialBlocksTable(): void {
5228
+ this.#db.run(`
5229
+ CREATE TABLE IF NOT EXISTS auth_credential_blocks (
5230
+ credential_id INTEGER NOT NULL,
5231
+ provider_key TEXT NOT NULL,
5232
+ block_scope TEXT NOT NULL DEFAULT '',
5233
+ blocked_until_ms INTEGER NOT NULL,
5234
+ updated_at INTEGER NOT NULL,
5235
+ PRIMARY KEY (credential_id, provider_key, block_scope)
5236
+ );
5237
+ CREATE INDEX IF NOT EXISTS idx_auth_credential_blocks_expires ON auth_credential_blocks(blocked_until_ms);
5238
+ `);
5239
+ }
5240
+
5034
5241
  #migrateAuthSchema(fromVersion: number): void {
5035
5242
  if (fromVersion < 1) {
5036
5243
  this.#migrateAuthSchemaV0ToV1();
@@ -5041,6 +5248,9 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5041
5248
  if (fromVersion < 4) {
5042
5249
  this.#migrateAuthSchemaV3ToV4();
5043
5250
  }
5251
+ if (fromVersion < 5) {
5252
+ this.#migrateAuthSchemaV4ToV5();
5253
+ }
5044
5254
  }
5045
5255
 
5046
5256
  #migrateAuthSchemaV0ToV1(): void {
@@ -5127,6 +5337,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5127
5337
  migrate();
5128
5338
  }
5129
5339
 
5340
+ #migrateAuthSchemaV4ToV5(): void {
5341
+ const migrate = this.#db.transaction(() => {
5342
+ this.#createAuthCredentialBlocksTable();
5343
+ });
5344
+ migrate();
5345
+ }
5346
+
5130
5347
  #backfillCredentialIdentityKeys(): void {
5131
5348
  const selectRowsStmt = this.#db.prepare(
5132
5349
  "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
@@ -5392,6 +5609,54 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5392
5609
  }
5393
5610
  }
5394
5611
 
5612
+ getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined {
5613
+ const nowMs = Date.now();
5614
+ this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
5615
+ const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as
5616
+ | { blocked_until_ms?: number }
5617
+ | undefined;
5618
+ return typeof row?.blocked_until_ms === "number" ? row.blocked_until_ms : undefined;
5619
+ }
5620
+
5621
+ upsertCredentialBlock(block: StoredCredentialBlock): void {
5622
+ this.#upsertCredentialBlockStmt.run(
5623
+ block.credentialId,
5624
+ block.providerKey,
5625
+ block.blockScope,
5626
+ block.blockedUntilMs,
5627
+ );
5628
+ }
5629
+
5630
+ deleteCredentialBlocks(credentialId: number): void {
5631
+ this.#deleteCredentialBlocksStmt.run(credentialId);
5632
+ }
5633
+
5634
+ cleanExpiredCredentialBlocks(nowMs: number): void {
5635
+ this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
5636
+ }
5637
+
5638
+ listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] {
5639
+ if (credentialIds.length === 0) return [];
5640
+ const nowMs = Date.now();
5641
+ this.cleanExpiredCredentialBlocks(nowMs);
5642
+ const seenCredentialIds = new Set<number>();
5643
+ const blocks: StoredCredentialBlock[] = [];
5644
+ for (const credentialId of credentialIds) {
5645
+ if (seenCredentialIds.has(credentialId)) continue;
5646
+ seenCredentialIds.add(credentialId);
5647
+ const rows = this.#listCredentialBlocksByCredentialStmt.all(credentialId, nowMs) as CredentialBlockRow[];
5648
+ for (const row of rows) {
5649
+ blocks.push({
5650
+ credentialId: row.credential_id,
5651
+ providerKey: row.provider_key,
5652
+ blockScope: row.block_scope,
5653
+ blockedUntilMs: row.blocked_until_ms,
5654
+ });
5655
+ }
5656
+ }
5657
+ return blocks;
5658
+ }
5659
+
5395
5660
  recordUsageSnapshots(entries: UsageHistoryEntry[]): void {
5396
5661
  try {
5397
5662
  for (const entry of entries) {
@@ -5585,6 +5850,11 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5585
5850
  this.#getCacheIncludingExpiredStmt.finalize();
5586
5851
  this.#upsertCacheStmt.finalize();
5587
5852
  this.#deleteExpiredCacheStmt.finalize();
5853
+ this.#getCredentialBlockStmt.finalize();
5854
+ this.#listCredentialBlocksByCredentialStmt.finalize();
5855
+ this.#upsertCredentialBlockStmt.finalize();
5856
+ this.#deleteCredentialBlocksStmt.finalize();
5857
+ this.#deleteExpiredCredentialBlocksStmt.finalize();
5588
5858
  this.#insertUsageHistoryStmt.finalize();
5589
5859
  this.#lastUsageHistoryStmt.finalize();
5590
5860
  this.#listUsageHistoryStmt.finalize();
@@ -23,7 +23,7 @@ import {
23
23
  getOpenAIStreamFirstEventTimeoutMs,
24
24
  getOpenAIStreamIdleTimeoutMs,
25
25
  } from "../utils/idle-iterator";
26
- import { toolWireSchema } from "../utils/schema/wire";
26
+ import { sanitizeSchemaForOllama, toolWireSchema } from "../utils/schema";
27
27
  import {
28
28
  getStreamMarkupHealingPattern,
29
29
  type HealedToolCall,
@@ -280,7 +280,7 @@ function convertTools(tools: Tool[] | undefined): OllamaFunctionTool[] | undefin
280
280
  function: {
281
281
  name: tool.name,
282
282
  description: tool.description,
283
- parameters: toolWireSchema(tool),
283
+ parameters: sanitizeSchemaForOllama(toolWireSchema(tool)),
284
284
  },
285
285
  }));
286
286
  }
package/src/types.ts CHANGED
@@ -647,6 +647,23 @@ export interface DeveloperMessage {
647
647
  timestamp: number; // Unix timestamp in milliseconds
648
648
  }
649
649
 
650
+ export type AssistantRetryRecoveryKind = "credential" | "model" | "wait" | "plain";
651
+
652
+ export interface AssistantRetryRecovery {
653
+ kind: "auto-retry";
654
+ status: "recovered";
655
+ attempt: number;
656
+ recoveredAt: string;
657
+ recovery: AssistantRetryRecoveryKind;
658
+ note: string;
659
+ supersededBy?: {
660
+ timestamp: number;
661
+ responseId?: string;
662
+ provider: string;
663
+ model: string;
664
+ };
665
+ }
666
+
650
667
  export interface ContextSnapshot {
651
668
  promptTokens: number; // authoritative provider prompt/input tokens
652
669
  nonMessageTokens: number; // estimated non-message total at send time
@@ -660,6 +677,7 @@ export interface AssistantMessage {
660
677
  provider: Provider;
661
678
  model: string;
662
679
  contextSnapshot?: ContextSnapshot;
680
+ retryRecovery?: AssistantRetryRecovery;
663
681
  responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one
664
682
  /**
665
683
  * Name of the upstream provider an aggregator routed this request to, as
@@ -615,20 +615,40 @@ function scopeClaudeLimitsForModel(report: UsageReport, context: CredentialRanki
615
615
  }
616
616
 
617
617
  /**
618
- * Exclude Fable and Mythos tier weekly caps from proactive hard-blocking
619
- * (gating) because they are notoriously unreliable (they report 100% exhausted
620
- * while the account can still serve requests). They stay available for ranking
621
- * pressure in findWindowLimits via scopeClaudeLimitsForModel.
618
+ * A Fable/Mythos weekly row is trusted for gating only at full exhaustion
619
+ * (server `exhausted` status or used fraction >= 1) with a live reset
620
+ * timestamp. Anything below that stays untrusted: the counters are
621
+ * notoriously unreliable short of the cap (they report high utilization
622
+ * while the account can still serve requests).
623
+ */
624
+ function isConfirmedExhaustedTierRow(limit: UsageLimit, nowMs: number): boolean {
625
+ const resetsAt = limit.window?.resetsAt;
626
+ if (typeof resetsAt !== "number" || !Number.isFinite(resetsAt) || resetsAt <= nowMs) return false;
627
+ if (limit.status === "exhausted") return true;
628
+ const fraction = resolveUsedFraction(limit);
629
+ return typeof fraction === "number" && fraction >= 1;
630
+ }
631
+
632
+ /**
633
+ * Scope limits for proactive hard-blocking (gating). Fable and Mythos tier
634
+ * weekly caps participate only when {@link isConfirmedExhaustedTierRow}
635
+ * confirms them, so a confirmed-dead account is skipped up front and a
636
+ * reactive 429 block extends to the tier reset in markUsageLimitReached,
637
+ * while unconfirmed rows remain ranking pressure only via
638
+ * scopeClaudeLimitsForModel.
622
639
  */
623
640
  function scopeClaudeLimitsForModelHardBlock(
624
641
  report: UsageReport,
625
642
  context: CredentialRankingContext | undefined,
626
643
  ): UsageLimit[] {
627
644
  const kind = getClaudeModelKind(context);
628
- const excludeHardBlock = kind === "fable" || kind === "mythos";
629
- return report.limits.filter(
630
- limit => limit.scope.shared === true || (kind !== undefined && limit.scope.tier === kind && !excludeHardBlock),
631
- );
645
+ const requireConfirmedTierRow = kind === "fable" || kind === "mythos";
646
+ const nowMs = Date.now();
647
+ return report.limits.filter(limit => {
648
+ if (limit.scope.shared === true) return true;
649
+ if (kind === undefined || limit.scope.tier !== kind) return false;
650
+ return !requireConfirmedTierRow || isConfirmedExhaustedTierRow(limit, nowMs);
651
+ });
632
652
  }
633
653
 
634
654
  function rankingUsedFraction(limit: UsageLimit): number {