@gajae-code/ai 0.13.2 → 0.14.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 (81) hide show
  1. package/CHANGELOG.md +61 -2
  2. package/dist/types/auth-broker/client.d.ts +9 -1
  3. package/dist/types/auth-broker/redact.d.ts +7 -0
  4. package/dist/types/auth-broker/remote-store.d.ts +50 -9
  5. package/dist/types/auth-broker/types.d.ts +14 -0
  6. package/dist/types/auth-broker/wire-schemas.d.ts +25 -0
  7. package/dist/types/auth-storage.d.ts +200 -6
  8. package/dist/types/core.d.ts +1 -0
  9. package/dist/types/model-cache.d.ts +4 -1
  10. package/dist/types/model-manager.d.ts +11 -0
  11. package/dist/types/provider-models/openai-compat.d.ts +5 -0
  12. package/dist/types/provider-models/special.d.ts +3 -0
  13. package/dist/types/providers/anthropic.d.ts +31 -0
  14. package/dist/types/providers/cursor.d.ts +9 -1
  15. package/dist/types/providers/kiro-codewhisperer.d.ts +8 -0
  16. package/dist/types/providers/mock.d.ts +8 -0
  17. package/dist/types/providers/register-builtins.d.ts +1 -0
  18. package/dist/types/providers/transform-messages.d.ts +18 -0
  19. package/dist/types/types.d.ts +34 -8
  20. package/dist/types/usage/grok-cli.d.ts +5 -0
  21. package/dist/types/usage.d.ts +6 -0
  22. package/dist/types/utils/discovery/openai-compatible.d.ts +5 -0
  23. package/dist/types/utils/event-stream.d.ts +4 -2
  24. package/dist/types/utils/fallback-transport.d.ts +10 -0
  25. package/dist/types/utils/http-inspector.d.ts +1 -0
  26. package/dist/types/utils/idle-iterator.d.ts +13 -1
  27. package/dist/types/utils/json-parse.d.ts +19 -0
  28. package/dist/types/utils/oauth/callback-server.d.ts +13 -0
  29. package/dist/types/utils/oauth/kiro.d.ts +71 -0
  30. package/dist/types/utils/oauth/types.d.ts +1 -1
  31. package/dist/types/utils/parse-bind.d.ts +8 -5
  32. package/dist/types/utils/tool-call-healing.d.ts +7 -0
  33. package/dist/types/utils/tool-choice-capability.d.ts +11 -0
  34. package/package.json +3 -2
  35. package/src/auth-broker/client.ts +30 -0
  36. package/src/auth-broker/redact.ts +15 -0
  37. package/src/auth-broker/refresher.ts +4 -2
  38. package/src/auth-broker/remote-store.ts +693 -70
  39. package/src/auth-broker/server.ts +57 -12
  40. package/src/auth-broker/types.ts +16 -0
  41. package/src/auth-broker/wire-schemas.ts +21 -0
  42. package/src/auth-gateway/server.ts +84 -19
  43. package/src/auth-storage.ts +985 -41
  44. package/src/core.ts +1 -0
  45. package/src/model-cache.ts +23 -4
  46. package/src/model-manager.ts +70 -11
  47. package/src/model-thinking.ts +45 -1
  48. package/src/models.json +9604 -1932
  49. package/src/openai-completions-compat.ts +2 -1
  50. package/src/provider-models/descriptors.ts +7 -1
  51. package/src/provider-models/openai-compat.ts +52 -28
  52. package/src/provider-models/special.ts +12 -0
  53. package/src/providers/amazon-bedrock.ts +2 -1
  54. package/src/providers/anthropic.ts +831 -27
  55. package/src/providers/cursor.ts +83 -3
  56. package/src/providers/kiro-codewhisperer.ts +572 -0
  57. package/src/providers/mock.ts +15 -2
  58. package/src/providers/ollama.ts +9 -2
  59. package/src/providers/openai-codex-responses.ts +16 -9
  60. package/src/providers/openai-completions.ts +6 -1
  61. package/src/providers/openai-responses-shared.ts +180 -18
  62. package/src/providers/register-builtins.ts +24 -2
  63. package/src/providers/transform-messages.ts +64 -1
  64. package/src/stream.ts +25 -2
  65. package/src/types.ts +36 -7
  66. package/src/usage/grok-cli.ts +86 -1
  67. package/src/usage.ts +7 -0
  68. package/src/utils/discovery/openai-compatible.ts +89 -4
  69. package/src/utils/event-stream.ts +11 -2
  70. package/src/utils/fallback-transport.ts +44 -2
  71. package/src/utils/http-inspector.ts +1 -0
  72. package/src/utils/idle-iterator.ts +29 -6
  73. package/src/utils/json-parse.ts +80 -0
  74. package/src/utils/oauth/callback-server.ts +31 -1
  75. package/src/utils/oauth/index.ts +14 -1
  76. package/src/utils/oauth/kiro.ts +448 -0
  77. package/src/utils/oauth/synthetic.ts +2 -3
  78. package/src/utils/oauth/types.ts +1 -0
  79. package/src/utils/parse-bind.ts +27 -0
  80. package/src/utils/tool-call-healing.ts +13 -2
  81. package/src/utils/tool-choice-capability.ts +386 -6
@@ -17,7 +17,7 @@
17
17
  * the end of a chunk is held back until the next chunk arrives.
18
18
  */
19
19
 
20
- import { parseJsonWithRepair } from "./json-parse";
20
+ import { findUnnecessaryUnicodeEscape, parseJsonWithRepair } from "./json-parse";
21
21
 
22
22
  const TOK_SECTION_BEGIN = "<|tool_calls_section_begin|>";
23
23
  const TOK_SECTION_END = "<|tool_calls_section_end|>";
@@ -34,6 +34,13 @@ export interface HealedToolCall {
34
34
  readonly id: string;
35
35
  readonly name: string;
36
36
  readonly arguments: string;
37
+ /**
38
+ * Whether the raw leaked payload spelled a printable non-ASCII character as a
39
+ * `\uXXXX` escape. Captured BEFORE the normalizing round-trip below, which
40
+ * decodes escapes into literal characters and would otherwise erase the only
41
+ * evidence that the text is unverifiable.
42
+ */
43
+ readonly escapedNonAsciiArguments: boolean;
37
44
  }
38
45
 
39
46
  /**
@@ -230,6 +237,10 @@ export class ToolCallHealer {
230
237
  const name = normalizeFunctionName(rawId);
231
238
  const id = generateHealedToolCallId();
232
239
 
240
+ // Sample the raw payload first: the round-trip below decodes `\uXXXX` into
241
+ // literal characters, so checking `argsJson` afterwards always reports clean.
242
+ const escapedNonAsciiArguments = findUnnecessaryUnicodeEscape(rawArgs) !== undefined;
243
+
233
244
  let argsJson = rawArgs;
234
245
  if (rawArgs.length > 0) {
235
246
  try {
@@ -242,7 +253,7 @@ export class ToolCallHealer {
242
253
  argsJson = "{}";
243
254
  }
244
255
 
245
- this.#completed.push({ id, name, arguments: argsJson });
256
+ this.#completed.push({ id, name, arguments: argsJson, escapedNonAsciiArguments });
246
257
  this.#inCall = false;
247
258
  this.#inArgs = false;
248
259
  this.#pendingId = "";
@@ -1,3 +1,8 @@
1
+ import { Database } from "bun:sqlite";
2
+ import * as crypto from "node:crypto";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { getToolChoiceCapabilityCachePath } from "@gajae-code/utils/dirs";
1
6
  import { extractHttpStatusFromError } from "@gajae-code/utils/fetch-retry";
2
7
  import * as logger from "@gajae-code/utils/logger";
3
8
  import type { Api, Model, ToolChoice, ToolChoiceCompat, ToolChoiceSupport, ToolChoiceSupportSource } from "../types";
@@ -11,6 +16,26 @@ const supportRank: Record<ToolChoiceSupport, number> = {
11
16
 
12
17
  const registry = new Map<string, ToolChoiceSupport>();
13
18
  const loggedRegistryKeys = new Set<string>();
19
+ const registryExpiresAt = new Map<string, number>();
20
+ const CACHE_SCHEMA_VERSION = 1;
21
+ const CACHE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
22
+ const EMPTY_CACHE_TTL_MS = 5 * 60 * 1000;
23
+ const CACHE_MAX_ENTRIES = 256;
24
+ const REGISTRY_MAX_ENTRIES = 256;
25
+ const CACHE_MUTATION_LOCK_STALE_MS = 5_000;
26
+
27
+ type NativeExactUnlinkBindings = Pick<typeof import("@gajae-code/natives"), "exactUnlink">;
28
+
29
+ let nativeExactUnlinkBindings: NativeExactUnlinkBindings | undefined;
30
+
31
+ let cachePathOverride: string | undefined;
32
+ let nowForTests: (() => number) | undefined;
33
+ let beforeExpiredDeleteForTests: (() => void) | undefined;
34
+ let beforeMalformedDeleteForTests: (() => void) | undefined;
35
+ let onCacheOpenForTests: (() => void) | undefined;
36
+ let simulateCacheOperationErrorForTests: (() => Error | undefined) | undefined;
37
+ let beforeCorruptRetireForTests: (() => void) | undefined;
38
+ let beforeLockExactUnlinkForTests: ((lockPath: string) => void) | undefined;
14
39
 
15
40
  /**
16
41
  * Claude Mythos accepts tools but rejects forced tool use (Anthropic 400:
@@ -45,21 +70,55 @@ export function toolChoiceRegistryKey(model: Model<Api>): string {
45
70
 
46
71
  /** Returns the current runtime tool-choice capability override for a model. */
47
72
  export function getToolChoiceCapabilityOverride(model: Model<Api>): ToolChoiceSupport | undefined {
48
- return registry.get(toolChoiceRegistryKey(model));
73
+ const key = toolChoiceRegistryKey(model);
74
+ hydrateToolChoiceCapability(key);
75
+ return registry.get(key);
49
76
  }
50
77
 
51
78
  /** Clears runtime tool-choice capability overrides for tests. */
52
79
  export function clearToolChoiceIncapabilityRegistryForTests(): void {
53
80
  registry.clear();
54
81
  loggedRegistryKeys.clear();
82
+ registryExpiresAt.clear();
83
+ }
84
+
85
+ /** Overrides durable-cache dependencies for isolated tests. */
86
+ export function configureToolChoiceCapabilityCacheForTests(options?: {
87
+ path?: string;
88
+ now?: () => number;
89
+ beforeExpiredDelete?: () => void;
90
+ beforeMalformedDelete?: () => void;
91
+ onCacheOpen?: () => void;
92
+ simulateOperationError?: () => Error | undefined;
93
+ beforeCorruptRetire?: () => void;
94
+ beforeLockExactUnlink?: (lockPath: string) => void;
95
+ }): void {
96
+ cachePathOverride = options?.path;
97
+ nowForTests = options?.now;
98
+ beforeExpiredDeleteForTests = options?.beforeExpiredDelete;
99
+ beforeMalformedDeleteForTests = options?.beforeMalformedDelete;
100
+ onCacheOpenForTests = options?.onCacheOpen;
101
+ simulateCacheOperationErrorForTests = options?.simulateOperationError;
102
+ beforeCorruptRetireForTests = options?.beforeCorruptRetire;
103
+ beforeLockExactUnlinkForTests = options?.beforeLockExactUnlink;
104
+ clearToolChoiceIncapabilityRegistryForTests();
55
105
  }
56
106
 
57
107
  /** Records a discovered maximum supported tool-choice level for a model. */
58
108
  export function markToolChoiceIncapability(model: Model<Api>, maxSupport: ToolChoiceSupport, reason?: string): void {
59
109
  const key = toolChoiceRegistryKey(model);
60
- const existing = registry.get(key);
61
- const next = existing && supportRank[existing] < supportRank[maxSupport] ? existing : maxSupport;
62
- registry.set(key, next);
110
+ const releaseMutationLock = acquireCapabilityCacheMutationLock();
111
+ try {
112
+ hydrateToolChoiceCapability(key);
113
+ const existing = registry.get(key);
114
+ const next = existing && supportRank[existing] < supportRank[maxSupport] ? existing : maxSupport;
115
+ registry.set(key, next);
116
+ const persisted = persistToolChoiceCapability(key, next);
117
+ if (persisted) registry.set(key, persisted.support);
118
+ registryExpiresAt.set(key, (persisted?.observedAt ?? currentTime()) + CACHE_TTL_MS);
119
+ } finally {
120
+ releaseMutationLock?.();
121
+ }
63
122
 
64
123
  if (!loggedRegistryKeys.has(key)) {
65
124
  loggedRegistryKeys.add(key);
@@ -74,6 +133,100 @@ export function markToolChoiceIncapability(model: Model<Api>, maxSupport: ToolCh
74
133
  }
75
134
  }
76
135
 
136
+ function acquireCapabilityCacheMutationLock(): (() => void) | undefined {
137
+ if (process.env.NODE_ENV === "test" && cachePathOverride === undefined) return;
138
+ const cachePath = cachePathOverride ?? getToolChoiceCapabilityCachePath();
139
+ const lockPath = `${cachePath}.mutation.lock`;
140
+ const sleeper = new Int32Array(new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT));
141
+ const owner = `${process.pid}:${crypto.randomUUID()}`;
142
+ while (true) {
143
+ try {
144
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true, mode: 0o700 });
145
+ const descriptor = fs.openSync(lockPath, "wx", 0o600);
146
+ fs.writeFileSync(descriptor, owner);
147
+ fs.closeSync(descriptor);
148
+ return () => {
149
+ try {
150
+ exactUnlinkCapabilityLock(lockPath, owner);
151
+ } catch {
152
+ // A crashed owner was already reaped.
153
+ }
154
+ };
155
+ } catch (error) {
156
+ if (!(error && typeof error === "object" && (error as { code?: unknown }).code === "EEXIST")) return;
157
+ try {
158
+ const lockOwner = fs.readFileSync(lockPath, "utf8");
159
+ const ownerPid = Number(lockOwner.split(":", 1)[0]);
160
+ const ownerIsAlive = Number.isSafeInteger(ownerPid) && ownerPid > 0 && isProcessAlive(ownerPid);
161
+ const staleOwner =
162
+ !ownerIsAlive && Date.now() - fs.statSync(lockPath).mtimeMs > CACHE_MUTATION_LOCK_STALE_MS;
163
+ if (staleOwner && !exactUnlinkCapabilityLock(lockPath, lockOwner)) {
164
+ Atomics.wait(sleeper, 0, 0, 10);
165
+ continue;
166
+ }
167
+ } catch {
168
+ // The lock changed while this waiter was being inspected.
169
+ }
170
+ Atomics.wait(sleeper, 0, 0, 10);
171
+ }
172
+ }
173
+ }
174
+
175
+ function exactUnlinkCapabilityLock(lockPath: string, expectedOwner: string): boolean {
176
+ // Open the lock first and read bytes + identity from the SAME descriptor: the
177
+ // pinned inode cannot be recycled or substituted while the handle is open, so
178
+ // a replacement owner that lands at the pathname after the read can never be
179
+ // mistaken for the record whose bytes authorized this removal.
180
+ const descriptor = fs.openSync(lockPath, "r");
181
+ let bytes: Buffer;
182
+ let stat: import("node:fs").BigIntStats;
183
+ try {
184
+ bytes = fs.readFileSync(descriptor);
185
+ if (bytes.toString("utf8") !== expectedOwner) return false;
186
+ stat = fs.fstatSync(descriptor, { bigint: true });
187
+ beforeLockExactUnlinkForTests?.(lockPath);
188
+ if (!stat.isFile()) return false;
189
+ } finally {
190
+ fs.closeSync(descriptor);
191
+ }
192
+ const parent = fs.statSync(path.dirname(lockPath), { bigint: true });
193
+ if (!parent.isDirectory()) return false;
194
+ if (!nativeExactUnlinkBindings)
195
+ nativeExactUnlinkBindings = require("@gajae-code/natives") as NativeExactUnlinkBindings;
196
+ const bindings = nativeExactUnlinkBindings;
197
+ const result = bindings.exactUnlink(lockPath, {
198
+ dev: stat.dev,
199
+ ino: stat.ino,
200
+ nlink: stat.nlink,
201
+ parentDev: parent.dev,
202
+ parentIno: parent.ino,
203
+ size: stat.size,
204
+ mtimeNs: stat.mtimeNs,
205
+ sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
206
+ quarantineName: `.tool-choice-capability-lock-${crypto.randomUUID()}`,
207
+ });
208
+ return (
209
+ result.ok ||
210
+ (result.code === "cleanup_pending" &&
211
+ result.payloadDurable === true &&
212
+ result.detachedPath !== undefined &&
213
+ result.retainedSuccessorPath === undefined &&
214
+ result.retainedUnknownPath === undefined)
215
+ );
216
+ }
217
+
218
+ function isProcessAlive(pid: number): boolean {
219
+ try {
220
+ process.kill(pid, 0);
221
+ return true;
222
+ } catch (error) {
223
+ // EPERM means the process exists but may not be signalable; only ESRCH
224
+ // proves the pid is gone. Any other outcome is treated as alive so an
225
+ // uncertain owner is never reaped as stale.
226
+ return (error as { code?: string }).code !== "ESRCH";
227
+ }
228
+ }
229
+
77
230
  /**
78
231
  * Resolves a requested tool_choice against static and runtime capability limits.
79
232
  * `compat` overrides `model.compat` for transports that layer URL/provider
@@ -85,12 +238,13 @@ export function resolveToolChoice(
85
238
  compat?: ToolChoiceCompat,
86
239
  ): ResolveToolChoiceResult {
87
240
  const derived = deriveToolChoiceSupport(compat ?? model.compat);
88
- const runtime = registry.get(toolChoiceRegistryKey(model));
241
+ const registryKey = toolChoiceRegistryKey(model);
242
+ hydrateToolChoiceCapability(registryKey);
243
+ const runtime = registry.get(registryKey);
89
244
  const support = runtime && supportRank[runtime] < supportRank[derived.support] ? runtime : derived.support;
90
245
  const supportSource: ToolChoiceSupportSource = support === derived.support ? derived.source : "runtime";
91
246
  const requestedInfo = requestedToolChoiceLevel(requested);
92
247
  const clampLevel = requestedInfo.requestedLevel === "none" ? "auto" : requestedInfo.requestedLevel;
93
- const registryKey = toolChoiceRegistryKey(model);
94
248
 
95
249
  if (requested === undefined) {
96
250
  return {
@@ -251,3 +405,229 @@ function safeHostname(baseUrl: string): string | undefined {
251
405
  return undefined;
252
406
  }
253
407
  }
408
+
409
+ function hydrateToolChoiceCapability(registryKey: string): void {
410
+ const now = currentTime();
411
+ const expiresAt = registryExpiresAt.get(registryKey);
412
+ if (expiresAt !== undefined && now < expiresAt) return;
413
+ registry.delete(registryKey);
414
+ registryExpiresAt.delete(registryKey);
415
+ const hydrated = withCapabilityCache(database => {
416
+ const digest = capabilityKeyDigest(registryKey);
417
+ const row = database
418
+ .query("SELECT max_support, support_rank, observed_at FROM tool_choice_capabilities WHERE key_digest = ?")
419
+ .get(digest) as { max_support?: unknown; support_rank?: unknown; observed_at?: unknown } | null;
420
+ if (!row) return false;
421
+ if (
422
+ !isToolChoiceSupport(row.max_support) ||
423
+ row.support_rank !== supportRank[row.max_support] ||
424
+ !isValidObservedAt(row.observed_at, now)
425
+ ) {
426
+ beforeMalformedDeleteForTests?.();
427
+ database.run(
428
+ "DELETE FROM tool_choice_capabilities WHERE key_digest = ? AND max_support = ? AND support_rank = ? AND observed_at = ?",
429
+ [
430
+ digest,
431
+ typeof row.max_support === "string" ? row.max_support : String(row.max_support),
432
+ typeof row.support_rank === "number" ? row.support_rank : -1,
433
+ typeof row.observed_at === "number" ? row.observed_at : -1,
434
+ ],
435
+ );
436
+ return false;
437
+ }
438
+ if (now - row.observed_at >= CACHE_TTL_MS) {
439
+ beforeExpiredDeleteForTests?.();
440
+ database.run("DELETE FROM tool_choice_capabilities WHERE key_digest = ? AND observed_at = ?", [
441
+ digest,
442
+ row.observed_at,
443
+ ]);
444
+ return false;
445
+ }
446
+ registry.set(registryKey, row.max_support);
447
+ registryExpiresAt.set(registryKey, row.observed_at + CACHE_TTL_MS);
448
+ return true;
449
+ });
450
+ if (hydrated === false && !registryExpiresAt.has(registryKey)) {
451
+ registryExpiresAt.set(registryKey, now + EMPTY_CACHE_TTL_MS);
452
+ }
453
+ pruneRegistry(now);
454
+ }
455
+
456
+ function persistToolChoiceCapability(
457
+ registryKey: string,
458
+ maxSupport: ToolChoiceSupport,
459
+ ): { support: ToolChoiceSupport; observedAt: number } | undefined {
460
+ return withCapabilityCache(database => {
461
+ const write = database.transaction(() => {
462
+ const digest = capabilityKeyDigest(registryKey);
463
+ const observedAt = currentTime();
464
+ database.run(
465
+ "INSERT INTO tool_choice_capabilities (key_digest, max_support, support_rank, observed_at) VALUES (?, ?, ?, ?) ON CONFLICT(key_digest) DO UPDATE SET max_support = excluded.max_support, support_rank = excluded.support_rank, observed_at = excluded.observed_at WHERE excluded.support_rank <= tool_choice_capabilities.support_rank",
466
+ [digest, maxSupport, supportRank[maxSupport], observedAt],
467
+ );
468
+ database.run(
469
+ "DELETE FROM tool_choice_capabilities WHERE key_digest NOT IN (SELECT key_digest FROM tool_choice_capabilities ORDER BY observed_at DESC, key_digest DESC LIMIT ?)",
470
+ [CACHE_MAX_ENTRIES],
471
+ );
472
+ const row = database
473
+ .query("SELECT max_support, observed_at FROM tool_choice_capabilities WHERE key_digest = ?")
474
+ .get(digest) as { max_support?: unknown; observed_at?: unknown } | null;
475
+ return row && isToolChoiceSupport(row.max_support) && typeof row.observed_at === "number"
476
+ ? { support: row.max_support, observedAt: row.observed_at }
477
+ : undefined;
478
+ });
479
+ return write();
480
+ });
481
+ }
482
+
483
+ function withCapabilityCache<T>(operation: (database: Database) => T): T | undefined {
484
+ if (process.env.NODE_ENV === "test" && cachePathOverride === undefined) return;
485
+ const cachePath = cachePathOverride ?? getToolChoiceCapabilityCachePath();
486
+ let openedFileSize: number | undefined;
487
+ try {
488
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true, mode: 0o700 });
489
+ try {
490
+ openedFileSize = fs.statSync(cachePath).size;
491
+ } catch {
492
+ // File does not exist yet (first run); nothing to identity-check on retirement.
493
+ }
494
+ const database = openCapabilityCache(cachePath);
495
+ try {
496
+ try {
497
+ fs.chmodSync(cachePath, 0o600);
498
+ } catch {
499
+ // Cache access remains fail-open on filesystems without POSIX modes.
500
+ }
501
+ const simulatedError = simulateCacheOperationErrorForTests?.();
502
+ if (simulatedError) throw simulatedError;
503
+ return operation(database);
504
+ } finally {
505
+ database.close();
506
+ }
507
+ } catch (error) {
508
+ logger.debug("Tool-choice capability cache unavailable", {
509
+ cachePath: path.basename(cachePath),
510
+ error: error instanceof Error ? error.message : String(error),
511
+ });
512
+ if (isCorruptCapabilityCacheError(error)) {
513
+ beforeCorruptRetireForTests?.();
514
+ retireCorruptCapabilityCache(cachePath, openedFileSize);
515
+ }
516
+ }
517
+ }
518
+
519
+ function openCapabilityCache(cachePath: string): Database {
520
+ onCacheOpenForTests?.();
521
+ const database = new Database(cachePath, { create: true, strict: true });
522
+ try {
523
+ database.run("PRAGMA busy_timeout = 3000");
524
+ database.run("PRAGMA journal_mode = WAL");
525
+ database.run("PRAGMA synchronous = FULL");
526
+ const initialize = database.transaction(() => {
527
+ const version = database.query("PRAGMA user_version").get() as { user_version?: number } | null;
528
+ const schemaVersion = version?.user_version ?? 0;
529
+ if (schemaVersion === 0) {
530
+ database.run(`
531
+ CREATE TABLE IF NOT EXISTS tool_choice_capabilities (
532
+ key_digest TEXT PRIMARY KEY NOT NULL,
533
+ max_support TEXT NOT NULL,
534
+ support_rank INTEGER NOT NULL,
535
+ observed_at INTEGER NOT NULL
536
+ ) STRICT
537
+ `);
538
+ assertCapabilityCacheSchema(database);
539
+ database.run(`PRAGMA user_version = ${CACHE_SCHEMA_VERSION}`);
540
+ return;
541
+ }
542
+ if (schemaVersion !== CACHE_SCHEMA_VERSION) {
543
+ throw new CapabilityCacheCorruptionError("unsupported cache schema version");
544
+ }
545
+ assertCapabilityCacheSchema(database);
546
+ });
547
+ initialize();
548
+ return database;
549
+ } catch (error) {
550
+ database.close();
551
+ throw error;
552
+ }
553
+ }
554
+
555
+ function assertCapabilityCacheSchema(database: Database): void {
556
+ const columns = database.query("PRAGMA table_info(tool_choice_capabilities)").all() as Array<{
557
+ name?: unknown;
558
+ type?: unknown;
559
+ notnull?: unknown;
560
+ pk?: unknown;
561
+ }>;
562
+ const actual = columns.map(column => [column.name, column.type, column.notnull, column.pk]);
563
+ const expected = [
564
+ ["key_digest", "TEXT", 1, 1],
565
+ ["max_support", "TEXT", 1, 0],
566
+ ["support_rank", "INTEGER", 1, 0],
567
+ ["observed_at", "INTEGER", 1, 0],
568
+ ];
569
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
570
+ throw new CapabilityCacheCorruptionError("invalid cache schema");
571
+ }
572
+ }
573
+
574
+ class CapabilityCacheCorruptionError extends Error {}
575
+
576
+ function isCorruptCapabilityCacheError(error: unknown): boolean {
577
+ if (error instanceof CapabilityCacheCorruptionError) return true;
578
+ if (!error || typeof error !== "object") return false;
579
+ const code = (error as { code?: unknown }).code;
580
+ return code === "SQLITE_CORRUPT" || code === "SQLITE_NOTADB";
581
+ }
582
+
583
+ /**
584
+ * Removes a confirmed-corrupt cache file and its WAL/SHM siblings. The file
585
+ * size captured before the corrupt open (`openedFileSize`) is checked before
586
+ * unlinking so a concurrently recreated valid replacement database with a
587
+ * different file size is never deleted.
588
+ */
589
+ function retireCorruptCapabilityCache(cachePath: string, openedFileSize?: number): void {
590
+ for (const suffix of ["", "-wal", "-shm"]) {
591
+ const target = `${cachePath}${suffix}`;
592
+ try {
593
+ if (openedFileSize !== undefined && suffix === "") {
594
+ const stat = fs.statSync(target);
595
+ if (stat.size !== openedFileSize) continue;
596
+ }
597
+ fs.rmSync(target, { force: true });
598
+ } catch {
599
+ // A best-effort cache reset must never break provider fallback behavior.
600
+ }
601
+ }
602
+ }
603
+
604
+ function capabilityKeyDigest(registryKey: string): string {
605
+ return crypto.createHash("sha256").update(registryKey).digest("hex");
606
+ }
607
+
608
+ function isToolChoiceSupport(value: unknown): value is ToolChoiceSupport {
609
+ return value === "none" || value === "auto" || value === "required" || value === "named";
610
+ }
611
+
612
+ function isValidObservedAt(value: unknown, now: number): value is number {
613
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= now;
614
+ }
615
+
616
+ function currentTime(): number {
617
+ return nowForTests?.() ?? Date.now();
618
+ }
619
+
620
+ function pruneRegistry(now: number): void {
621
+ for (const [key, expiresAt] of registryExpiresAt) {
622
+ if (expiresAt <= now) {
623
+ registryExpiresAt.delete(key);
624
+ registry.delete(key);
625
+ }
626
+ }
627
+ while (registryExpiresAt.size > REGISTRY_MAX_ENTRIES) {
628
+ const oldestKey = registryExpiresAt.keys().next().value;
629
+ if (typeof oldestKey !== "string") break;
630
+ registryExpiresAt.delete(oldestKey);
631
+ registry.delete(oldestKey);
632
+ }
633
+ }