@juspay/neurolink 12.20.0 → 12.20.2

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/CHANGELOG.md +2 -6
  2. package/dist/browser/neurolink.min.js +400 -400
  3. package/dist/cli/commands/proxy.js +171 -25
  4. package/dist/cli/commands/telemetry.d.ts +4 -1
  5. package/dist/cli/commands/telemetry.js +113 -0
  6. package/dist/cli/utils/nativeTelemetry.d.ts +19 -0
  7. package/dist/cli/utils/nativeTelemetry.js +322 -0
  8. package/dist/proxy/bodyCapturePolicy.d.ts +6 -0
  9. package/dist/proxy/bodyCapturePolicy.js +132 -0
  10. package/dist/proxy/codexFallback.d.ts +27 -9
  11. package/dist/proxy/codexFallback.js +84 -13
  12. package/dist/proxy/openaiFormat.js +8 -1
  13. package/dist/proxy/otelLogSink.d.ts +11 -1
  14. package/dist/proxy/otelLogSink.js +174 -27
  15. package/dist/proxy/otlpLogTransport.d.ts +4 -2
  16. package/dist/proxy/otlpLogTransport.js +22 -7
  17. package/dist/proxy/requestLogger.js +11 -3
  18. package/dist/proxy/restartControl.js +18 -3
  19. package/dist/proxy/rollingProxyServer.js +48 -1
  20. package/dist/proxy/rollingWorkerProcess.js +87 -10
  21. package/dist/proxy/rollingWorkerProtocol.js +4 -2
  22. package/dist/proxy/rollingWorkerSupervisor.js +47 -10
  23. package/dist/proxy/socketWorkerRuntime.js +90 -4
  24. package/dist/proxy/updateChecker.d.ts +3 -3
  25. package/dist/proxy/updateChecker.js +16 -9
  26. package/dist/proxy/updateCoordinator.d.ts +10 -1
  27. package/dist/proxy/updateCoordinator.js +68 -1
  28. package/dist/proxy/updateState.d.ts +2 -0
  29. package/dist/proxy/updateState.js +15 -0
  30. package/dist/proxy/vertexAnthropicFallback.d.ts +17 -1
  31. package/dist/proxy/vertexAnthropicFallback.js +25 -1
  32. package/dist/server/routes/claudeProxyRoutes.d.ts +17 -0
  33. package/dist/server/routes/claudeProxyRoutes.js +53 -5
  34. package/dist/types/cli.d.ts +18 -0
  35. package/dist/types/providers.d.ts +8 -0
  36. package/dist/types/proxy.d.ts +117 -7
  37. package/dist/utils/anthropicCacheBreakpoints.d.ts +22 -1
  38. package/dist/utils/anthropicCacheBreakpoints.js +113 -0
  39. package/dist/utils/pricing.js +69 -2
  40. package/docs-site/static/search-index.json +1 -1
  41. package/package.json +1 -1
  42. package/scripts/observability/NATIVE-DURABLE.md +68 -0
  43. package/scripts/observability/otel-collector.proxy-native-durable.yaml +2 -2
  44. package/scripts/observability/proxy-telemetry-check.mjs +135 -50
@@ -0,0 +1,322 @@
1
+ import { createHash } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ import { lstat, mkdir, mkdtemp, readFile, rm, writeFile, } from "node:fs/promises";
5
+ import { dirname, isAbsolute, join, resolve } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import yaml from "js-yaml";
8
+ const execute = promisify(execFile);
9
+ const profilePath = fileURLToPath(new URL("../../../scripts/observability/otel-collector.proxy-native-durable.yaml", import.meta.url));
10
+ const mib = 1024 * 1024;
11
+ const stagedFiles = [
12
+ "collector.yaml",
13
+ "collector.env.json",
14
+ "proxy.env",
15
+ "doctor.env",
16
+ ];
17
+ const pendingChecks = [
18
+ "Run native-validate with the intended collector executable",
19
+ "Review source collector customizations against the staged standard profile; custom processors, TLS and exporter settings are not automatically copied",
20
+ "Create private queue/compaction directories (0700) on a volume with the declared quota; queue payload bounds do not enforce a disk quota",
21
+ "Apply and verify backend stream retention policies; preparation does not call the backend",
22
+ "Merge proxy and doctor environment snippets into existing settings; supply doctor backend authorization privately",
23
+ "Operator must back up config/service/env, explicitly activate through their service manager, and roll back those backups if health, export, stream or persistence checks fail",
24
+ "Reconcile fresh request, body and backend records after activation; validation alone is not runtime proof",
25
+ ];
26
+ function record(value) {
27
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
28
+ throw new Error("Expected an object in the native collector configuration");
29
+ }
30
+ return value;
31
+ }
32
+ function text(value, field) {
33
+ if (typeof value !== "string" || !value || /[\r\n\0]/.test(value)) {
34
+ throw new Error(`Missing or invalid ${field}`);
35
+ }
36
+ const resolved = value.replace(/\$\{env:([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g, (_match, key, fallback) => {
37
+ const replacement = process.env[key] ?? fallback;
38
+ if (replacement === undefined) {
39
+ throw new Error(`Set the source collector environment variable ${key} before preparation`);
40
+ }
41
+ return replacement;
42
+ });
43
+ if (!resolved || /\$\{|[\r\n\0]/.test(resolved)) {
44
+ throw new Error(`Unsupported substitution in ${field}`);
45
+ }
46
+ return resolved;
47
+ }
48
+ function positiveInteger(value, name, max) {
49
+ if (!Number.isSafeInteger(value) || value <= 0 || value > max) {
50
+ throw new Error(`${name} must be a positive integer no greater than ${max}`);
51
+ }
52
+ return value;
53
+ }
54
+ function privatePath(value, name) {
55
+ if (!isAbsolute(value) || /[\r\n\0]/.test(value)) {
56
+ throw new Error(`${name} must be an absolute path`);
57
+ }
58
+ const path = resolve(value);
59
+ if (path === dirname(path)) {
60
+ throw new Error(`${name} must not be a filesystem root`);
61
+ }
62
+ return path;
63
+ }
64
+ function loopbackListen(value, name) {
65
+ const endpoint = text(value, name);
66
+ const match = endpoint.match(/^127\.0\.0\.1:(\d+)$/);
67
+ if (!match) {
68
+ throw new Error(`${name} must use an explicit 127.0.0.1 listener`);
69
+ }
70
+ positiveInteger(Number(match[1]), name, 65535);
71
+ return endpoint;
72
+ }
73
+ function digest(value) {
74
+ return createHash("sha256").update(value).digest("hex");
75
+ }
76
+ /** Produce new private files only. Existing config, services and queues are untouched. */
77
+ export async function prepareNativeTelemetry(args) {
78
+ const output = privatePath(args.output, "output");
79
+ const queue = privatePath(args.queueDirectory, "queue-directory");
80
+ const compact = privatePath(args.compactionDirectory, "compaction-directory");
81
+ if ([queue, compact].some((path) => path === output ||
82
+ path.startsWith(`${output}/`) ||
83
+ output.startsWith(`${path}/`))) {
84
+ throw new Error("Staging and collector storage paths must not overlap");
85
+ }
86
+ if (queue === compact ||
87
+ queue.startsWith(`${compact}/`) ||
88
+ compact.startsWith(`${queue}/`)) {
89
+ throw new Error("Queue and compaction directories must be separate, non-nested paths");
90
+ }
91
+ const metadataMib = positiveInteger(args.metadataQueueMib, "metadata-queue-mib", 1024);
92
+ const bodyMib = positiveInteger(args.bodyQueueMib, "body-queue-mib", 4096);
93
+ const quotaMib = positiveInteger(args.diskQuotaMib, "disk-quota-mib", 1048576);
94
+ if (quotaMib < 2 * (3 * metadataMib + bodyMib)) {
95
+ throw new Error("disk-quota-mib must allow at least twice all four queue payload limits for storage and compaction overhead");
96
+ }
97
+ positiveInteger(args.metadataRetentionDays, "metadata-retention-days", 3650);
98
+ positiveInteger(args.bodyRetentionDays, "body-retention-days", 3650);
99
+ positiveInteger(args.bodyPort, "body-port", 65535);
100
+ const sourcePath = resolve(args.collectorConfig);
101
+ const source = await readFile(sourcePath, "utf8");
102
+ let config;
103
+ try {
104
+ config = record(yaml.load(source));
105
+ }
106
+ catch {
107
+ // YAML parser diagnostics can contain complete source lines with credentials.
108
+ throw new Error("Could not parse the source collector YAML; source details are suppressed");
109
+ }
110
+ const exporter = record(record(config.exporters)["otlphttp/openobserve"]);
111
+ const headers = record(exporter.headers);
112
+ const endpoint = text(exporter.endpoint, "OpenObserve endpoint");
113
+ let url;
114
+ try {
115
+ url = new URL(endpoint);
116
+ }
117
+ catch {
118
+ throw new Error("Invalid OpenObserve endpoint");
119
+ }
120
+ if (url.username ||
121
+ url.password ||
122
+ url.search ||
123
+ url.hash ||
124
+ !/^\/api\/[a-zA-Z0-9_-]+\/?$/.test(url.pathname) ||
125
+ !(url.protocol === "https:" ||
126
+ (url.protocol === "http:" &&
127
+ ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)))) {
128
+ throw new Error("OpenObserve endpoint must be /api/<organization>, without embedded credentials; require HTTPS outside loopback");
129
+ }
130
+ const authorization = text(headers.Authorization ?? headers.authorization, "OpenObserve authorization");
131
+ const metadataStream = text(headers["stream-name"] ?? "neurolink_proxy", "metadata stream");
132
+ const bodyStream = args.bodyStream ?? `${metadataStream}_bodies`;
133
+ if (![metadataStream, bodyStream].every((value) => /^[a-zA-Z0-9_]+$/.test(value)) ||
134
+ metadataStream === bodyStream) {
135
+ throw new Error("Metadata and body streams must have distinct alphanumeric/underscore names");
136
+ }
137
+ const receivers = record(config.receivers);
138
+ const metadataListen = loopbackListen(record(record(record(receivers.otlp ?? receivers["otlp/metadata"]).protocols)
139
+ .http).endpoint, "metadata listener");
140
+ const extensions = record(config.extensions ?? {});
141
+ const healthListen = loopbackListen(record(extensions.health_check ?? { endpoint: "127.0.0.1:14333" }).endpoint, "health listener");
142
+ const telemetry = record(record(config.service).telemetry ?? {});
143
+ const metrics = record(telemetry.metrics ?? {});
144
+ const readers = Array.isArray(metrics.readers) ? metrics.readers : [];
145
+ const prometheus = readers
146
+ .map((reader) => record(record(record(reader).pull ?? {}).exporter ?? {}).prometheus)
147
+ .find((value) => value !== undefined);
148
+ const metricsPort = prometheus ? Number(record(prometheus).port) : 14388;
149
+ if (prometheus && record(prometheus).host !== "127.0.0.1") {
150
+ throw new Error("Collector metrics must use an explicit 127.0.0.1 listener");
151
+ }
152
+ positiveInteger(metricsPort, "metrics port", 65535);
153
+ const ports = [
154
+ Number(metadataListen.split(":")[1]),
155
+ Number(healthListen.split(":")[1]),
156
+ metricsPort,
157
+ args.bodyPort,
158
+ ];
159
+ if (new Set(ports).size !== ports.length) {
160
+ throw new Error("Metadata, bodies, health and metrics must use distinct ports");
161
+ }
162
+ const profile = await readFile(profilePath, "utf8");
163
+ const env = {
164
+ NEUROLINK_OTEL_METADATA_LISTEN: metadataListen,
165
+ NEUROLINK_OTEL_BODIES_LISTEN: `127.0.0.1:${args.bodyPort}`,
166
+ NEUROLINK_OTEL_HEALTH_LISTEN: healthListen,
167
+ NEUROLINK_OTEL_METRICS_PORT: String(metricsPort),
168
+ NEUROLINK_OTEL_QUEUE_DIRECTORY: queue,
169
+ NEUROLINK_OTEL_COMPACTION_DIRECTORY: compact,
170
+ NEUROLINK_OTEL_METADATA_QUEUE_BYTES: String(metadataMib * mib),
171
+ NEUROLINK_OTEL_BODY_QUEUE_BYTES: String(bodyMib * mib),
172
+ NEUROLINK_OPENOBSERVE_OTLP_ENDPOINT: endpoint,
173
+ NEUROLINK_OPENOBSERVE_BASIC_AUTH: authorization,
174
+ NEUROLINK_PROXY_STREAM_HEADER: metadataStream,
175
+ NEUROLINK_PROXY_BODY_STREAM_HEADER: bodyStream,
176
+ };
177
+ // Every value in these public snippets is independently restricted above.
178
+ const proxyEnv = `NEUROLINK_PROXY_LOG_SINK=otel\nOTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://${metadataListen}/v1/logs\nNEUROLINK_PROXY_OTLP_BODIES_ENDPOINT=http://127.0.0.1:${args.bodyPort}/v1/logs\n`;
179
+ const doctorEnv = `NEUROLINK_OPENOBSERVE_URL=${url.origin}\nNEUROLINK_OPENOBSERVE_ORG=${url.pathname.split("/")[2]}\nNEUROLINK_PROXY_STREAM_HEADER=${metadataStream}\nNEUROLINK_PROXY_BODY_STREAM_HEADER=${bodyStream}\nNEUROLINK_OTEL_COLLECTOR_METRICS_URL=http://127.0.0.1:${metricsPort}/metrics\n`;
180
+ const contents = [
181
+ profile,
182
+ `${JSON.stringify(env, null, 2)}\n`,
183
+ proxyEnv,
184
+ doctorEnv,
185
+ ];
186
+ const manifest = {
187
+ schemaVersion: 1,
188
+ activationImplemented: false,
189
+ source: { path: sourcePath, sha256: digest(source) },
190
+ files: Object.fromEntries(stagedFiles.map((name, index) => [name, digest(contents[index])])),
191
+ metadataStream,
192
+ bodyStream,
193
+ collectorMinimumVersion: "0.160.0",
194
+ queuePayloadBytes: {
195
+ metadataPerSignal: metadataMib * mib,
196
+ bodies: bodyMib * mib,
197
+ total: (3 * metadataMib + bodyMib) * mib,
198
+ },
199
+ operatorPolicy: {
200
+ diskQuotaBytes: quotaMib * mib,
201
+ metadataRetentionDays: args.metadataRetentionDays,
202
+ bodyRetentionDays: args.bodyRetentionDays,
203
+ enforced: false,
204
+ },
205
+ pending: pendingChecks,
206
+ };
207
+ // Exclusive creation refuses an existing destination, including a symlink.
208
+ await mkdir(output, { mode: 0o700 });
209
+ try {
210
+ for (let index = 0; index < stagedFiles.length; index++) {
211
+ await writeFile(join(output, stagedFiles[index]), contents[index], {
212
+ mode: 0o600,
213
+ flag: "wx",
214
+ });
215
+ }
216
+ await writeFile(join(output, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600, flag: "wx" });
217
+ }
218
+ catch {
219
+ await rm(output, { recursive: true, force: true });
220
+ throw new Error("Could not write the private staging directory; incomplete staged files were removed");
221
+ }
222
+ return {
223
+ directory: output,
224
+ prepared: true,
225
+ activated: false,
226
+ metadataStream,
227
+ bodyStream,
228
+ pending: pendingChecks,
229
+ };
230
+ }
231
+ /** Runs only the collector's static validator, with test-owned temporary storage. */
232
+ export async function validateNativeTelemetry(args) {
233
+ const directory = privatePath(args.directory, "directory");
234
+ const binary = privatePath(args.collectorBin, "collector-bin");
235
+ const permissions = await lstat(directory);
236
+ if (!permissions.isDirectory() || (permissions.mode & 0o077) !== 0) {
237
+ throw new Error("Staging directory must be private (0700)");
238
+ }
239
+ for (const name of ["manifest.json", ...stagedFiles]) {
240
+ const permissions = await lstat(join(directory, name));
241
+ if (!permissions.isFile() || (permissions.mode & 0o077) !== 0) {
242
+ throw new Error("Staged files must be private regular files (0600)");
243
+ }
244
+ }
245
+ let manifest;
246
+ let env;
247
+ try {
248
+ manifest = record(JSON.parse(await readFile(join(directory, "manifest.json"), "utf8")));
249
+ env = record(JSON.parse(await readFile(join(directory, "collector.env.json"), "utf8")));
250
+ }
251
+ catch {
252
+ throw new Error("Invalid native telemetry stage; prepare a new directory");
253
+ }
254
+ if (manifest.schemaVersion !== 1 ||
255
+ manifest.activationImplemented !== false) {
256
+ throw new Error("Unsupported native telemetry staging manifest");
257
+ }
258
+ for (const name of stagedFiles) {
259
+ const file = join(directory, name);
260
+ if (digest(await readFile(file, "utf8")) !== record(manifest.files)[name]) {
261
+ throw new Error("Staged files changed or are not private; prepare a new directory before validation");
262
+ }
263
+ }
264
+ const source = record(manifest.source);
265
+ if (typeof source.path !== "string" ||
266
+ digest(await readFile(source.path, "utf8")) !== source.sha256) {
267
+ throw new Error("Source collector configuration changed; prepare again to avoid validating a stale migration");
268
+ }
269
+ const collectorEnv = {};
270
+ for (const [key, value] of Object.entries(env)) {
271
+ if (typeof value !== "string" ||
272
+ !/^(NEUROLINK_OTEL_|NEUROLINK_OPENOBSERVE_|NEUROLINK_PROXY_)/.test(key)) {
273
+ throw new Error("Invalid staged collector environment");
274
+ }
275
+ collectorEnv[key] = value;
276
+ }
277
+ let version;
278
+ try {
279
+ const result = await execute(binary, ["--version"], {
280
+ timeout: 15000,
281
+ maxBuffer: 65536,
282
+ });
283
+ const match = `${result.stdout}\n${result.stderr}`.match(/\b(\d+)\.(\d+)\.(\d+)([-+][\w.-]+)?\b/);
284
+ if (!match ||
285
+ match[4] ||
286
+ (Number(match[1]) === 0 && Number(match[2]) < 160)) {
287
+ throw new Error("unsupported");
288
+ }
289
+ version = `${match[1]}.${match[2]}.${match[3]}`;
290
+ }
291
+ catch {
292
+ throw new Error("Could not verify collector >= 0.160.0 (stable); executable diagnostics are suppressed to protect credentials");
293
+ }
294
+ const temporary = await mkdtemp(join(directory, "validation-"));
295
+ try {
296
+ await execute(binary, ["validate", "--config", join(directory, "collector.yaml")], {
297
+ env: {
298
+ ...process.env,
299
+ ...collectorEnv,
300
+ NEUROLINK_OTEL_QUEUE_DIRECTORY: join(temporary, "queue"),
301
+ NEUROLINK_OTEL_COMPACTION_DIRECTORY: join(temporary, "compact"),
302
+ },
303
+ timeout: 30000,
304
+ maxBuffer: 1048576,
305
+ });
306
+ }
307
+ catch {
308
+ throw new Error("Collector rejected the staged profile or validation timed out; executable diagnostics are suppressed to protect credentials");
309
+ }
310
+ finally {
311
+ await rm(temporary, { recursive: true, force: true });
312
+ }
313
+ return {
314
+ directory,
315
+ valid: true,
316
+ collectorVersion: version,
317
+ activated: false,
318
+ runtimeVerified: false,
319
+ pending: pendingChecks.slice(1),
320
+ };
321
+ }
322
+ //# sourceMappingURL=nativeTelemetry.js.map
@@ -0,0 +1,6 @@
1
+ import type { ProxyBodyCaptureEntry, ProxyBodyCapturePolicyOptions, ProxyBodyCapturePolicySnapshot, ProxyBodyCapturePublication, ProxyBodyDeliveryResult, StoredBodyArtifact } from "../types/index.js";
2
+ /** Diagnostic-only policy. Never receives or changes an inference body. */
3
+ export declare function createProxyBodyCapturePolicy(options?: ProxyBodyCapturePolicyOptions): {
4
+ snapshot(): ProxyBodyCapturePolicySnapshot;
5
+ publish(entry: ProxyBodyCaptureEntry, stored: StoredBodyArtifact, publish: () => Promise<ProxyBodyDeliveryResult | undefined>): Promise<ProxyBodyCapturePublication>;
6
+ };
@@ -0,0 +1,132 @@
1
+ const MAX_REFERENCES = 1024;
2
+ const REFERENCE_TTL_MS = 5 * 60_000;
3
+ /** Diagnostic-only policy. Never receives or changes an inference body. */
4
+ export function createProxyBodyCapturePolicy(options = {}) {
5
+ const now = options.now ?? (() => performance.now());
6
+ const configured = options.bytesPerMinute?.trim();
7
+ const budget = configured ? Number(configured) : null;
8
+ const invalidBudget = budget !== null && (!Number.isSafeInteger(budget) || budget <= 0);
9
+ const references = new Map();
10
+ let availableBytes = budget ?? Infinity;
11
+ let lastRefill = now();
12
+ let submittedBytes = 0;
13
+ let deduplicatedCaptures = 0;
14
+ let deduplicatedBytes = 0;
15
+ let policyExcludedCaptures = 0;
16
+ let policyExcludedBytes = 0;
17
+ const maintain = () => {
18
+ const at = now();
19
+ if (budget !== null && !invalidBudget) {
20
+ availableBytes = Math.min(budget, availableBytes + (Math.max(0, at - lastRefill) * budget) / 60_000);
21
+ }
22
+ lastRefill = at;
23
+ for (const [key, value] of references) {
24
+ if (value.expiresAt <= at) {
25
+ references.delete(key);
26
+ }
27
+ }
28
+ };
29
+ return {
30
+ snapshot() {
31
+ maintain();
32
+ return {
33
+ deduplicationEnabled: options.deduplicate !== false,
34
+ maxReferences: MAX_REFERENCES,
35
+ referenceTtlMs: REFERENCE_TTL_MS,
36
+ references: references.size,
37
+ byteBudgetPerMinute: invalidBudget ? null : budget,
38
+ invalidByteBudget: invalidBudget,
39
+ availableBytes: budget === null || invalidBudget ? null : Math.floor(availableBytes),
40
+ submittedBytes,
41
+ deduplicatedCaptures,
42
+ deduplicatedBytes,
43
+ policyExcludedCaptures,
44
+ policyExcludedBytes,
45
+ };
46
+ },
47
+ async publish(entry, stored, publish) {
48
+ maintain();
49
+ if (stored.redactedBody === undefined) {
50
+ return {};
51
+ }
52
+ const bytes = Buffer.byteLength(stored.redactedBody, "utf8");
53
+ const key = options.deduplicate !== false && stored.bodySha256 && entry.captureId
54
+ ? JSON.stringify([entry.requestId, stored.bodySha256, bytes])
55
+ : undefined;
56
+ const prior = key ? references.get(key) : undefined;
57
+ // Only detached diagnostic work waits. An uncertain export never becomes
58
+ // a reference, and references always point directly to an exported body.
59
+ if (prior &&
60
+ prior.reference.captureId !== entry.captureId &&
61
+ (await prior.acknowledged) &&
62
+ prior.expiresAt > now()) {
63
+ deduplicatedCaptures++;
64
+ deduplicatedBytes += bytes;
65
+ return {
66
+ delivery: {
67
+ status: "reference",
68
+ reason: "identical_redacted_payload",
69
+ },
70
+ reference: { ...prior.reference },
71
+ };
72
+ }
73
+ maintain();
74
+ if (invalidBudget || bytes > availableBytes) {
75
+ policyExcludedCaptures++;
76
+ policyExcludedBytes += bytes;
77
+ return {
78
+ delivery: {
79
+ status: "policy_excluded",
80
+ reason: invalidBudget
81
+ ? "body_byte_budget_invalid"
82
+ : "body_byte_budget_exhausted",
83
+ },
84
+ };
85
+ }
86
+ availableBytes -= bytes;
87
+ submittedBytes += bytes;
88
+ let acknowledge = () => { };
89
+ const candidate = key && entry.captureId && stored.bodySha256
90
+ ? {
91
+ reference: {
92
+ captureId: entry.captureId,
93
+ requestId: entry.requestId,
94
+ bodySha256: stored.bodySha256,
95
+ redactedBodyBytes: bytes,
96
+ exportedAt: "",
97
+ },
98
+ expiresAt: now() + REFERENCE_TTL_MS,
99
+ acknowledged: new Promise((resolve) => {
100
+ acknowledge = resolve;
101
+ }),
102
+ }
103
+ : undefined;
104
+ if (key && candidate) {
105
+ // Eviction only loses an optimization. It cannot invalidate an emitted
106
+ // reference, whose source remains an independently exported body.
107
+ if (references.size >= MAX_REFERENCES) {
108
+ const oldest = references.keys().next().value;
109
+ if (oldest !== undefined) {
110
+ references.delete(oldest);
111
+ }
112
+ }
113
+ references.set(key, candidate);
114
+ }
115
+ let accepted = false;
116
+ try {
117
+ const delivery = await publish();
118
+ accepted = delivery?.status === "transport_acknowledged";
119
+ if (candidate && accepted) {
120
+ candidate.reference.exportedAt = new Date().toISOString();
121
+ }
122
+ return { delivery };
123
+ }
124
+ finally {
125
+ acknowledge(accepted);
126
+ if (key && !accepted && references.get(key) === candidate) {
127
+ references.delete(key);
128
+ }
129
+ }
130
+ },
131
+ };
132
+ }
@@ -23,21 +23,39 @@ export declare class CodexFallbackStreamError extends Error {
23
23
  constructor(payload: Record<string, unknown>, eventType: string, usage?: CodexFallbackResult["usage"]);
24
24
  }
25
25
  /**
26
- * Stable per-conversation routing key for OpenAI prompt caching.
26
+ * Routing key that pins a request to the cache already holding its prefix.
27
27
  *
28
28
  * Anthropic caching is explicit — the client marks breakpoints and the provider
29
29
  * honours them. OpenAI caching is automatic: a prefix is reused only when the
30
30
  * request lands on infrastructure already holding it, and `prompt_cache_key` is
31
- * what pins a conversation to one. Without it this hop takes its chances on
32
- * routing, which is the worst case for a fallback that arrives in bursts
33
- * separated by long gaps.
31
+ * what pins a request to one. Without it this hop takes its chances on routing,
32
+ * which is the worst case for a fallback that arrives in bursts separated by
33
+ * long gaps.
34
34
  *
35
- * Claude Code carries its session id inside `metadata.user_id`, alongside
36
- * account and device identifiers. Only the session id is read, and it is
37
- * hashed, so no account or device identifier reaches the upstream. When no
38
- * session id can be recovered the field is omitted rather than filled with
39
- * something shared: a key common to every conversation would pin unrelated
35
+ * The key must therefore describe the *prefix*, not the caller. Keying it by
36
+ * session id gives every session a private namespace, so N agents that share
37
+ * one system prompt and tool set each pay that shared prefix in full rather
38
+ * than paying it once — the cost is linear in fan-out width and invisible,
39
+ * because each request looks individually well-behaved. Across 42 captured
40
+ * fallback bodies the prefixes collapse to 10 distinct values, the largest
41
+ * shared by 12 requests belonging to different sessions.
42
+ *
43
+ * So derive the key from the prefix itself: the instructions and the tool
44
+ * declarations, which are exactly the leading bytes the upstream caches and
45
+ * the only part stable across a conversation's turns. Identical prefixes then
46
+ * share one cache across sessions and subagents, a prefix that genuinely
47
+ * differs still gets its own key so nothing unrelated contends, and turns
48
+ * within one session keep sharing a key because neither input changes between
49
+ * them.
50
+ *
51
+ * A request carrying neither instructions nor tools has no prefix worth
52
+ * pinning, and falls back to the session id so its turns at least stay
53
+ * together. With neither available the field is omitted rather than filled
54
+ * with something shared: a key common to every request would pin unrelated
40
55
  * prefixes onto one cache instead of separating them.
56
+ *
57
+ * The digest covers only content already sent upstream in `instructions` and
58
+ * `tools`; no account, device or session identifier reaches the wire.
41
59
  */
42
60
  export declare function codexPromptCacheKey(body: ClaudeRequest): string | undefined;
43
61
  /** Convert a Claude Messages request into the ChatGPT Codex Responses shape. */
@@ -237,23 +237,95 @@ function convertClaudeMessage(role, content) {
237
237
  return input;
238
238
  }
239
239
  /**
240
- * Stable per-conversation routing key for OpenAI prompt caching.
240
+ * Routing key that pins a request to the cache already holding its prefix.
241
241
  *
242
242
  * Anthropic caching is explicit — the client marks breakpoints and the provider
243
243
  * honours them. OpenAI caching is automatic: a prefix is reused only when the
244
244
  * request lands on infrastructure already holding it, and `prompt_cache_key` is
245
- * what pins a conversation to one. Without it this hop takes its chances on
246
- * routing, which is the worst case for a fallback that arrives in bursts
247
- * separated by long gaps.
245
+ * what pins a request to one. Without it this hop takes its chances on routing,
246
+ * which is the worst case for a fallback that arrives in bursts separated by
247
+ * long gaps.
248
248
  *
249
- * Claude Code carries its session id inside `metadata.user_id`, alongside
250
- * account and device identifiers. Only the session id is read, and it is
251
- * hashed, so no account or device identifier reaches the upstream. When no
252
- * session id can be recovered the field is omitted rather than filled with
253
- * something shared: a key common to every conversation would pin unrelated
249
+ * The key must therefore describe the *prefix*, not the caller. Keying it by
250
+ * session id gives every session a private namespace, so N agents that share
251
+ * one system prompt and tool set each pay that shared prefix in full rather
252
+ * than paying it once — the cost is linear in fan-out width and invisible,
253
+ * because each request looks individually well-behaved. Across 42 captured
254
+ * fallback bodies the prefixes collapse to 10 distinct values, the largest
255
+ * shared by 12 requests belonging to different sessions.
256
+ *
257
+ * So derive the key from the prefix itself: the instructions and the tool
258
+ * declarations, which are exactly the leading bytes the upstream caches and
259
+ * the only part stable across a conversation's turns. Identical prefixes then
260
+ * share one cache across sessions and subagents, a prefix that genuinely
261
+ * differs still gets its own key so nothing unrelated contends, and turns
262
+ * within one session keep sharing a key because neither input changes between
263
+ * them.
264
+ *
265
+ * A request carrying neither instructions nor tools has no prefix worth
266
+ * pinning, and falls back to the session id so its turns at least stay
267
+ * together. With neither available the field is omitted rather than filled
268
+ * with something shared: a key common to every request would pin unrelated
254
269
  * prefixes onto one cache instead of separating them.
270
+ *
271
+ * The digest covers only content already sent upstream in `instructions` and
272
+ * `tools`; no account, device or session identifier reaches the wire.
255
273
  */
256
274
  export function codexPromptCacheKey(body) {
275
+ const prefix = codexCachePrefix(body);
276
+ if (prefix !== undefined) {
277
+ return createHash("sha256").update(prefix).digest("hex");
278
+ }
279
+ const sessionId = codexSessionId(body);
280
+ if (sessionId !== undefined) {
281
+ return createHash("sha256").update(sessionId).digest("hex");
282
+ }
283
+ return undefined;
284
+ }
285
+ /**
286
+ * How much of the instructions the key covers.
287
+ *
288
+ * Claude Code's system prompt is not byte-stable: measured across live 16,722
289
+ * character prompts, the first 14,768 characters (88.3%) are identical and the
290
+ * divergence sits in a trailing session-memory section that is rewritten every
291
+ * turn. Hashing the whole thing therefore produces a fresh key per request —
292
+ * the opposite of pinning. Hashing a bounded head instead tracks the part the
293
+ * upstream can actually reuse, since its cache is built forward from the first
294
+ * token.
295
+ *
296
+ * 8192 is below the observed divergence point with margin, and the error is
297
+ * asymmetric: too long only costs sharing, while too short pins genuinely
298
+ * unrelated prefixes onto one cache where they evict each other.
299
+ *
300
+ * The bound only helps a prompt long enough to have a tail beyond it. A
301
+ * shorter prompt is hashed whole, so a churning section inside it would move
302
+ * the key every turn — where keying by session used to hold still. Measured on
303
+ * the same corpus, that does not happen: 85 of 92 prompts carrying a system
304
+ * block sit under this bound, and within a session they resolve to a handful
305
+ * of keys rather than one per request (28 requests to 4 keys, 17 to 2, 31 to
306
+ * 2) — those are distinct agents, not one agent churning. Identifying the
307
+ * stable section structurally would remove the assumption, and is worth doing
308
+ * if a prompt ever proves otherwise.
309
+ */
310
+ const CACHE_KEY_PREFIX_CHARS = 8192;
311
+ /**
312
+ * The leading, turn-stable bytes of a Codex request: a bounded head of the
313
+ * instructions plus the tool names, which distinguish one agent's tool surface
314
+ * from another's when their instructions open identically. Descriptions and
315
+ * schemas are deliberately left out — they sit behind the instructions in the
316
+ * prefix, so two requests agreeing on this much already share a cacheable head
317
+ * worth routing together.
318
+ */
319
+ function codexCachePrefix(body) {
320
+ const head = (buildSystemInstructions(body) ?? "").slice(0, CACHE_KEY_PREFIX_CHARS);
321
+ const toolNames = (body.tools ?? []).map((tool) => tool.name).join("\u0000");
322
+ if (!head && !toolNames) {
323
+ return undefined;
324
+ }
325
+ return `${head}\u0001${toolNames}`;
326
+ }
327
+ /** Claude Code carries its session id inside `metadata.user_id`. */
328
+ function codexSessionId(body) {
257
329
  const raw = body.metadata?.user_id;
258
330
  if (typeof raw !== "string" || raw.length === 0) {
259
331
  return undefined;
@@ -270,10 +342,9 @@ export function codexPromptCacheKey(body) {
270
342
  }
271
343
  const fields = parsed;
272
344
  const sessionId = fields.session_id ?? fields.parent_session_id;
273
- if (typeof sessionId !== "string" || sessionId.length === 0) {
274
- return undefined;
275
- }
276
- return createHash("sha256").update(sessionId).digest("hex");
345
+ return typeof sessionId === "string" && sessionId.length > 0
346
+ ? sessionId
347
+ : undefined;
277
348
  }
278
349
  /** Convert a Claude Messages request into the ChatGPT Codex Responses shape. */
279
350
  export function convertClaudeRequestToCodex(body, model, reasoningEffort) {
@@ -11,6 +11,7 @@
11
11
  import { jsonSchema, tool } from "../utils/tool.js";
12
12
  import { randomBytes } from "crypto";
13
13
  import { normalizeJsonSchemaObject } from "../utils/schemaConversion.js";
14
+ import { applyClaudeRequestCacheBreakpoints } from "../utils/anthropicCacheBreakpoints.js";
14
15
  // ---------------------------------------------------------------------------
15
16
  // Helpers
16
17
  // ---------------------------------------------------------------------------
@@ -603,7 +604,13 @@ export function convertOpenAIToClaudeRequest(openai) {
603
604
  ? openai.stop
604
605
  : [openai.stop];
605
606
  }
606
- return result;
607
+ // An OpenAI-shaped caller has no way to ask for caching: its provider does it
608
+ // automatically from the first token, so the format carries no equivalent of
609
+ // `cache_control`. Translating faithfully therefore produces a request
610
+ // Anthropic caches at 0% on every turn — and a caller that arrives here after
611
+ // its own pool ran dry would burn the replacement quota faster than the one
612
+ // it was spared. Synthesise the breakpoints the caller could not express.
613
+ return applyClaudeRequestCacheBreakpoints(result);
607
614
  }
608
615
  /**
609
616
  * Convert a non-streaming Claude Messages response to an OpenAI Chat