codex-multi-auth 2.20.0 → 2.22.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 (44) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +5 -4
  3. package/dist/lib/accounts.d.ts +5 -0
  4. package/dist/lib/accounts.js +33 -4
  5. package/dist/lib/api-route-store.d.ts +6 -0
  6. package/dist/lib/api-route-store.js +98 -30
  7. package/dist/lib/codex-manager/api-login-menu.d.ts +1 -1
  8. package/dist/lib/codex-manager/api-login-menu.js +4 -6
  9. package/dist/lib/codex-manager/commands/status.d.ts +1 -1
  10. package/dist/lib/codex-manager/commands/status.js +26 -9
  11. package/dist/lib/codex-manager.js +7 -1
  12. package/dist/lib/constants.d.ts +1 -0
  13. package/dist/lib/constants.js +1 -0
  14. package/dist/lib/model-route-policy.d.ts +1 -0
  15. package/dist/lib/model-route-policy.js +2 -1
  16. package/dist/lib/runtime/account-model-catalog.d.ts +1 -0
  17. package/dist/lib/runtime/api-model-capabilities.d.ts +11 -2
  18. package/dist/lib/runtime/api-model-capabilities.js +155 -24
  19. package/dist/lib/runtime/app-bind.d.ts +4 -0
  20. package/dist/lib/runtime/app-bind.js +32 -1
  21. package/dist/lib/runtime/automatic-api-checks.d.ts +15 -0
  22. package/dist/lib/runtime/automatic-api-checks.js +188 -0
  23. package/dist/lib/runtime/model-discovery-status.d.ts +11 -1
  24. package/dist/lib/runtime/model-discovery-status.js +64 -49
  25. package/dist/lib/runtime/persisted-api-capabilities.d.ts +20 -0
  26. package/dist/lib/runtime/persisted-api-capabilities.js +387 -0
  27. package/dist/lib/runtime/responses-websocket.d.ts +18 -5
  28. package/dist/lib/runtime/responses-websocket.js +33 -11
  29. package/dist/lib/runtime/rotation-account-selection.d.ts +20 -1
  30. package/dist/lib/runtime/rotation-account-selection.js +94 -4
  31. package/dist/lib/runtime/rotation-proxy-state.d.ts +26 -0
  32. package/dist/lib/runtime/rotation-proxy-state.js +376 -4
  33. package/dist/lib/runtime/rotation-server-types.d.ts +3 -0
  34. package/dist/lib/runtime/rotation-token-refresh.d.ts +23 -1
  35. package/dist/lib/runtime/rotation-token-refresh.js +218 -27
  36. package/dist/lib/runtime-rotation-proxy.js +571 -96
  37. package/dist/lib/storage/credential-sidecars.js +34 -2
  38. package/dist/lib/storage/file-lock.d.ts +2 -0
  39. package/dist/lib/storage/file-lock.js +44 -30
  40. package/dist/lib/usage/ledger.js +1 -0
  41. package/dist/lib/usage/redaction.js +1 -0
  42. package/dist/lib/usage/types.d.ts +1 -1
  43. package/package.json +1 -1
  44. package/scripts/codex-app-router.js +42 -14
@@ -1,3 +1,4 @@
1
+ import { enrichPersistedApiCapabilities } from "./persisted-api-capabilities.js";
1
2
  import { withFileTransactionLock } from "../storage/file-lock.js";
2
3
  import { styleReportText as paint } from "../ui/format.js";
3
4
  import { withCheckProgress } from "../ui/check-progress.js";
@@ -26,7 +27,7 @@ const printable = z
26
27
  .max(256)
27
28
  .regex(/^[^\x00-\x1f\x7f]*$/);
28
29
  const entitlementSchema = z.object({
29
- model: printable, checkedAt: z.number().optional(),
30
+ model: printable, checkedAt: z.number().optional(), programCheckedAt: z.number().finite().nonnegative().max(8.64e15).optional(),
30
31
  probes: z.record(printable, z.enum(["verified", "unsupported", "unverified", "downgraded"])).optional(),
31
32
  serviceTiers: z.array(z.object({ id: printable, name: printable })).max(100),
32
33
  reasoningLevels: z.array(printable).max(100), accessPrograms: z.array(printable).max(100),
@@ -55,24 +56,6 @@ const schema = z.object({
55
56
  .optional(),
56
57
  entries: z.array(entrySchema).max(3000),
57
58
  });
58
- const probeCacheSchema = z.object({
59
- version: z.literal(1),
60
- entries: z.array(z.tuple([
61
- z.string().regex(/^[0-9a-f]{64}$/),
62
- z.object({ at: z.number(), levels: z.array(printable).max(100), tiers: z.array(printable).max(100), status: z.record(printable, printable) }),
63
- ])).max(1000),
64
- });
65
- function probeCachePath() {
66
- return join(getCodexMultiAuthDir(), "api-capability-probes.json");
67
- }
68
- async function loadProbeCache() {
69
- try {
70
- return probeCacheSchema.parse(JSON.parse(await fs.readFile(probeCachePath(), "utf8"))).entries;
71
- }
72
- catch {
73
- return [];
74
- }
75
- }
76
59
  export async function discoverModelInventory(storage, routes, options = {}) {
77
60
  const fetcher = options.fetchImpl ?? fetch, now = options.now ?? Date.now;
78
61
  const manager = new AccountManager(undefined, storage ?? {
@@ -132,28 +115,13 @@ export async function discoverModelInventory(storage, routes, options = {}) {
132
115
  finally {
133
116
  await manager.flushPendingSave();
134
117
  }
135
- const capabilities = new ApiModelCapabilities(fetcher, now);
136
- if (options.persistProbes)
137
- capabilities.importProbes(await loadProbeCache());
118
+ const capabilities = new ApiModelCapabilities(fetcher, now, undefined, undefined, options.programRefreshBefore);
138
119
  const api = new ApiModelRuntime(fetcher, now, options.updateApiDiscovery, options.updateApiDiscovery
139
- ? (models, refresh, route, forceProbes) => capabilities.enrich(models, refresh, route, forceProbes)
120
+ ? (models, refresh, route, forceProbes) => options.persistProbes
121
+ ? enrichPersistedApiCapabilities(models, refresh, route, forceProbes, { fetchImpl: fetcher, now, requireStoredRoute: true, signal: options.signal, programRefreshBefore: options.programRefreshBefore })
122
+ : capabilities.enrich(models, refresh, route, forceProbes)
140
123
  : undefined);
141
124
  await api.catalogs(routes, true, options.forceProbes === true);
142
- if (options.persistProbes) {
143
- const path = probeCachePath();
144
- await withFileTransactionLock(path, async () => {
145
- // Merge with the latest file: a concurrent check may have probed other
146
- // credentials since this one loaded. The newer result wins per key.
147
- const merged = new Map(await loadProbeCache());
148
- for (const [key, value] of capabilities.exportProbes()) {
149
- const disk = merged.get(key);
150
- if (!disk || disk.at <= value.at)
151
- merged.set(key, value);
152
- }
153
- const entries = [...merged].sort((x, y) => x[1].at - y[1].at).slice(-1000);
154
- await writeFileAtomic(path, JSON.stringify(probeCacheSchema.parse({ version: 1, entries })) + "\n");
155
- }).catch(() => undefined);
156
- }
157
125
  entries.push(...api.statuses(routes).map((s, i) => ({
158
126
  id: modelScopeId(s.kind, s.id),
159
127
  label: s.label,
@@ -194,29 +162,76 @@ export async function loadModelInventory() {
194
162
  }
195
163
  }
196
164
  let inventoryWrites = Promise.resolve();
197
- export function saveModelInventory(value) {
198
- const path = inventoryPath();
199
- const pending = inventoryWrites.then(() => withFileTransactionLock(path, () => saveModelInventorySerial(value, path)));
165
+ /** Drain queued writes before resetting process-local test state. */
166
+ export async function resetModelInventoryWritesForTests() {
167
+ for (;;) {
168
+ const pending = inventoryWrites;
169
+ await pending;
170
+ if (inventoryWrites === pending) {
171
+ inventoryWrites = Promise.resolve();
172
+ return;
173
+ }
174
+ }
175
+ }
176
+ export function saveModelInventory(value, options = {}) {
177
+ const path = inventoryPath(), signal = options.signal;
178
+ if (signal?.aborted)
179
+ return Promise.reject(signal.reason);
180
+ let stopWaiting;
181
+ const pending = inventoryWrites.then(() => {
182
+ // Once started, drain our own cancellable lock/write and cleanup.
183
+ stopWaiting?.();
184
+ signal?.throwIfAborted();
185
+ return withFileTransactionLock(path, () => saveModelInventorySerial(value, path, options.mergeEntries === true, signal), { signal });
186
+ });
187
+ // Keep the underlying queue intact even if a waiting caller cancels.
200
188
  inventoryWrites = pending.catch(() => undefined);
201
- return pending;
189
+ if (!signal)
190
+ return pending;
191
+ return new Promise((resolve, reject) => {
192
+ const abort = () => { stopWaiting?.(); reject(signal.reason); };
193
+ stopWaiting = () => signal.removeEventListener("abort", abort);
194
+ signal.addEventListener("abort", abort, { once: true });
195
+ if (signal.aborted)
196
+ abort();
197
+ void pending.then(resolve, reject).finally(() => stopWaiting?.());
198
+ });
202
199
  }
203
- async function saveModelInventorySerial(value, path) {
204
- const data = schema.parse(withInventoryChanges(value, await loadModelInventory()));
200
+ async function saveModelInventorySerial(value, path, mergeEntries = false, signal) {
201
+ signal?.throwIfAborted();
202
+ const previous = await loadModelInventory();
203
+ if (mergeEntries && previous) {
204
+ // Clock rollback must not make old observations permanently newer.
205
+ const now = Date.now();
206
+ const entries = [...previous.entries];
207
+ for (const entry of value.entries) {
208
+ const index = entries.findIndex(old => old.id === entry.id && old.kind === entry.kind);
209
+ if (index < 0)
210
+ entries.push(entry);
211
+ else if ((entries[index]?.checkedAt ?? -Infinity) > now || (entries[index]?.checkedAt ?? -Infinity) <= entry.checkedAt)
212
+ entries[index] = entry;
213
+ }
214
+ value = { ...previous, ...value, clientVersion: value.clientVersion ?? previous.clientVersion,
215
+ checkedAt: previous.checkedAt > now ? value.checkedAt : Math.max(previous.checkedAt, value.checkedAt), entries };
216
+ }
217
+ const data = schema.parse(withInventoryChanges(value, previous));
205
218
  const serialized = JSON.stringify(data) + "\n";
206
219
  if (Buffer.byteLength(serialized) > 4 * 1024 * 1024)
207
220
  throw Error("Model discovery status exceeds size limit");
208
- await writeFileAtomic(path, serialized);
221
+ await writeFileAtomic(path, serialized, signal);
209
222
  }
210
- async function writeFileAtomic(path, serialized) {
223
+ async function writeFileAtomic(path, serialized, signal) {
224
+ signal?.throwIfAborted();
211
225
  const temp = tempPathFor(path);
212
226
  await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 });
213
227
  const retry = { maxAttempts: 6, backoffMs: 25 };
214
228
  try {
229
+ signal?.throwIfAborted();
215
230
  await fs.writeFile(temp, serialized, {
216
231
  mode: 0o600,
217
232
  flag: "wx",
218
233
  });
219
- await withRetry(() => fs.rename(temp, path), retry);
234
+ await withRetry(() => { signal?.throwIfAborted(); return fs.rename(temp, path); }, retry);
220
235
  }
221
236
  finally {
222
237
  await withRetry(() => fs.unlink(temp), retry).catch((e) => {
@@ -234,10 +249,10 @@ export function formatModelInventory(inventory, now = Date.now()) {
234
249
  ...inventory.entries.flatMap((e) => [
235
250
  ` ${paint(e.label, "heading")}${e.routable === true ? " [routing eligible]" : e.routable === false ? " [discovery only]" : ""}${e.bound ? " [stored binding]" : ""}${e.selected ? " [preferred workspace]" : ""} [${e.kind.toUpperCase()}]: ${!e.enabled ? paint("disabled", "muted") : e.error ? paint("discovery failed", "danger") : paint(`${e.models.length} available`, "success")}; ${e.checkedAt ? new Date(e.checkedAt).toISOString() : "never checked"}${now - e.checkedAt > 60000 ? paint(" (stale; run check)", "warning") : ""}`,
236
251
  ` ${paint("Available:", "muted")} ${e.models.join(", ") || "none"}`,
237
- ...(e.entitlements ?? []).map((m) => ` ${paint(m.model, "accent")}: speeds=${m.serviceTiers.map((t) => `${t.name} (${t.id})`).join(", ") || "not advertised"}; reasoning=${m.reasoningLevels.join(", ") || "not advertised"}; programs=${m.accessPrograms.join(", ") || "not advertised"}${m.probes
252
+ ...(e.entitlements ?? []).map((m) => ` ${paint(m.model, "accent")}: speeds=${m.serviceTiers.map((t) => `${t.name} (${t.id})`).join(", ") || "not advertised"}; reasoning=${m.reasoningLevels.join(", ") || "not advertised"}; ${e.kind === "oauth" ? "programs" : "configured programs"}=${m.accessPrograms.join(", ") || (e.kind === "oauth" ? "not advertised" : "none")}${m.probes
238
253
  ? `; probes: ${Object.entries(m.probes)
239
254
  .map(([k, v]) => `${k}=${paint(v, v === "verified" ? "success" : v === "unsupported" || v === "downgraded" ? "warning" : "muted")}`)
240
- .join(", ")} (${new Date(m.checkedAt ?? 0).toISOString()})`
255
+ .join(", ")} (${new Date(m.checkedAt ?? 0).toISOString()})${m.programCheckedAt !== undefined ? `; programs checked ${new Date(m.programCheckedAt).toISOString()}` : ""}`
241
256
  : ""}`),
242
257
  ...(e.kind !== "oauth"
243
258
  ? [
@@ -0,0 +1,20 @@
1
+ import { type ApiRouteCredential } from "../api-route-store.js";
2
+ import type { RouteModel } from "../model-route-policy.js";
3
+ import { ApiModelCapabilities } from "./api-model-capabilities.js";
4
+ export declare function persistedProbeSlotsForTests(): {
5
+ active: number;
6
+ waiting: number;
7
+ };
8
+ /** Reject resets during work so live waiters cannot lose their concurrency slots. */
9
+ export declare function resetPersistedApiCapabilitiesForTests(): void;
10
+ /** Reload under a credential/model lease so independent consumers share paid evidence. */
11
+ declare function enrichPersistedApiCapabilitiesImpl(models: RouteModel[], refresh: boolean, route: ApiRouteCredential, force: boolean, options?: {
12
+ fetchImpl?: typeof fetch;
13
+ now?: () => number;
14
+ metadata?: ApiModelCapabilities;
15
+ signal?: AbortSignal;
16
+ programRefreshBefore?: number;
17
+ requireStoredRoute?: boolean;
18
+ }): Promise<RouteModel[]>;
19
+ export declare function enrichPersistedApiCapabilities(...args: Parameters<typeof enrichPersistedApiCapabilitiesImpl>): Promise<RouteModel[]>;
20
+ export {};
@@ -0,0 +1,387 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { z } from "zod";
5
+ import { logWarn } from "../logger.js";
6
+ import { getCodexMultiAuthDir } from "../runtime-paths.js";
7
+ import { withFileTransactionLock } from "../storage/file-lock.js";
8
+ import { withRetry } from "../fs-retry.js";
9
+ import { tempPathFor } from "../temp-path.js";
10
+ import { mapWithConcurrency } from "../concurrency.js";
11
+ import { getApiRoutesPath, loadApiRoutes, ensureApiRouteCapabilityEpoch } from "../api-route-store.js";
12
+ import { ApiModelCapabilities } from "./api-model-capabilities.js";
13
+ const printable = z.string().max(256).regex(/^[^\x00-\x1f\x7f]*$/);
14
+ const probeCacheSchema = z.object({
15
+ version: z.literal(1),
16
+ entries: z.array(z.tuple([
17
+ z.string().regex(/^[0-9a-f]{64}$/),
18
+ z.object({
19
+ ownerEpoch: z.string().uuid().optional(),
20
+ programRetry: z.boolean().optional(),
21
+ programCheckedAt: z.number().finite().nonnegative().max(8.64e15).optional(),
22
+ at: z.number(),
23
+ levels: z.array(printable).max(100),
24
+ tiers: z.array(printable).max(100),
25
+ status: z.record(printable, printable),
26
+ }),
27
+ ])).max(1000),
28
+ });
29
+ let activeProbes = 0;
30
+ let activeEnrichments = 0;
31
+ const retainedProbes = new Map();
32
+ const retainedOwners = new Map();
33
+ const probeWaiters = [];
34
+ const unavailableLeaseCodes = new Set([
35
+ "ELOCKED", "EACCES", "EPERM", "EROFS", "ENOSPC", "EDQUOT", "EMFILE", "ENFILE",
36
+ "EBUSY", "EIO", "ENOENT", "ENOTDIR", "ESTALE",
37
+ ]);
38
+ export function persistedProbeSlotsForTests() {
39
+ return { active: activeProbes, waiting: probeWaiters.length };
40
+ }
41
+ /** Reject resets during work so live waiters cannot lose their concurrency slots. */
42
+ export function resetPersistedApiCapabilitiesForTests() {
43
+ if (activeEnrichments || activeProbes || probeWaiters.length) {
44
+ throw Error("Cannot reset active API capability checks");
45
+ }
46
+ retainedProbes.clear();
47
+ retainedOwners.clear();
48
+ }
49
+ async function withSharedProbeSlot(operation, signal) {
50
+ signal?.throwIfAborted();
51
+ if (activeProbes >= 4)
52
+ await new Promise((resolve, reject) => {
53
+ const grant = () => {
54
+ signal?.removeEventListener("abort", abort);
55
+ resolve();
56
+ };
57
+ const abort = () => {
58
+ const index = probeWaiters.indexOf(grant);
59
+ if (index < 0)
60
+ return; // A granted permit is released by the finally below.
61
+ probeWaiters.splice(index, 1);
62
+ signal?.removeEventListener("abort", abort);
63
+ reject(signal?.reason);
64
+ };
65
+ probeWaiters.push(grant);
66
+ signal?.addEventListener("abort", abort, { once: true });
67
+ if (signal?.aborted)
68
+ abort();
69
+ });
70
+ else
71
+ activeProbes++;
72
+ try {
73
+ // Cancellation can happen after grant but before this continuation runs.
74
+ signal?.throwIfAborted();
75
+ return await operation();
76
+ }
77
+ finally {
78
+ const next = probeWaiters.shift();
79
+ if (next)
80
+ next();
81
+ else
82
+ activeProbes--;
83
+ }
84
+ }
85
+ function probeCachePath() {
86
+ return join(getCodexMultiAuthDir(), "api-capability-probes.json");
87
+ }
88
+ async function loadProbeCache() {
89
+ try {
90
+ const text = await withRetry(() => fs.readFile(probeCachePath(), "utf8"), {
91
+ maxAttempts: 6,
92
+ backoffMs: 25,
93
+ });
94
+ return probeCacheSchema.parse(JSON.parse(text)).entries;
95
+ }
96
+ catch (error) {
97
+ if (error.code !== "ENOENT")
98
+ throw error;
99
+ return [];
100
+ }
101
+ }
102
+ function invalidProbeCache(error) {
103
+ return error instanceof SyntaxError || error instanceof z.ZodError;
104
+ }
105
+ /** Explicit checks may repair corrupt JSON; access failures never authorize a reset. */
106
+ async function loadOrRepairProbeCache(force, ownsRoute, signal) {
107
+ try {
108
+ return await loadProbeCache();
109
+ }
110
+ catch (error) {
111
+ if (!force || !invalidProbeCache(error))
112
+ throw error;
113
+ }
114
+ const path = probeCachePath();
115
+ return withFileTransactionLock(path, async () => {
116
+ const repair = async () => {
117
+ signal?.throwIfAborted();
118
+ if (ownsRoute && !await ownsRoute())
119
+ throw Error("API route ownership changed");
120
+ // A concurrent writer may have recovered it while we waited.
121
+ try {
122
+ return await loadProbeCache();
123
+ }
124
+ catch (error) {
125
+ if (!invalidProbeCache(error))
126
+ throw error;
127
+ }
128
+ const temp = tempPathFor(path), retry = { maxAttempts: 6, backoffMs: 25 };
129
+ try {
130
+ await fs.writeFile(temp, JSON.stringify({ version: 1, entries: [] }) + "\n", { mode: 0o600, flag: "wx" });
131
+ signal?.throwIfAborted();
132
+ await withRetry(() => fs.rename(temp, path), retry);
133
+ }
134
+ finally {
135
+ await withRetry(() => fs.rm(temp, { force: true }), retry);
136
+ }
137
+ return [];
138
+ };
139
+ return ownsRoute ? withFileTransactionLock(getApiRoutesPath(), repair, { signal }) : repair();
140
+ }, { signal });
141
+ }
142
+ async function storedGeneration(route, model) {
143
+ const path = getApiRoutesPath();
144
+ const before = await fs.stat(path).catch(() => undefined);
145
+ if (!before)
146
+ return undefined;
147
+ const current = (await loadApiRoutes()).find(value => value.id === route.id && value.kind === route.kind && value.apiKey === route.apiKey &&
148
+ value.enabled && value.probeCapabilities && value.visibleModels?.includes(model));
149
+ if (!current || JSON.stringify(current.accessPrograms) !== JSON.stringify(route.accessPrograms) || JSON.stringify(current.modelAccessPrograms) !== JSON.stringify(route.modelAccessPrograms))
150
+ return undefined;
151
+ if (route.capabilityEpoch && route.capabilityEpoch !== current.capabilityEpoch)
152
+ return undefined;
153
+ if (current.capabilityEpoch)
154
+ return current.capabilityEpoch;
155
+ const after = await fs.stat(path).catch(() => undefined);
156
+ const version = (value) => `${value.dev}:${value.ino}:${value.mtimeMs}:${value.ctimeMs}:${value.size}`;
157
+ return after && version(before) === version(after) ? version(after) : undefined;
158
+ }
159
+ function dropRetained(key) {
160
+ retainedProbes.delete(key);
161
+ retainedOwners.delete(key);
162
+ }
163
+ function rememberProbe(key, value, owner) {
164
+ if (retainedProbes.size >= 1000)
165
+ dropRetained(retainedProbes.keys().next().value ?? "");
166
+ retainedProbes.set(key, value);
167
+ if (owner)
168
+ retainedOwners.set(key, owner.generation);
169
+ else
170
+ retainedOwners.delete(key);
171
+ }
172
+ async function persistProbe(key, value, now, signal, owner, memoryKey) {
173
+ if (!value)
174
+ return true;
175
+ const path = probeCachePath();
176
+ return withFileTransactionLock(path, async () => {
177
+ const persist = async () => {
178
+ signal?.throwIfAborted();
179
+ const merged = new Map(await loadProbeCache());
180
+ const disk = merged.get(key);
181
+ if (!disk || disk.ownerEpoch !== value.ownerEpoch || disk.at > now() || disk.at <= value.at)
182
+ merged.set(key, value);
183
+ const entries = [...merged].sort((a, b) => a[1].at - b[1].at).slice(-1000);
184
+ const temp = tempPathFor(path);
185
+ const retry = { maxAttempts: 6, backoffMs: 25 };
186
+ try {
187
+ await fs.writeFile(temp, JSON.stringify(probeCacheSchema.parse({ version: 1, entries })) + "\n", { mode: 0o600, flag: "wx" });
188
+ await withRetry(() => fs.rename(temp, path), retry);
189
+ }
190
+ finally {
191
+ await withRetry(() => fs.rm(temp, { force: true }), retry);
192
+ }
193
+ return true;
194
+ };
195
+ if (!owner)
196
+ return persist();
197
+ return withFileTransactionLock(getApiRoutesPath(), async () => {
198
+ if (await storedGeneration(owner.route, owner.model) !== owner.generation) {
199
+ if (memoryKey)
200
+ dropRetained(memoryKey);
201
+ return false;
202
+ }
203
+ try {
204
+ return await persist();
205
+ }
206
+ catch (error) {
207
+ signal?.throwIfAborted();
208
+ // Keep the ownership lock through the memory decision, too.
209
+ if (memoryKey)
210
+ rememberProbe(memoryKey, value, owner);
211
+ throw error;
212
+ }
213
+ }, { signal });
214
+ }, { signal });
215
+ }
216
+ function latestProbe(key, retained, entries, now) {
217
+ const disk = entries.find(([id]) => id === key)?.[1];
218
+ const validDisk = disk && disk.at <= now ? disk : undefined;
219
+ return retained && retained.at <= now && (!validDisk || retained.at > validDisk.at) ? retained : validDisk;
220
+ }
221
+ /** Reload under a credential/model lease so independent consumers share paid evidence. */
222
+ async function enrichPersistedApiCapabilitiesImpl(models, refresh, route, force, options = {}) {
223
+ options.signal?.throwIfAborted();
224
+ const fetcher = options.fetchImpl ?? fetch;
225
+ const now = options.now ?? Date.now;
226
+ const metadata = options.metadata ?? new ApiModelCapabilities(fetcher, now);
227
+ // Inference metadata must not wait for another consumer's paid probes.
228
+ if (!route.enabled || !route.probeCapabilities) {
229
+ return metadata.enrich(models, refresh, route, force);
230
+ }
231
+ return mapWithConcurrency(models, 3, async (model) => {
232
+ const path = probeCachePath();
233
+ const key = createHash("sha256").update(route.id).update("\0").update(route.apiKey).update("\0").update(model.slug).digest("hex");
234
+ const memoryKey = `${path}\0${key}`;
235
+ let ownershipRoute = route;
236
+ let capturedOwnership = false;
237
+ let readOnlyOwner = false;
238
+ let generation;
239
+ if (options.requireStoredRoute) {
240
+ try {
241
+ const migrated = await ensureApiRouteCapabilityEpoch(route, options.signal);
242
+ if (migrated) {
243
+ capturedOwnership = true;
244
+ ownershipRoute = { ...route, capabilityEpoch: migrated.capabilityEpoch, legacyCapabilityCache: migrated.legacyCapabilityCache };
245
+ generation = await withFileTransactionLock(getApiRoutesPath(), () => storedGeneration(ownershipRoute, model.slug), { signal: options.signal });
246
+ }
247
+ }
248
+ catch {
249
+ options.signal?.throwIfAborted();
250
+ readOnlyOwner = true;
251
+ if (!capturedOwnership) {
252
+ const current = (await loadApiRoutes().catch(() => [])).find(value => value.id === route.id && value.kind === route.kind && value.apiKey === route.apiKey &&
253
+ (!route.capabilityEpoch || route.capabilityEpoch === value.capabilityEpoch));
254
+ if (current) {
255
+ ownershipRoute = { ...route, capabilityEpoch: current.capabilityEpoch, legacyCapabilityCache: current.legacyCapabilityCache };
256
+ capturedOwnership = true;
257
+ }
258
+ }
259
+ if (capturedOwnership)
260
+ generation = await storedGeneration(ownershipRoute, model.slug).catch(() => undefined);
261
+ }
262
+ }
263
+ const owner = generation ? { route: ownershipRoute, model: model.slug, generation } : undefined;
264
+ const ownsRoute = async () => !options.requireStoredRoute || Boolean(owner && await storedGeneration(owner.route, model.slug).catch(() => undefined) === owner.generation);
265
+ if (options.requireStoredRoute && (!owner || retainedOwners.get(memoryKey) !== owner.generation))
266
+ dropRetained(memoryKey);
267
+ const ownedEntries = (entries) => entries.filter(([, value]) => !options.requireStoredRoute || (value.ownerEpoch
268
+ ? value.ownerEpoch === owner?.generation
269
+ : ownershipRoute.legacyCapabilityCache === true || !ownershipRoute.capabilityEpoch));
270
+ const emptyEvidence = async () => {
271
+ options.signal?.throwIfAborted();
272
+ const [result] = await metadata.enrich([model], refresh, route, false, []);
273
+ options.signal?.throwIfAborted();
274
+ return result ?? model;
275
+ };
276
+ if (!await ownsRoute())
277
+ return emptyEvidence();
278
+ const fallbackWithoutProbes = async (readDisk = true) => {
279
+ options.signal?.throwIfAborted();
280
+ let entries = [];
281
+ // Atomic cache reads are safe without the paid-probe lease; never write here.
282
+ if (readDisk) {
283
+ try {
284
+ entries = await loadProbeCache();
285
+ }
286
+ catch { /* Retained evidence may still be usable. */ }
287
+ }
288
+ options.signal?.throwIfAborted();
289
+ if (!await ownsRoute()) {
290
+ dropRetained(memoryKey);
291
+ return emptyEvidence();
292
+ }
293
+ const retained = retainedProbes.get(memoryKey);
294
+ const value = latestProbe(key, retained, ownedEntries(entries), now());
295
+ const [result] = await metadata.enrich([model], refresh, route, false, value ? [[key, value]] : []);
296
+ options.signal?.throwIfAborted();
297
+ // Documentation may await network I/O while a credential wipe completes.
298
+ if (!await ownsRoute()) {
299
+ dropRetained(memoryKey);
300
+ return emptyEvidence();
301
+ }
302
+ return result ?? model;
303
+ };
304
+ if (readOnlyOwner)
305
+ return fallbackWithoutProbes();
306
+ try {
307
+ return await withFileTransactionLock(join(dirname(path), "api-probe-locks", key), async () => {
308
+ options.signal?.throwIfAborted();
309
+ const guardedFetch = async (input, init) => {
310
+ if (init?.method?.toUpperCase() === "POST" && !await ownsRoute())
311
+ throw Error("API route ownership changed");
312
+ return fetcher(input, init);
313
+ };
314
+ const capabilities = new ApiModelCapabilities(guardedFetch, now, operation => withSharedProbeSlot(operation, options.signal), metadata, options.programRefreshBefore);
315
+ let entries;
316
+ try {
317
+ entries = await loadOrRepairProbeCache(force, options.requireStoredRoute ? ownsRoute : undefined, options.signal);
318
+ }
319
+ catch {
320
+ logWarn("API capability cache unavailable; skipping paid probes.");
321
+ return fallbackWithoutProbes(false);
322
+ }
323
+ if (!await ownsRoute()) {
324
+ dropRetained(memoryKey);
325
+ return emptyEvidence();
326
+ }
327
+ const retained = retainedProbes.get(memoryKey);
328
+ const latest = latestProbe(key, retained, ownedEntries(entries), now());
329
+ if (latest)
330
+ capabilities.importProbes([[key, latest]]);
331
+ options.signal?.throwIfAborted();
332
+ const [result] = await capabilities.enrich([model], refresh, route, force);
333
+ // Imported evidence belongs to other leases; only write our own result.
334
+ const exported = new Map(capabilities.exportProbes()).get(key);
335
+ const value = exported && owner ? { ...exported, ownerEpoch: owner.generation } : exported;
336
+ try {
337
+ const saved = await persistProbe(key, value, now, options.signal, owner, memoryKey);
338
+ if (!saved)
339
+ return emptyEvidence();
340
+ dropRetained(memoryKey);
341
+ }
342
+ catch {
343
+ options.signal?.throwIfAborted();
344
+ if (value && !options.requireStoredRoute)
345
+ rememberProbe(memoryKey, value);
346
+ else if (value && owner) {
347
+ // Acquisition can fail before persistProbe enters its callback.
348
+ // This independent lock never acquires a cache lock underneath it.
349
+ await withFileTransactionLock(getApiRoutesPath(), async () => {
350
+ if (await ownsRoute())
351
+ rememberProbe(memoryKey, value, owner);
352
+ else
353
+ dropRetained(memoryKey);
354
+ }, { signal: options.signal }).catch(() => undefined);
355
+ }
356
+ if (!await ownsRoute()) {
357
+ dropRetained(memoryKey);
358
+ return emptyEvidence();
359
+ }
360
+ logWarn("API capability results could not be persisted.");
361
+ }
362
+ return result ?? model;
363
+ }, { waitMs: 60_000, signal: options.signal,
364
+ guardAcquisition: options.requireStoredRoute ? action => withFileTransactionLock(getApiRoutesPath(), async () => {
365
+ if (!await ownsRoute())
366
+ throw Object.assign(Error("API route ownership changed"), { code: "ESTALE" });
367
+ return action();
368
+ }, { signal: options.signal }) : undefined,
369
+ });
370
+ }
371
+ catch (error) {
372
+ if (!unavailableLeaseCodes.has(error.code ?? ""))
373
+ throw error;
374
+ logWarn("API capability lease unavailable; skipping paid probes.");
375
+ return fallbackWithoutProbes();
376
+ }
377
+ });
378
+ }
379
+ export async function enrichPersistedApiCapabilities(...args) {
380
+ activeEnrichments++;
381
+ try {
382
+ return await enrichPersistedApiCapabilitiesImpl(...args);
383
+ }
384
+ finally {
385
+ activeEnrichments--;
386
+ }
387
+ }
@@ -1,14 +1,26 @@
1
1
  import type { Duplex } from "node:stream";
2
2
  import type { IncomingMessage, Server } from "node:http";
3
+ /** Safe operational evidence only: never persist upstream reason text or payloads. */
4
+ export interface WebSocketDisconnectDiagnostic {
5
+ at: number;
6
+ cause: "close" | "transport_error" | "invalid_event" | "buffer_limit" | "send_error";
7
+ stage: "awaiting_first_event" | "streaming";
8
+ closeCode?: number;
9
+ routeKind?: "oauth" | "api" | "zdr";
10
+ connectionAgeMs?: number;
11
+ eventCount?: number;
12
+ }
13
+ interface WebSocketStats {
14
+ connections: number;
15
+ upstreamConnections: number;
16
+ upstreamRequests: number;
17
+ lastDisconnect?: WebSocketDisconnectDiagnostic;
18
+ }
3
19
  /** Adapts only the transport; authenticated HTTP routing remains the policy authority. */
4
20
  export declare class ResponsesWebSocketGateway {
5
21
  private readonly upstreamFetch;
6
22
  private readonly options;
7
- readonly stats: {
8
- connections: number;
9
- upstreamConnections: number;
10
- upstreamRequests: number;
11
- };
23
+ readonly stats: WebSocketStats;
12
24
  private readonly contexts;
13
25
  private readonly scope;
14
26
  private readonly sessions;
@@ -28,3 +40,4 @@ export declare class ResponsesWebSocketGateway {
28
40
  private serve;
29
41
  close(): void;
30
42
  }
43
+ export {};