@oh-my-pi/pi-ai 16.3.4 → 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.
@@ -16,7 +16,13 @@ import * as AIError from "./error";
16
16
  import { isUsageLimitOutcome } from "./error/rate-limit";
17
17
  import { getProviderDefinition, PASTE_CODE_LOGIN_PROVIDERS } from "./registry";
18
18
  import { getOAuthApiKey, getOAuthProvider, refreshOAuthToken } from "./registry/oauth";
19
- import type { OAuthController, OAuthCredentials, OAuthProvider, OAuthProviderId } from "./registry/oauth/types";
19
+ import type {
20
+ OAuthAuthInfo,
21
+ OAuthController,
22
+ OAuthCredentials,
23
+ OAuthProvider,
24
+ OAuthProviderId,
25
+ } from "./registry/oauth/types";
20
26
  import { getEnvApiKey, getEnvApiKeyName } from "./stream";
21
27
  import type { Provider } from "./types";
22
28
  import type {
@@ -117,6 +123,18 @@ export interface StoredAuthCredential {
117
123
  disabledCause: string | null;
118
124
  }
119
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
+
120
138
  /**
121
139
  * Per-credential health record returned by {@link AuthStorage.checkCredentials}.
122
140
  *
@@ -299,6 +317,16 @@ export interface AuthCredentialStore {
299
317
  getCache(key: string, options?: { includeExpired?: boolean }): string | null;
300
318
  setCache(key: string, value: string, expiresAtSec: number): void;
301
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[];
302
330
  /**
303
331
  * Append usage-limit snapshots for trend history. Optional: stores without
304
332
  * durable storage (e.g. the broker remote store) omit it and recording is
@@ -981,6 +1009,11 @@ export class AuthStorage {
981
1009
  } catch {
982
1010
  // Best-effort.
983
1011
  }
1012
+ try {
1013
+ this.#store.cleanExpiredCredentialBlocks?.(Date.now());
1014
+ } catch {
1015
+ // Best-effort.
1016
+ }
984
1017
  this.#usageFetch = options.usageFetch ?? fetch;
985
1018
  this.#usageRequestTimeoutMs = options.usageRequestTimeoutMs ?? DEFAULT_USAGE_REQUEST_TIMEOUT_MS;
986
1019
  this.#refreshOAuthCredentialOverride = options.refreshOAuthCredential;
@@ -1299,13 +1332,13 @@ export class AuthStorage {
1299
1332
  return blockScope ? `${providerKey}\0${blockScope}` : providerKey;
1300
1333
  }
1301
1334
 
1302
- /** Returns block expiry timestamp for a credential/key pair, cleaning up expired entries. */
1303
- #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 {
1304
1337
  const backoffMap = this.#credentialBackoff.get(backoffKey);
1305
1338
  if (!backoffMap) return undefined;
1306
1339
  const blockedUntil = backoffMap.get(credentialIndex);
1307
1340
  if (!blockedUntil) return undefined;
1308
- if (blockedUntil <= Date.now()) {
1341
+ if (blockedUntil <= nowMs) {
1309
1342
  backoffMap.delete(credentialIndex);
1310
1343
  if (backoffMap.size === 0) {
1311
1344
  this.#credentialBackoff.delete(backoffKey);
@@ -1315,28 +1348,80 @@ export class AuthStorage {
1315
1348
  return blockedUntil;
1316
1349
  }
1317
1350
 
1318
- /** 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. */
1319
1372
  #getCredentialBlockedUntil(
1373
+ provider: string,
1320
1374
  providerKey: string,
1321
1375
  credentialIndex: number,
1322
1376
  blockScope: string | undefined = undefined,
1323
1377
  ): number | undefined {
1324
- const globalBlockedUntil = this.#getCredentialBlockedUntilForKey(providerKey, credentialIndex);
1325
- if (globalBlockedUntil !== undefined || !blockScope) return globalBlockedUntil;
1326
- 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;
1327
1410
  }
1328
1411
 
1329
1412
  /** Checks if a credential is temporarily blocked due to usage limits. */
1330
1413
  #isCredentialBlocked(
1414
+ provider: string,
1331
1415
  providerKey: string,
1332
1416
  credentialIndex: number,
1333
1417
  blockScope: string | undefined = undefined,
1334
1418
  ): boolean {
1335
- return this.#getCredentialBlockedUntil(providerKey, credentialIndex, blockScope) !== undefined;
1419
+ return this.#getCredentialBlockedUntil(provider, providerKey, credentialIndex, blockScope) !== undefined;
1336
1420
  }
1337
1421
 
1338
1422
  /** Marks a credential as blocked until the specified time. */
1339
1423
  #markCredentialBlocked(
1424
+ provider: string,
1340
1425
  providerKey: string,
1341
1426
  credentialIndex: number,
1342
1427
  blockedUntilMs: number,
@@ -1345,8 +1430,31 @@ export class AuthStorage {
1345
1430
  const backoffKey = this.#toScopedBackoffKey(providerKey, blockScope);
1346
1431
  const backoffMap = this.#credentialBackoff.get(backoffKey) ?? new Map<number, number>();
1347
1432
  const existing = backoffMap.get(credentialIndex) ?? 0;
1348
- backoffMap.set(credentialIndex, Math.max(existing, blockedUntilMs));
1433
+ const nextBlockedUntil = Math.max(existing, blockedUntilMs);
1434
+ backoffMap.set(credentialIndex, nextBlockedUntil);
1349
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
+ }
1350
1458
  }
1351
1459
 
1352
1460
  /** Records which credential was used for a session (for rate-limit switching). */
@@ -1463,7 +1571,7 @@ export class AuthStorage {
1463
1571
 
1464
1572
  for (const idx of order) {
1465
1573
  const candidate = credentials[idx];
1466
- if (!this.#isCredentialBlocked(providerKey, candidate.index)) {
1574
+ if (!this.#isCredentialBlocked(provider, providerKey, candidate.index)) {
1467
1575
  return candidate;
1468
1576
  }
1469
1577
  }
@@ -1863,7 +1971,7 @@ export class AuthStorage {
1863
1971
  provider: OAuthProviderId,
1864
1972
  ctrl: OAuthController & {
1865
1973
  /** onAuth is required by auth-storage but optional in OAuthController */
1866
- onAuth: (info: { url: string; instructions?: string }) => void;
1974
+ onAuth: (info: OAuthAuthInfo) => void;
1867
1975
  /** onPrompt is required for some providers (github-copilot, openai-codex) */
1868
1976
  onPrompt: (prompt: { message: string; placeholder?: string }) => Promise<string>;
1869
1977
  },
@@ -2977,7 +3085,7 @@ export class AuthStorage {
2977
3085
  }
2978
3086
  }
2979
3087
 
2980
- this.#markCredentialBlocked(providerKey, sessionCredential.index, blockedUntil, blockScope);
3088
+ this.#markCredentialBlocked(provider, providerKey, sessionCredential.index, blockedUntil, blockScope);
2981
3089
 
2982
3090
  const remainingCredentials = this.#getCredentialsForProvider(provider)
2983
3091
  .map((credential, index) => ({ credential, index }))
@@ -2988,7 +3096,12 @@ export class AuthStorage {
2988
3096
 
2989
3097
  let retryAtMs: number | undefined;
2990
3098
  for (const candidate of remainingCredentials) {
2991
- const candidateBlockedUntil = this.#getCredentialBlockedUntil(providerKey, candidate.index, blockScope);
3099
+ const candidateBlockedUntil = this.#getCredentialBlockedUntil(
3100
+ provider,
3101
+ providerKey,
3102
+ candidate.index,
3103
+ blockScope,
3104
+ );
2992
3105
  if (candidateBlockedUntil === undefined) return { switched: true };
2993
3106
  if (retryAtMs === undefined || candidateBlockedUntil < retryAtMs) retryAtMs = candidateBlockedUntil;
2994
3107
  }
@@ -3168,7 +3281,12 @@ export class AuthStorage {
3168
3281
  args.order.map(async idx => {
3169
3282
  const selection = args.credentials[idx];
3170
3283
  if (!selection) return null;
3171
- 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
+ );
3172
3290
  if (blockedUntil !== undefined) return { selection, usage: null, usageChecked: false, blockedUntil };
3173
3291
  const usage = await this.#getUsageReport(args.provider, selection.credential, {
3174
3292
  ...args.options,
@@ -3205,7 +3323,13 @@ export class AuthStorage {
3205
3323
  if (!blocked && scopedLimits && this.#isUsageLimitReached(scopedLimits)) {
3206
3324
  const resetAtMs = this.#getUsageResetAtMs(scopedLimits, nowMs);
3207
3325
  blockedUntil = resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs;
3208
- 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
+ );
3209
3333
  blocked = true;
3210
3334
  }
3211
3335
  const windows = usage ? strategy.findWindowLimits(usage, args.rankingContext) : undefined;
@@ -3269,7 +3393,7 @@ export class AuthStorage {
3269
3393
  // with the most headroom proactively and fall back intelligently when rate-limited.
3270
3394
  const sessionPreferredIsAvailable =
3271
3395
  sessionPreferredIndex !== undefined &&
3272
- !this.#isCredentialBlocked(providerKey, sessionPreferredIndex, blockScope);
3396
+ !this.#isCredentialBlocked(provider, providerKey, sessionPreferredIndex, blockScope);
3273
3397
  const shouldRank = checkUsage && (!sessionPreferredIsAvailable || requiresProModel);
3274
3398
  const rankingOrder = shouldRank && sessionId ? credentials.map((_credential, index) => index) : order;
3275
3399
  const candidates = shouldRank
@@ -3292,7 +3416,7 @@ export class AuthStorage {
3292
3416
  if (sessionPreferredIndex !== undefined && !requiresProModel) {
3293
3417
  const sessionPreferredCandidate = candidates.findIndex(
3294
3418
  candidate =>
3295
- !this.#isCredentialBlocked(providerKey, candidate.selection.index, blockScope) &&
3419
+ !this.#isCredentialBlocked(provider, providerKey, candidate.selection.index, blockScope) &&
3296
3420
  candidate.selection.index === sessionPreferredIndex,
3297
3421
  );
3298
3422
  if (sessionPreferredCandidate > 0) {
@@ -3397,7 +3521,7 @@ export class AuthStorage {
3397
3521
  if (resolved) return resolved;
3398
3522
  }
3399
3523
 
3400
- if (fallback && this.#isCredentialBlocked(providerKey, fallback.selection.index, blockScope)) {
3524
+ if (fallback && this.#isCredentialBlocked(provider, providerKey, fallback.selection.index, blockScope)) {
3401
3525
  return this.#tryOAuthCredential(provider, fallback.selection, providerKey, sessionId, options, {
3402
3526
  checkUsage,
3403
3527
  allowBlocked: true,
@@ -3560,7 +3684,7 @@ export class AuthStorage {
3560
3684
  blockScope,
3561
3685
  allowFallback = true,
3562
3686
  } = usageOptions;
3563
- if (!allowBlocked && this.#isCredentialBlocked(providerKey, selection.index, blockScope)) {
3687
+ if (!allowBlocked && this.#isCredentialBlocked(provider, providerKey, selection.index, blockScope)) {
3564
3688
  return undefined;
3565
3689
  }
3566
3690
 
@@ -3597,6 +3721,7 @@ export class AuthStorage {
3597
3721
  if (this.#isUsageLimitReached(scopedLimits)) {
3598
3722
  const resetAtMs = this.#getUsageResetAtMs(scopedLimits, Date.now());
3599
3723
  this.#markCredentialBlocked(
3724
+ provider,
3600
3725
  providerKey,
3601
3726
  selection.index,
3602
3727
  resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs,
@@ -3673,6 +3798,7 @@ export class AuthStorage {
3673
3798
  if (this.#isUsageLimitReached(scopedLimits)) {
3674
3799
  const resetAtMs = this.#getUsageResetAtMs(scopedLimits, Date.now());
3675
3800
  this.#markCredentialBlocked(
3801
+ provider,
3676
3802
  providerKey,
3677
3803
  selection.index,
3678
3804
  resetAtMs ?? Date.now() + AuthStorage.#defaultBackoffMs,
@@ -3749,7 +3875,7 @@ export class AuthStorage {
3749
3875
  }
3750
3876
  } else {
3751
3877
  // Block temporarily for transient failures (5 minutes)
3752
- this.#markCredentialBlocked(providerKey, selection.index, Date.now() + 5 * 60 * 1000);
3878
+ this.#markCredentialBlocked(provider, providerKey, selection.index, Date.now() + 5 * 60 * 1000);
3753
3879
  }
3754
3880
  }
3755
3881
 
@@ -4148,6 +4274,15 @@ export class AuthStorage {
4148
4274
  * that `markUsageLimitReached` set for the now-obsolete reset time.
4149
4275
  */
4150
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
+
4151
4286
  const index = this.#getStoredCredentials(provider).findIndex(entry => entry.id === credentialId);
4152
4287
  if (index < 0) return;
4153
4288
  const providerKey = this.#getProviderTypeKey(provider, "oauth");
@@ -4207,6 +4342,7 @@ export class AuthStorage {
4207
4342
 
4208
4343
  this.#clearSessionCredential(provider, sessionId);
4209
4344
  this.#markCredentialBlocked(
4345
+ provider,
4210
4346
  this.#getProviderTypeKey(provider, matched.type),
4211
4347
  matched.index,
4212
4348
  Date.now() + AuthStorage.#defaultBackoffMs,
@@ -4269,11 +4405,16 @@ export class AuthStorage {
4269
4405
  (credential, index) =>
4270
4406
  credential.type === sessionCredential.type &&
4271
4407
  index !== sessionCredential.index &&
4272
- !this.#isCredentialBlocked(providerKey, index),
4408
+ !this.#isCredentialBlocked(provider, providerKey, index),
4273
4409
  );
4274
4410
  const target = this.#getStoredCredentials(provider)[sessionCredential.index];
4275
4411
  this.#clearSessionCredential(provider, sessionId);
4276
- 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
+ );
4277
4418
 
4278
4419
  if (target) {
4279
4420
  const markSuspect = this.#store.markCredentialSuspect?.bind(this.#store);
@@ -4499,6 +4640,33 @@ export class AuthStorage {
4499
4640
  });
4500
4641
  }
4501
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
+
4502
4670
  /**
4503
4671
  * Describe where the active credential for a provider came from.
4504
4672
  *
@@ -4564,13 +4732,20 @@ type AuthRow = {
4564
4732
  identity_key: string | null;
4565
4733
  };
4566
4734
 
4735
+ type CredentialBlockRow = {
4736
+ credential_id: number;
4737
+ provider_key: string;
4738
+ block_scope: string;
4739
+ blocked_until_ms: number;
4740
+ };
4741
+
4567
4742
  type SerializedCredentialRecord = {
4568
4743
  credentialType: AuthCredential["type"];
4569
4744
  data: string;
4570
4745
  identityKey: string | null;
4571
4746
  };
4572
4747
 
4573
- const AUTH_SCHEMA_VERSION = 4;
4748
+ const AUTH_SCHEMA_VERSION = 5;
4574
4749
  const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
4575
4750
 
4576
4751
  /**
@@ -4770,6 +4945,11 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4770
4945
  #getCacheIncludingExpiredStmt: Statement;
4771
4946
  #upsertCacheStmt: Statement;
4772
4947
  #deleteExpiredCacheStmt: Statement;
4948
+ #getCredentialBlockStmt: Statement;
4949
+ #listCredentialBlocksByCredentialStmt: Statement;
4950
+ #upsertCredentialBlockStmt: Statement;
4951
+ #deleteCredentialBlocksStmt: Statement;
4952
+ #deleteExpiredCredentialBlocksStmt: Statement;
4773
4953
  #insertUsageHistoryStmt: Statement;
4774
4954
  #insertUsageCostStmt: Statement;
4775
4955
  #listUsageCostsStmt: Statement;
@@ -4815,6 +4995,23 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4815
4995
  "INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
4816
4996
  );
4817
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
+ );
4818
5015
  this.#insertUsageHistoryStmt = this.#db.prepare(
4819
5016
  "INSERT INTO usage_history (recorded_at, provider, account_key, email, account_id, limit_id, label, window_label, used_fraction, status, resets_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
4820
5017
  );
@@ -4926,6 +5123,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4926
5123
 
4927
5124
  if (!this.#authCredentialsTableExists()) {
4928
5125
  this.#createAuthCredentialsTable();
5126
+ this.#createAuthCredentialBlocksTable();
4929
5127
  this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION);
4930
5128
  return;
4931
5129
  }
@@ -4942,6 +5140,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4942
5140
  }
4943
5141
 
4944
5142
  this.#createAuthCredentialIndexes();
5143
+ this.#createAuthCredentialBlocksTable();
4945
5144
  this.#backfillCredentialIdentityKeys();
4946
5145
  // Rewriting an already-current version row is a no-op write transaction
4947
5146
  // on every boot; only persist when the recorded version actually changes.
@@ -5025,6 +5224,20 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5025
5224
  `);
5026
5225
  }
5027
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
+
5028
5241
  #migrateAuthSchema(fromVersion: number): void {
5029
5242
  if (fromVersion < 1) {
5030
5243
  this.#migrateAuthSchemaV0ToV1();
@@ -5035,6 +5248,9 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5035
5248
  if (fromVersion < 4) {
5036
5249
  this.#migrateAuthSchemaV3ToV4();
5037
5250
  }
5251
+ if (fromVersion < 5) {
5252
+ this.#migrateAuthSchemaV4ToV5();
5253
+ }
5038
5254
  }
5039
5255
 
5040
5256
  #migrateAuthSchemaV0ToV1(): void {
@@ -5121,6 +5337,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5121
5337
  migrate();
5122
5338
  }
5123
5339
 
5340
+ #migrateAuthSchemaV4ToV5(): void {
5341
+ const migrate = this.#db.transaction(() => {
5342
+ this.#createAuthCredentialBlocksTable();
5343
+ });
5344
+ migrate();
5345
+ }
5346
+
5124
5347
  #backfillCredentialIdentityKeys(): void {
5125
5348
  const selectRowsStmt = this.#db.prepare(
5126
5349
  "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
@@ -5386,6 +5609,54 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5386
5609
  }
5387
5610
  }
5388
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
+
5389
5660
  recordUsageSnapshots(entries: UsageHistoryEntry[]): void {
5390
5661
  try {
5391
5662
  for (const entry of entries) {
@@ -5579,6 +5850,11 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5579
5850
  this.#getCacheIncludingExpiredStmt.finalize();
5580
5851
  this.#upsertCacheStmt.finalize();
5581
5852
  this.#deleteExpiredCacheStmt.finalize();
5853
+ this.#getCredentialBlockStmt.finalize();
5854
+ this.#listCredentialBlocksByCredentialStmt.finalize();
5855
+ this.#upsertCredentialBlockStmt.finalize();
5856
+ this.#deleteCredentialBlocksStmt.finalize();
5857
+ this.#deleteExpiredCredentialBlocksStmt.finalize();
5582
5858
  this.#insertUsageHistoryStmt.finalize();
5583
5859
  this.#lastUsageHistoryStmt.finalize();
5584
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
  }
@@ -3311,7 +3311,12 @@ export function convertOpenAICodexResponsesTools(
3311
3311
  name: tool.name,
3312
3312
  description: tool.description || "",
3313
3313
  parameters,
3314
- ...(effectiveStrict && { strict: true }),
3314
+ // See openai-responses.ts::convertTools — explicit `strict: false` is
3315
+ // preserved on the wire because some backends distinguish it from
3316
+ // omitted (#4336). `strict: true` still requires enforcement success,
3317
+ // and the `PI_NO_STRICT` global bypass MUST suppress the flag entirely
3318
+ // so Codex proxies that reject the `strict` key stay silent.
3319
+ ...(effectiveStrict ? { strict: true } : !NO_STRICT && tool.strict === false ? { strict: false } : {}),
3315
3320
  };
3316
3321
  });
3317
3322
  }
@@ -2115,6 +2115,17 @@ function convertTools(
2115
2115
  return {
2116
2116
  tools: adaptedTools.map(({ tool, baseParameters, parameters, strict }) => {
2117
2117
  const includeStrict = toolStrictMode === "all_strict" || (toolStrictMode === "mixed" && strict);
2118
+ // `strict: false` is semantically distinct from omitted `strict` on some
2119
+ // backends: with it absent, optional properties may be over-filled with
2120
+ // placeholder values (#4336). Preserve the author's explicit `false`,
2121
+ // but only in "mixed" mode against a provider that understands the
2122
+ // field — the `all_strict → none` collapse and `supportsStrictMode:
2123
+ // false` paths deliberately keep the wire flag uniformly absent.
2124
+ const includeExplicitFalse =
2125
+ !includeStrict &&
2126
+ tool.strict === false &&
2127
+ toolStrictMode === "mixed" &&
2128
+ compat.supportsStrictMode !== false;
2118
2129
  const wireParameters = includeStrict ? parameters : baseParameters;
2119
2130
  return {
2120
2131
  type: "function",
@@ -2128,7 +2139,7 @@ function convertTools(
2128
2139
  ? (normalizeSchemaForMoonshot(wireParameters) as Record<string, unknown>)
2129
2140
  : wireParameters,
2130
2141
  // Only include strict if provider supports it. Some reject unknown fields.
2131
- ...(includeStrict && { strict: true }),
2142
+ ...(includeStrict ? { strict: true } : includeExplicitFalse ? { strict: false } : {}),
2132
2143
  },
2133
2144
  };
2134
2145
  }),
@@ -987,7 +987,16 @@ export function convertTools(
987
987
  name: tool.name,
988
988
  description: tool.description || "",
989
989
  parameters,
990
- ...(effectiveStrict && { strict: true }),
990
+ // `strict: false` and an omitted `strict` are NOT equivalent for every
991
+ // OpenAI-compat backend — some over-fill optional args when the flag is
992
+ // absent (#4336). Preserve the author's explicit `false` only while the
993
+ // Responses strict field is enabled; compatibility disables and
994
+ // strict-schema fallback retries rely on uniformly absent flags.
995
+ ...(effectiveStrict
996
+ ? { strict: true }
997
+ : !NO_STRICT && strictMode && tool.strict === false
998
+ ? { strict: false }
999
+ : {}),
991
1000
  } as OpenAITool);
992
1001
  }
993
1002
  return out;