@oh-my-pi/pi-ai 17.1.8 → 17.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/types/auth-broker/server.d.ts +5 -0
  3. package/dist/types/auth-broker/types.d.ts +4 -0
  4. package/dist/types/auth-storage.d.ts +26 -1
  5. package/dist/types/error/classes.d.ts +5 -1
  6. package/dist/types/error/rate-limit.d.ts +5 -4
  7. package/dist/types/providers/anthropic-client.d.ts +13 -1
  8. package/dist/types/providers/anthropic.d.ts +9 -1
  9. package/dist/types/providers/cursor/exec-modern.d.ts +98 -0
  10. package/dist/types/providers/cursor-pi-args.d.ts +99 -0
  11. package/dist/types/providers/cursor.d.ts +53 -3
  12. package/dist/types/providers/error-message.d.ts +2 -4
  13. package/dist/types/providers/openai-codex-responses.d.ts +10 -0
  14. package/dist/types/providers/openai-responses-wire.d.ts +8 -0
  15. package/dist/types/providers/openai-shared.d.ts +13 -1
  16. package/dist/types/registry/exa.d.ts +8 -0
  17. package/dist/types/registry/registry.d.ts +7 -1
  18. package/dist/types/registry/xai.d.ts +4 -1
  19. package/dist/types/stream.d.ts +2 -0
  20. package/dist/types/types.d.ts +101 -1
  21. package/dist/types/usage/umans.d.ts +2 -0
  22. package/dist/types/utils/block-symbols.d.ts +11 -0
  23. package/dist/types/utils/harmony-leak.d.ts +15 -7
  24. package/dist/types/utils/schema/normalize.d.ts +1 -0
  25. package/package.json +4 -4
  26. package/src/auth-broker/client.ts +5 -1
  27. package/src/auth-broker/server.ts +103 -9
  28. package/src/auth-broker/snapshot-cache.ts +12 -3
  29. package/src/auth-broker/types.ts +6 -0
  30. package/src/auth-storage.ts +439 -28
  31. package/src/error/classes.ts +98 -3
  32. package/src/error/flags.ts +6 -1
  33. package/src/error/rate-limit.ts +18 -12
  34. package/src/providers/amazon-bedrock.ts +3 -3
  35. package/src/providers/anthropic-client.ts +24 -2
  36. package/src/providers/anthropic.ts +31 -6
  37. package/src/providers/cursor/exec-modern.ts +495 -0
  38. package/src/providers/cursor/proto/agent.proto +1007 -0
  39. package/src/providers/cursor-pi-args.ts +135 -0
  40. package/src/providers/cursor.ts +1138 -66
  41. package/src/providers/devin.ts +2 -1
  42. package/src/providers/error-message.ts +3 -1
  43. package/src/providers/openai-codex-responses.ts +106 -26
  44. package/src/providers/openai-completions.ts +19 -4
  45. package/src/providers/openai-responses-wire.ts +8 -0
  46. package/src/providers/openai-responses.ts +12 -1
  47. package/src/providers/openai-shared.ts +75 -12
  48. package/src/registry/api-key-validation.ts +18 -34
  49. package/src/registry/exa.ts +19 -0
  50. package/src/registry/novita.ts +6 -3
  51. package/src/registry/registry.ts +3 -1
  52. package/src/registry/xai.ts +17 -1
  53. package/src/stream.ts +1 -2
  54. package/src/types.ts +115 -0
  55. package/src/usage/umans.ts +192 -0
  56. package/src/utils/block-symbols.ts +12 -0
  57. package/src/utils/harmony-leak.ts +32 -0
  58. package/src/utils/idle-iterator.ts +2 -2
  59. package/src/utils/schema/normalize.ts +140 -38
@@ -65,6 +65,7 @@ import {
65
65
  } from "./usage/openai-codex-reset";
66
66
  import { opencodeGoUsageProvider } from "./usage/opencode-go";
67
67
  import { syntheticUsageProvider } from "./usage/synthetic";
68
+ import { umansUsageProvider } from "./usage/umans";
68
69
  import { xaiOauthUsageProvider } from "./usage/xai-oauth";
69
70
  import { zaiRankingStrategy, zaiUsageProvider } from "./usage/zai";
70
71
 
@@ -373,6 +374,13 @@ export interface CredentialRefreshLeaseFence {
373
374
 
374
375
  export interface AuthCredentialStore {
375
376
  close(): void;
377
+ /**
378
+ * Stateful probe for commits made by another process to the backing store.
379
+ * Returns true once per observed change.
380
+ */
381
+ pollExternalChanges?(): boolean;
382
+ /** Record the current auth revision after a local mutation already notified consumers. */
383
+ acknowledgeLocalChanges?(): void;
376
384
  /** Optional hook to notify the underlying store that usage report cache is stale. */
377
385
  invalidateUsageCache?(signal?: AbortSignal): Promise<void>;
378
386
  listAuthCredentials(provider?: string): StoredAuthCredential[];
@@ -653,6 +661,7 @@ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [
653
661
  ollamaCloudUsageProvider,
654
662
  claudeUsageProvider,
655
663
  zaiUsageProvider,
664
+ umansUsageProvider,
656
665
  opencodeGoUsageProvider,
657
666
  githubCopilotUsageProvider,
658
667
  cursorUsageProvider,
@@ -1346,6 +1355,19 @@ export class AuthStorage {
1346
1355
  return this.#generation;
1347
1356
  }
1348
1357
 
1358
+ /**
1359
+ * Reload state after another process commits to the backing store, then
1360
+ * notify snapshot consumers even when only credential blocks changed.
1361
+ */
1362
+ async pollExternalChanges(): Promise<boolean> {
1363
+ const pollExternalChanges = this.#store.pollExternalChanges?.bind(this.#store);
1364
+ if (!pollExternalChanges?.()) return false;
1365
+ const previousGeneration = this.#generation;
1366
+ await this.reload();
1367
+ if (this.#generation === previousGeneration) this.#bumpGeneration("external-store");
1368
+ return true;
1369
+ }
1370
+
1349
1371
  onGenerationChanged(listener: (generation: number) => void): () => void {
1350
1372
  this.#generationListeners.add(listener);
1351
1373
  return () => {
@@ -1359,6 +1381,7 @@ export class AuthStorage {
1359
1381
 
1360
1382
  #bumpGeneration(reason: string): void {
1361
1383
  this.#generation += 1;
1384
+ this.#store.acknowledgeLocalChanges?.();
1362
1385
  for (const listener of [...this.#generationListeners]) {
1363
1386
  try {
1364
1387
  listener(this.#generation);
@@ -1788,15 +1811,20 @@ export class AuthStorage {
1788
1811
  }
1789
1812
  }
1790
1813
 
1791
- /** Records which credential was used for a session (for rate-limit switching). */
1814
+ /**
1815
+ * Records which credential was used for a session (for rate-limit switching).
1816
+ * `lastUsedAtMs` backdates the sticky (session-file pin restores on resume);
1817
+ * it defaults to now for live selections.
1818
+ */
1792
1819
  #recordSessionCredential(
1793
1820
  provider: string,
1794
1821
  sessionId: string | undefined,
1795
1822
  type: AuthCredential["type"],
1796
1823
  index: number,
1824
+ lastUsedAtMs?: number,
1797
1825
  ): void {
1798
1826
  if (!sessionId) return;
1799
- const nowMs = Date.now();
1827
+ const nowMs = lastUsedAtMs ?? Date.now();
1800
1828
  const sessionMap = this.#sessionLastCredential.get(provider) ?? new Map();
1801
1829
  sessionMap.set(sessionId, { type, index, lastUsedAtMs: nowMs });
1802
1830
  this.#sessionLastCredential.set(provider, sessionMap);
@@ -1994,7 +2022,6 @@ export class AuthStorage {
1994
2022
  const windows = usage ? strategy.findWindowLimits(usage, args.rankingContext) : undefined;
1995
2023
  const primary = windows?.primary;
1996
2024
  const secondary = windows?.secondary;
1997
- const secondaryTarget = secondary ?? primary;
1998
2025
  ranked.push({
1999
2026
  selection,
2000
2027
  usage,
@@ -2003,9 +2030,9 @@ export class AuthStorage {
2003
2030
  blockedUntil,
2004
2031
  hasPriorityBoost: strategy.hasPriorityBoost?.(primary) ?? false,
2005
2032
  planPriority: 0,
2006
- secondaryUsed: this.#normalizeUsageFraction(secondaryTarget),
2033
+ secondaryUsed: this.#normalizeUsageFraction(secondary),
2007
2034
  secondaryRequiredDrain: this.#computeWindowRequiredDrain(
2008
- secondaryTarget,
2035
+ secondary,
2009
2036
  nowMs,
2010
2037
  strategy.windowDefaults.secondaryMs,
2011
2038
  ),
@@ -4205,7 +4232,10 @@ export class AuthStorage {
4205
4232
  let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
4206
4233
 
4207
4234
  if (credentialType === "oauth" && target.credential.type === "oauth" && strategy) {
4208
- const report = await this.#getUsageReport(provider, target.credential, options);
4235
+ const report = await raceUsageWithSignal(
4236
+ this.#getUsageReport(provider, target.credential, options),
4237
+ options?.signal,
4238
+ );
4209
4239
  if (report) {
4210
4240
  const scopedLimits = this.#getScopedUsageLimits(strategy, report, rankingContext);
4211
4241
  if (this.#isUsageLimitReached(scopedLimits)) {
@@ -4216,6 +4246,7 @@ export class AuthStorage {
4216
4246
  }
4217
4247
  }
4218
4248
  }
4249
+ options?.signal?.throwIfAborted();
4219
4250
 
4220
4251
  // Usage lookup may refresh, disable, or remove a row. Re-resolve its
4221
4252
  // durable id before applying positional in-memory and persisted blocks.
@@ -4451,7 +4482,6 @@ export class AuthStorage {
4451
4482
  const windows = usage ? strategy.findWindowLimits(usage, args.rankingContext) : undefined;
4452
4483
  const primary = windows?.primary;
4453
4484
  const secondary = windows?.secondary;
4454
- const secondaryTarget = secondary ?? primary;
4455
4485
  ranked.push({
4456
4486
  selection,
4457
4487
  usage,
@@ -4460,9 +4490,9 @@ export class AuthStorage {
4460
4490
  blockedUntil,
4461
4491
  hasPriorityBoost: strategy.hasPriorityBoost?.(primary) ?? false,
4462
4492
  planPriority: getOpenAICodexPlanPriority(usage, args.planRequirement),
4463
- secondaryUsed: this.#normalizeUsageFraction(secondaryTarget),
4493
+ secondaryUsed: this.#normalizeUsageFraction(secondary),
4464
4494
  secondaryRequiredDrain: this.#computeWindowRequiredDrain(
4465
- secondaryTarget,
4495
+ secondary,
4466
4496
  nowMs,
4467
4497
  strategy.windowDefaults.secondaryMs,
4468
4498
  ),
@@ -5378,8 +5408,18 @@ export class AuthStorage {
5378
5408
  * The durable credential id keeps the pin stable across credential refreshes,
5379
5409
  * storage reordering, and process restarts. Normal auth retry and usage-limit
5380
5410
  * handling may still route around an unavailable account.
5411
+ *
5412
+ * `options.lastUsedAtMs` backdates the sticky's last-use timestamp so a pin
5413
+ * restored from a persisted session keeps the provider's warm-window
5414
+ * semantics: a resume inside the prompt-cache TTL reuses the account, a
5415
+ * stale resume still re-ranks.
5381
5416
  */
5382
- pinSessionOAuthAccount(provider: string, sessionId: string, credentialId: number): boolean {
5417
+ pinSessionOAuthAccount(
5418
+ provider: string,
5419
+ sessionId: string,
5420
+ credentialId: number,
5421
+ options?: { lastUsedAtMs?: number },
5422
+ ): boolean {
5383
5423
  if (!sessionId || this.#runtimeOverrides.has(provider) || this.#configOverrides.has(provider)) {
5384
5424
  return false;
5385
5425
  }
@@ -5387,7 +5427,7 @@ export class AuthStorage {
5387
5427
  const index = stored.findIndex(entry => entry.id === credentialId);
5388
5428
  const target = stored[index];
5389
5429
  if (target?.credential.type !== "oauth") return false;
5390
- this.#recordSessionCredential(provider, sessionId, "oauth", index);
5430
+ this.#recordSessionCredential(provider, sessionId, "oauth", index, options?.lastUsedAtMs);
5391
5431
  return true;
5392
5432
  }
5393
5433
 
@@ -6373,8 +6413,11 @@ type SerializedCredentialRecord = {
6373
6413
  identityKey: string | null;
6374
6414
  };
6375
6415
 
6376
- const AUTH_SCHEMA_VERSION = 6;
6416
+ const AUTH_SCHEMA_VERSION = 7;
6377
6417
  const SQLITE_NOW_EPOCH = "CAST(strftime('%s','now') AS INTEGER)";
6418
+ const LEGACY_CODEX_BLOCK_PROVIDER_KEY = "openai-codex:oauth";
6419
+ const LEGACY_CODEX_BLOCK_SCOPE = "shared";
6420
+ const CODEX_METER_BLOCK_SCOPES = ["chat", "spark"] as const;
6378
6421
 
6379
6422
  /**
6380
6423
  * SQLite's busy result code family — base `SQLITE_BUSY` plus the extended
@@ -6666,11 +6709,17 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6666
6709
  #lastUsageHistoryStmt: Statement;
6667
6710
  #listUsageHistoryStmt: Statement;
6668
6711
  #updateUsageHistoryStmt: Statement;
6712
+ #dataVersion: number;
6713
+ #authRevision: number;
6714
+ #localAuthRevision: number;
6669
6715
  #closed = false;
6670
6716
 
6671
6717
  constructor(db: Database) {
6672
6718
  this.#db = db;
6673
6719
  this.#initializeSchema();
6720
+ this.#dataVersion = this.#readDataVersion();
6721
+ this.#authRevision = this.#readAuthRevision();
6722
+ this.#localAuthRevision = this.#readLocalAuthRevision();
6674
6723
 
6675
6724
  this.#listActiveStmt = this.#db.prepare(
6676
6725
  "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE disabled_cause IS NULL ORDER BY id ASC",
@@ -6734,7 +6783,11 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6734
6783
  "SELECT blocked_until_ms, updated_at FROM auth_credential_blocks WHERE credential_id = ? AND provider_key = ? AND block_scope = ? AND blocked_until_ms > ?",
6735
6784
  );
6736
6785
  this.#listCredentialBlocksByCredentialStmt = this.#db.prepare(
6737
- "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",
6786
+ `SELECT credential_id, provider_key, block_scope, blocked_until_ms, updated_at
6787
+ FROM auth_credential_blocks
6788
+ WHERE credential_id = ? AND blocked_until_ms > ?
6789
+ AND NOT (provider_key = ? AND block_scope = ?)
6790
+ ORDER BY provider_key ASC, block_scope ASC`,
6738
6791
  );
6739
6792
  this.#upsertCredentialBlockStmt = this.#db.prepare(
6740
6793
  `INSERT INTO auth_credential_blocks (credential_id, provider_key, block_scope, blocked_until_ms, updated_at)
@@ -6915,6 +6968,8 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6915
6968
  this.#createAuthCredentialsTable();
6916
6969
  this.#createAuthCredentialBlocksTable();
6917
6970
  this.#createAuthCredentialRefreshLeasesTable();
6971
+ this.#createAuthCredentialBlockCompatibilityObjects();
6972
+ this.#createAuthChangeTrackingObjects();
6918
6973
  this.#writeAuthSchemaVersion(AUTH_SCHEMA_VERSION);
6919
6974
  return;
6920
6975
  }
@@ -6933,6 +6988,10 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
6933
6988
  this.#createAuthCredentialIndexes();
6934
6989
  this.#createAuthCredentialBlocksTable();
6935
6990
  this.#createAuthCredentialRefreshLeasesTable();
6991
+ if (schemaVersion <= AUTH_SCHEMA_VERSION) {
6992
+ this.#createAuthCredentialBlockCompatibilityObjects();
6993
+ }
6994
+ this.#createAuthChangeTrackingObjects();
6936
6995
  this.#backfillCredentialIdentityKeys();
6937
6996
  // Rewriting an already-current version row is a no-op write transaction
6938
6997
  // on every boot; only persist when the recorded version actually changes.
@@ -7030,6 +7089,208 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7030
7089
  `);
7031
7090
  }
7032
7091
 
7092
+ #createAuthChangeTrackingObjects(): void {
7093
+ this.#db.run(`
7094
+ CREATE TABLE IF NOT EXISTS auth_change_revision (
7095
+ id INTEGER PRIMARY KEY CHECK (id = 1),
7096
+ revision INTEGER NOT NULL
7097
+ );
7098
+ INSERT OR IGNORE INTO auth_change_revision (id, revision) VALUES (1, 0);
7099
+ CREATE TEMP TABLE IF NOT EXISTS auth_local_change_revision (
7100
+ id INTEGER PRIMARY KEY CHECK (id = 1),
7101
+ revision INTEGER NOT NULL
7102
+ );
7103
+ INSERT OR IGNORE INTO auth_local_change_revision (id, revision) VALUES (1, 0);
7104
+ `);
7105
+ for (const table of ["auth_credentials", "auth_credential_blocks"] as const) {
7106
+ for (const event of ["INSERT", "UPDATE", "DELETE"] as const) {
7107
+ this.#db.run(`
7108
+ CREATE TRIGGER IF NOT EXISTS auth_change_revision_${table}_${event.toLowerCase()}
7109
+ AFTER ${event} ON ${table}
7110
+ BEGIN
7111
+ UPDATE auth_change_revision SET revision = revision + 1 WHERE id = 1;
7112
+ END;
7113
+ `);
7114
+ this.#db.run(`
7115
+ CREATE TEMP TRIGGER IF NOT EXISTS auth_local_change_revision_${table}_${event.toLowerCase()}
7116
+ AFTER ${event} ON main.${table}
7117
+ BEGIN
7118
+ UPDATE auth_local_change_revision SET revision = revision + 1 WHERE id = 1;
7119
+ END;
7120
+ `);
7121
+ }
7122
+ }
7123
+ }
7124
+
7125
+ #createAuthCredentialBlockMirrorGuardTable(): void {
7126
+ this.#db.run(`
7127
+ CREATE TABLE IF NOT EXISTS auth_credential_block_mirror_guard (
7128
+ credential_id INTEGER PRIMARY KEY
7129
+ ) WITHOUT ROWID;
7130
+ `);
7131
+ }
7132
+
7133
+ /**
7134
+ * Keep a physical Codex `shared` row for pre-meter binaries that read this
7135
+ * database directly. Meter rows are canonical for current code. The guard
7136
+ * suppresses feedback while triggers update the compatibility projection.
7137
+ */
7138
+ #createAuthCredentialBlockCompatibilityTriggers(): void {
7139
+ for (const event of ["INSERT", "UPDATE"] as const) {
7140
+ const eventName = event.toLowerCase();
7141
+ this.#db.run(`
7142
+ CREATE TRIGGER IF NOT EXISTS auth_codex_shared_${eventName}_to_meters
7143
+ AFTER ${event} ON auth_credential_blocks
7144
+ WHEN NEW.provider_key = 'openai-codex:oauth'
7145
+ AND NEW.block_scope = 'shared'
7146
+ AND NOT EXISTS (
7147
+ SELECT 1 FROM auth_credential_block_mirror_guard
7148
+ WHERE credential_id = NEW.credential_id
7149
+ )
7150
+ BEGIN
7151
+ INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id)
7152
+ VALUES (NEW.credential_id);
7153
+ INSERT INTO auth_credential_blocks (
7154
+ credential_id,
7155
+ provider_key,
7156
+ block_scope,
7157
+ blocked_until_ms,
7158
+ updated_at
7159
+ )
7160
+ VALUES (
7161
+ NEW.credential_id,
7162
+ NEW.provider_key,
7163
+ 'chat',
7164
+ NEW.blocked_until_ms,
7165
+ NEW.updated_at
7166
+ )
7167
+ ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
7168
+ blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms),
7169
+ updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at);
7170
+ INSERT INTO auth_credential_blocks (
7171
+ credential_id,
7172
+ provider_key,
7173
+ block_scope,
7174
+ blocked_until_ms,
7175
+ updated_at
7176
+ )
7177
+ VALUES (
7178
+ NEW.credential_id,
7179
+ NEW.provider_key,
7180
+ 'spark',
7181
+ NEW.blocked_until_ms,
7182
+ NEW.updated_at
7183
+ )
7184
+ ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
7185
+ blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms),
7186
+ updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at);
7187
+ DELETE FROM auth_credential_block_mirror_guard
7188
+ WHERE credential_id = NEW.credential_id;
7189
+ END;
7190
+
7191
+ CREATE TRIGGER IF NOT EXISTS auth_codex_meter_${eventName}_to_shared
7192
+ AFTER ${event} ON auth_credential_blocks
7193
+ WHEN NEW.provider_key = 'openai-codex:oauth'
7194
+ AND NEW.block_scope IN ('chat', 'spark')
7195
+ AND NOT EXISTS (
7196
+ SELECT 1 FROM auth_credential_block_mirror_guard
7197
+ WHERE credential_id = NEW.credential_id
7198
+ )
7199
+ BEGIN
7200
+ INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id)
7201
+ VALUES (NEW.credential_id);
7202
+ DELETE FROM auth_credential_blocks
7203
+ WHERE credential_id = NEW.credential_id
7204
+ AND provider_key = NEW.provider_key
7205
+ AND block_scope = 'shared';
7206
+ INSERT INTO auth_credential_blocks (
7207
+ credential_id,
7208
+ provider_key,
7209
+ block_scope,
7210
+ blocked_until_ms,
7211
+ updated_at
7212
+ )
7213
+ SELECT
7214
+ NEW.credential_id,
7215
+ NEW.provider_key,
7216
+ 'shared',
7217
+ MAX(blocked_until_ms),
7218
+ MAX(updated_at)
7219
+ FROM auth_credential_blocks
7220
+ WHERE credential_id = NEW.credential_id
7221
+ AND provider_key = NEW.provider_key
7222
+ AND block_scope IN ('chat', 'spark')
7223
+ GROUP BY credential_id, provider_key;
7224
+ DELETE FROM auth_credential_block_mirror_guard
7225
+ WHERE credential_id = NEW.credential_id;
7226
+ END;
7227
+ `);
7228
+ }
7229
+
7230
+ this.#db.run(`
7231
+ CREATE TRIGGER IF NOT EXISTS auth_codex_shared_delete_to_meters
7232
+ AFTER DELETE ON auth_credential_blocks
7233
+ WHEN OLD.provider_key = 'openai-codex:oauth'
7234
+ AND OLD.block_scope = 'shared'
7235
+ AND NOT EXISTS (
7236
+ SELECT 1 FROM auth_credential_block_mirror_guard
7237
+ WHERE credential_id = OLD.credential_id
7238
+ )
7239
+ BEGIN
7240
+ INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id)
7241
+ VALUES (OLD.credential_id);
7242
+ DELETE FROM auth_credential_blocks
7243
+ WHERE credential_id = OLD.credential_id
7244
+ AND provider_key = OLD.provider_key
7245
+ AND block_scope IN ('chat', 'spark');
7246
+ DELETE FROM auth_credential_block_mirror_guard
7247
+ WHERE credential_id = OLD.credential_id;
7248
+ END;
7249
+
7250
+ CREATE TRIGGER IF NOT EXISTS auth_codex_meter_delete_to_shared
7251
+ AFTER DELETE ON auth_credential_blocks
7252
+ WHEN OLD.provider_key = 'openai-codex:oauth'
7253
+ AND OLD.block_scope IN ('chat', 'spark')
7254
+ AND NOT EXISTS (
7255
+ SELECT 1 FROM auth_credential_block_mirror_guard
7256
+ WHERE credential_id = OLD.credential_id
7257
+ )
7258
+ BEGIN
7259
+ INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id)
7260
+ VALUES (OLD.credential_id);
7261
+ DELETE FROM auth_credential_blocks
7262
+ WHERE credential_id = OLD.credential_id
7263
+ AND provider_key = OLD.provider_key
7264
+ AND block_scope = 'shared';
7265
+ INSERT INTO auth_credential_blocks (
7266
+ credential_id,
7267
+ provider_key,
7268
+ block_scope,
7269
+ blocked_until_ms,
7270
+ updated_at
7271
+ )
7272
+ SELECT
7273
+ OLD.credential_id,
7274
+ OLD.provider_key,
7275
+ 'shared',
7276
+ MAX(blocked_until_ms),
7277
+ MAX(updated_at)
7278
+ FROM auth_credential_blocks
7279
+ WHERE credential_id = OLD.credential_id
7280
+ AND provider_key = OLD.provider_key
7281
+ AND block_scope IN ('chat', 'spark')
7282
+ GROUP BY credential_id, provider_key;
7283
+ DELETE FROM auth_credential_block_mirror_guard
7284
+ WHERE credential_id = OLD.credential_id;
7285
+ END;
7286
+ `);
7287
+ }
7288
+
7289
+ #createAuthCredentialBlockCompatibilityObjects(): void {
7290
+ this.#createAuthCredentialBlockMirrorGuardTable();
7291
+ this.#createAuthCredentialBlockCompatibilityTriggers();
7292
+ }
7293
+
7033
7294
  #createAuthCredentialRefreshLeasesTable(): void {
7034
7295
  SqliteAuthCredentialStore.#ensureAuthCredentialRefreshLeasesTable(this.#db);
7035
7296
  }
@@ -7050,6 +7311,9 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7050
7311
  if (fromVersion < 6) {
7051
7312
  this.#migrateAuthSchemaV5ToV6();
7052
7313
  }
7314
+ if (fromVersion < 7) {
7315
+ this.#migrateAuthSchemaV6ToV7();
7316
+ }
7053
7317
  }
7054
7318
 
7055
7319
  #migrateAuthSchemaV0ToV1(): void {
@@ -7150,6 +7414,77 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7150
7414
  migrate();
7151
7415
  }
7152
7416
 
7417
+ #migrateAuthSchemaV6ToV7(): void {
7418
+ const migrate = this.#db.transaction(() => {
7419
+ this.#createAuthCredentialBlocksTable();
7420
+ this.#createAuthCredentialBlockMirrorGuardTable();
7421
+ this.#db.run(`
7422
+ DELETE FROM auth_credential_block_mirror_guard;
7423
+ INSERT OR IGNORE INTO auth_credential_block_mirror_guard (credential_id)
7424
+ SELECT DISTINCT credential_id
7425
+ FROM auth_credential_blocks
7426
+ WHERE provider_key = 'openai-codex:oauth'
7427
+ AND block_scope IN ('chat', 'spark', 'shared');
7428
+
7429
+ INSERT INTO auth_credential_blocks (
7430
+ credential_id,
7431
+ provider_key,
7432
+ block_scope,
7433
+ blocked_until_ms,
7434
+ updated_at
7435
+ )
7436
+ SELECT credential_id, provider_key, 'chat', blocked_until_ms, updated_at
7437
+ FROM auth_credential_blocks
7438
+ WHERE provider_key = 'openai-codex:oauth'
7439
+ AND block_scope = 'shared'
7440
+ ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
7441
+ blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms),
7442
+ updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at);
7443
+
7444
+ INSERT INTO auth_credential_blocks (
7445
+ credential_id,
7446
+ provider_key,
7447
+ block_scope,
7448
+ blocked_until_ms,
7449
+ updated_at
7450
+ )
7451
+ SELECT credential_id, provider_key, 'spark', blocked_until_ms, updated_at
7452
+ FROM auth_credential_blocks
7453
+ WHERE provider_key = 'openai-codex:oauth'
7454
+ AND block_scope = 'shared'
7455
+ ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
7456
+ blocked_until_ms = MAX(auth_credential_blocks.blocked_until_ms, excluded.blocked_until_ms),
7457
+ updated_at = MAX(auth_credential_blocks.updated_at, excluded.updated_at);
7458
+
7459
+ INSERT INTO auth_credential_blocks (
7460
+ credential_id,
7461
+ provider_key,
7462
+ block_scope,
7463
+ blocked_until_ms,
7464
+ updated_at
7465
+ )
7466
+ SELECT
7467
+ credential_id,
7468
+ provider_key,
7469
+ 'shared',
7470
+ MAX(blocked_until_ms),
7471
+ MAX(updated_at)
7472
+ FROM auth_credential_blocks
7473
+ WHERE provider_key = 'openai-codex:oauth'
7474
+ AND block_scope IN ('chat', 'spark')
7475
+ GROUP BY credential_id, provider_key
7476
+ ON CONFLICT(credential_id, provider_key, block_scope) DO UPDATE SET
7477
+ blocked_until_ms = excluded.blocked_until_ms,
7478
+ updated_at = excluded.updated_at;
7479
+
7480
+ DELETE FROM auth_credential_block_mirror_guard;
7481
+ `);
7482
+ this.#createAuthCredentialBlockCompatibilityTriggers();
7483
+ this.#writeAuthSchemaVersion(7);
7484
+ });
7485
+ migrate.immediate();
7486
+ }
7487
+
7153
7488
  #backfillCredentialIdentityKeys(): void {
7154
7489
  const selectRowsStmt = this.#db.prepare(
7155
7490
  "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
@@ -7418,7 +7753,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7418
7753
  id,
7419
7754
  expectedData,
7420
7755
  ) as { changes: number });
7421
- if (result.changes !== 1) return false;
7756
+ if (result.changes === 0) return false;
7422
7757
  if (provider) {
7423
7758
  this.#purgeSupersededDisabledRows(provider, this.listAuthCredentials(provider));
7424
7759
  }
@@ -7457,7 +7792,7 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7457
7792
  : (this.#deleteIfMatchesStmt.run(normalizeDisabledCause(disabledCause), id, expectedData) as {
7458
7793
  changes: number;
7459
7794
  });
7460
- return result.changes === 1;
7795
+ return result.changes > 0;
7461
7796
  }
7462
7797
  deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void {
7463
7798
  try {
@@ -7504,7 +7839,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7504
7839
 
7505
7840
  getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined {
7506
7841
  const nowMs = Date.now();
7507
- this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
7842
+ const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY;
7843
+ // Current callers use meter scopes. The physical shared row exists only
7844
+ // for direct SQLite readers from pre-meter releases.
7845
+ if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) {
7846
+ return undefined;
7847
+ }
7848
+ if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
7508
7849
  const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as
7509
7850
  | { blocked_until_ms?: number; updated_at?: number }
7510
7851
  | undefined;
@@ -7513,7 +7854,11 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7513
7854
 
7514
7855
  getCredentialBlockReconcileAfter(credentialId: number, providerKey: string, blockScope: string): number | undefined {
7515
7856
  const nowMs = Date.now();
7516
- this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
7857
+ const isCodexBlock = providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY;
7858
+ if (isCodexBlock && blockScope === LEGACY_CODEX_BLOCK_SCOPE) {
7859
+ return undefined;
7860
+ }
7861
+ if (!isCodexBlock) this.#deleteExpiredCredentialBlocksStmt.run(nowMs);
7517
7862
  const row = this.#getCredentialBlockStmt.get(credentialId, providerKey, blockScope, nowMs) as
7518
7863
  | { blocked_until_ms?: number; updated_at?: number }
7519
7864
  | undefined;
@@ -7527,16 +7872,33 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7527
7872
  }
7528
7873
 
7529
7874
  upsertCredentialBlock(block: StoredCredentialBlock): void {
7530
- this.#upsertCredentialBlockStmt.run(
7531
- block.credentialId,
7532
- block.providerKey,
7533
- block.blockScope,
7534
- block.blockedUntilMs,
7535
- );
7536
- this.#credentialBlockReconcileAfter.set(
7537
- `${block.credentialId}\0${block.providerKey}\0${block.blockScope}`,
7538
- Math.min(block.blockedUntilMs, Date.now() + USAGE_REPORT_TTL_MS),
7539
- );
7875
+ const isLegacyCodexBlock =
7876
+ block.providerKey === LEGACY_CODEX_BLOCK_PROVIDER_KEY && block.blockScope === LEGACY_CODEX_BLOCK_SCOPE;
7877
+ const blockScopes = isLegacyCodexBlock ? CODEX_METER_BLOCK_SCOPES : [block.blockScope];
7878
+ const upsert = this.#db.transaction(() => {
7879
+ for (const blockScope of blockScopes) {
7880
+ this.#upsertCredentialBlockStmt.run(
7881
+ block.credentialId,
7882
+ block.providerKey,
7883
+ blockScope,
7884
+ block.blockedUntilMs,
7885
+ );
7886
+ }
7887
+ });
7888
+ upsert.immediate();
7889
+
7890
+ const reconcileAfterMs = Math.min(block.blockedUntilMs, Date.now() + USAGE_REPORT_TTL_MS);
7891
+ for (const blockScope of blockScopes) {
7892
+ this.#credentialBlockReconcileAfter.set(
7893
+ `${block.credentialId}\0${block.providerKey}\0${blockScope}`,
7894
+ reconcileAfterMs,
7895
+ );
7896
+ }
7897
+ if (isLegacyCodexBlock) {
7898
+ this.#credentialBlockReconcileAfter.delete(
7899
+ `${block.credentialId}\0${block.providerKey}\0${LEGACY_CODEX_BLOCK_SCOPE}`,
7900
+ );
7901
+ }
7540
7902
  }
7541
7903
 
7542
7904
  deleteCredentialBlock(credentialId: number, providerKey: string, blockScope: string): void {
@@ -7567,7 +7929,12 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7567
7929
  for (const credentialId of credentialIds) {
7568
7930
  if (seenCredentialIds.has(credentialId)) continue;
7569
7931
  seenCredentialIds.add(credentialId);
7570
- const rows = this.#listCredentialBlocksByCredentialStmt.all(credentialId, nowMs) as CredentialBlockRow[];
7932
+ const rows = this.#listCredentialBlocksByCredentialStmt.all(
7933
+ credentialId,
7934
+ nowMs,
7935
+ LEGACY_CODEX_BLOCK_PROVIDER_KEY,
7936
+ LEGACY_CODEX_BLOCK_SCOPE,
7937
+ ) as CredentialBlockRow[];
7571
7938
  for (const row of rows) {
7572
7939
  blocks.push({
7573
7940
  credentialId: row.credential_id,
@@ -7894,6 +8261,50 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
7894
8261
  this.deleteAuthCredentialsForProvider(provider, "deleted by user");
7895
8262
  }
7896
8263
 
8264
+ /**
8265
+ * SQLite increments `data_version` when another connection commits. Own
8266
+ * writes leave it unchanged and already notify AuthStorage directly.
8267
+ */
8268
+ pollExternalChanges(): boolean {
8269
+ this.#acknowledgeLocalAuthChanges();
8270
+ const dataVersion = this.#readDataVersion();
8271
+ if (dataVersion === this.#dataVersion) return false;
8272
+ this.#dataVersion = dataVersion;
8273
+ const authRevision = this.#readAuthRevision();
8274
+ if (authRevision === this.#authRevision) return false;
8275
+ this.#authRevision = authRevision;
8276
+ return true;
8277
+ }
8278
+
8279
+ acknowledgeLocalChanges(): void {
8280
+ this.#acknowledgeLocalAuthChanges();
8281
+ }
8282
+
8283
+ #acknowledgeLocalAuthChanges(): void {
8284
+ const localAuthRevision = this.#readLocalAuthRevision();
8285
+ this.#authRevision += localAuthRevision - this.#localAuthRevision;
8286
+ this.#localAuthRevision = localAuthRevision;
8287
+ }
8288
+
8289
+ #readDataVersion(): number {
8290
+ const row = this.#db.query("PRAGMA data_version").get() as { data_version?: number } | null;
8291
+ return row?.data_version ?? 0;
8292
+ }
8293
+
8294
+ #readAuthRevision(): number {
8295
+ const row = this.#db.query("SELECT revision FROM auth_change_revision WHERE id = 1").get() as {
8296
+ revision?: number;
8297
+ } | null;
8298
+ return row?.revision ?? 0;
8299
+ }
8300
+
8301
+ #readLocalAuthRevision(): number {
8302
+ const row = this.#db.query("SELECT revision FROM auth_local_change_revision WHERE id = 1").get() as {
8303
+ revision?: number;
8304
+ } | null;
8305
+ return row?.revision ?? 0;
8306
+ }
8307
+
7897
8308
  close(): void {
7898
8309
  if (this.#closed) return;
7899
8310
  this.#closed = true;