agenticreplay 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/node_modules/@agenticreplay/adapters/dist/claude-code.d.ts.map +1 -1
- package/node_modules/@agenticreplay/adapters/dist/claude-code.js +0 -8
- package/node_modules/@agenticreplay/adapters/dist/claude-code.js.map +1 -1
- package/node_modules/@agenticreplay/adapters/dist/codex.d.ts.map +1 -1
- package/node_modules/@agenticreplay/adapters/dist/codex.js +0 -18
- package/node_modules/@agenticreplay/adapters/dist/codex.js.map +1 -1
- package/node_modules/@agenticreplay/adapters/dist/contract.d.ts +1 -1
- package/node_modules/@agenticreplay/adapters/dist/contract.d.ts.map +1 -1
- package/node_modules/@agenticreplay/adapters/dist/contract.js +0 -46
- package/node_modules/@agenticreplay/adapters/dist/contract.js.map +1 -1
- package/node_modules/@agenticreplay/adapters/dist/indexrag.d.ts.map +1 -1
- package/node_modules/@agenticreplay/adapters/dist/indexrag.js +0 -6
- package/node_modules/@agenticreplay/adapters/dist/indexrag.js.map +1 -1
- package/node_modules/@agenticreplay/adapters/package.json +6 -6
- package/node_modules/@agenticreplay/core/dist/graph.d.ts +0 -24
- package/node_modules/@agenticreplay/core/dist/graph.d.ts.map +1 -1
- package/node_modules/@agenticreplay/core/dist/graph.js +0 -68
- package/node_modules/@agenticreplay/core/dist/graph.js.map +1 -1
- package/node_modules/@agenticreplay/core/dist/paths.d.ts +1 -12
- package/node_modules/@agenticreplay/core/dist/paths.d.ts.map +1 -1
- package/node_modules/@agenticreplay/core/dist/paths.js +1 -22
- package/node_modules/@agenticreplay/core/dist/paths.js.map +1 -1
- package/node_modules/@agenticreplay/core/dist/writer.d.ts +0 -9
- package/node_modules/@agenticreplay/core/dist/writer.d.ts.map +1 -1
- package/node_modules/@agenticreplay/core/dist/writer.js +0 -5
- package/node_modules/@agenticreplay/core/dist/writer.js.map +1 -1
- package/node_modules/@agenticreplay/core/package.json +3 -3
- package/node_modules/@agenticreplay/fs-capture/dist/capture.d.ts +4 -15
- package/node_modules/@agenticreplay/fs-capture/dist/capture.d.ts.map +1 -1
- package/node_modules/@agenticreplay/fs-capture/dist/capture.js +22 -33
- package/node_modules/@agenticreplay/fs-capture/dist/capture.js.map +1 -1
- package/node_modules/@agenticreplay/fs-capture/dist/shadow.d.ts +0 -39
- package/node_modules/@agenticreplay/fs-capture/dist/shadow.d.ts.map +1 -1
- package/node_modules/@agenticreplay/fs-capture/dist/shadow.js +0 -63
- package/node_modules/@agenticreplay/fs-capture/dist/shadow.js.map +1 -1
- package/node_modules/@agenticreplay/fs-capture/package.json +2 -2
- package/node_modules/@agenticreplay/mcp-shim/dist/cli.d.ts +0 -2
- package/node_modules/@agenticreplay/mcp-shim/dist/cli.d.ts.map +1 -1
- package/node_modules/@agenticreplay/mcp-shim/dist/cli.js +2 -92
- package/node_modules/@agenticreplay/mcp-shim/dist/cli.js.map +1 -1
- package/node_modules/@agenticreplay/mcp-shim/dist/index.d.ts +0 -1
- package/node_modules/@agenticreplay/mcp-shim/dist/index.d.ts.map +1 -1
- package/node_modules/@agenticreplay/mcp-shim/dist/index.js +0 -1
- package/node_modules/@agenticreplay/mcp-shim/dist/index.js.map +1 -1
- package/node_modules/@agenticreplay/mcp-shim/package.json +1 -1
- package/node_modules/@agenticreplay/node-instrument/package.json +1 -1
- package/node_modules/@agenticreplay/plugin-api/dist/plugins.d.ts +9 -212
- package/node_modules/@agenticreplay/plugin-api/dist/plugins.d.ts.map +1 -1
- package/node_modules/@agenticreplay/plugin-api/package.json +1 -1
- package/node_modules/@agenticreplay/providers/package.json +3 -3
- package/node_modules/@agenticreplay/proxy/dist/dialects.d.ts +3 -29
- package/node_modules/@agenticreplay/proxy/dist/dialects.d.ts.map +1 -1
- package/node_modules/@agenticreplay/proxy/dist/dialects.js +3 -69
- package/node_modules/@agenticreplay/proxy/dist/dialects.js.map +1 -1
- package/node_modules/@agenticreplay/proxy/dist/index.d.ts +0 -2
- package/node_modules/@agenticreplay/proxy/dist/index.d.ts.map +1 -1
- package/node_modules/@agenticreplay/proxy/dist/index.js +0 -2
- package/node_modules/@agenticreplay/proxy/dist/index.js.map +1 -1
- package/node_modules/@agenticreplay/proxy/dist/intercept.d.ts +0 -48
- package/node_modules/@agenticreplay/proxy/dist/intercept.d.ts.map +1 -1
- package/node_modules/@agenticreplay/proxy/dist/intercept.js +32 -157
- package/node_modules/@agenticreplay/proxy/dist/intercept.js.map +1 -1
- package/node_modules/@agenticreplay/proxy/dist/server.d.ts +2 -148
- package/node_modules/@agenticreplay/proxy/dist/server.d.ts.map +1 -1
- package/node_modules/@agenticreplay/proxy/dist/server.js +25 -874
- package/node_modules/@agenticreplay/proxy/dist/server.js.map +1 -1
- package/node_modules/@agenticreplay/proxy/package.json +5 -5
- package/node_modules/@agenticreplay/schema/package.json +1 -1
- package/node_modules/@agenticreplay/shell-shim/package.json +3 -3
- package/node_modules/@agenticreplay/viewer/package.json +2 -2
- package/package.json +13 -14
- package/packages/cli/dist/api.d.ts +1 -27
- package/packages/cli/dist/api.d.ts.map +1 -1
- package/packages/cli/dist/api.js +2 -31
- package/packages/cli/dist/api.js.map +1 -1
- package/packages/cli/dist/args.d.ts.map +1 -1
- package/packages/cli/dist/args.js +0 -7
- package/packages/cli/dist/args.js.map +1 -1
- package/packages/cli/dist/commands/attach.d.ts +1 -4
- package/packages/cli/dist/commands/attach.d.ts.map +1 -1
- package/packages/cli/dist/commands/attach.js +24 -109
- package/packages/cli/dist/commands/attach.js.map +1 -1
- package/packages/cli/dist/commands/cost.d.ts +3 -0
- package/packages/cli/dist/commands/cost.d.ts.map +1 -0
- package/packages/cli/dist/commands/cost.js +13 -0
- package/packages/cli/dist/commands/cost.js.map +1 -0
- package/packages/cli/dist/commands/gc.d.ts +0 -2
- package/packages/cli/dist/commands/gc.d.ts.map +1 -1
- package/packages/cli/dist/commands/gc.js +3 -47
- package/packages/cli/dist/commands/gc.js.map +1 -1
- package/packages/cli/dist/commands/inspect.d.ts +0 -2
- package/packages/cli/dist/commands/inspect.d.ts.map +1 -1
- package/packages/cli/dist/commands/inspect.js +4 -30
- package/packages/cli/dist/commands/inspect.js.map +1 -1
- package/packages/cli/dist/commands/quickstart.d.ts +2 -34
- package/packages/cli/dist/commands/quickstart.d.ts.map +1 -1
- package/packages/cli/dist/commands/quickstart.js +32 -340
- package/packages/cli/dist/commands/quickstart.js.map +1 -1
- package/packages/cli/dist/commands/record.d.ts.map +1 -1
- package/packages/cli/dist/commands/record.js +34 -103
- package/packages/cli/dist/commands/record.js.map +1 -1
- package/packages/cli/dist/commands/setup.d.ts.map +1 -1
- package/packages/cli/dist/commands/setup.js +1 -30
- package/packages/cli/dist/commands/setup.js.map +1 -1
- package/packages/cli/dist/commands/sync.js +1 -1
- package/packages/cli/dist/commands/sync.js.map +1 -1
- package/packages/cli/dist/config.d.ts +1 -4
- package/packages/cli/dist/config.d.ts.map +1 -1
- package/packages/cli/dist/config.js.map +1 -1
- package/packages/cli/dist/flags.d.ts.map +1 -1
- package/packages/cli/dist/flags.js +4 -45
- package/packages/cli/dist/flags.js.map +1 -1
- package/packages/cli/dist/fs-events.d.ts +2 -13
- package/packages/cli/dist/fs-events.d.ts.map +1 -1
- package/packages/cli/dist/fs-events.js +5 -64
- package/packages/cli/dist/fs-events.js.map +1 -1
- package/packages/cli/dist/index.d.ts +0 -2
- package/packages/cli/dist/index.d.ts.map +1 -1
- package/packages/cli/dist/index.js +0 -2
- package/packages/cli/dist/index.js.map +1 -1
- package/packages/cli/dist/main.d.ts.map +1 -1
- package/packages/cli/dist/main.js +12 -67
- package/packages/cli/dist/main.js.map +1 -1
- package/packages/cli/dist/mcp-server.d.ts.map +1 -1
- package/packages/cli/dist/mcp-server.js +3 -86
- package/packages/cli/dist/mcp-server.js.map +1 -1
- package/packages/cli/dist/mcp.d.ts +1 -73
- package/packages/cli/dist/mcp.d.ts.map +1 -1
- package/packages/cli/dist/mcp.js +3 -113
- package/packages/cli/dist/mcp.js.map +1 -1
- package/packages/cli/dist/share-card.d.ts +0 -20
- package/packages/cli/dist/share-card.d.ts.map +1 -1
- package/packages/cli/dist/share-card.js +0 -63
- package/packages/cli/dist/share-card.js.map +1 -1
- package/packages/cli/dist/snapshot.d.ts +2 -5
- package/packages/cli/dist/snapshot.d.ts.map +1 -1
- package/packages/cli/dist/snapshot.js +2 -3
- package/packages/cli/dist/snapshot.js.map +1 -1
- package/packages/cli/dist/tls-capture.d.ts +1 -17
- package/packages/cli/dist/tls-capture.d.ts.map +1 -1
- package/packages/cli/dist/tls-capture.js +7 -77
- package/packages/cli/dist/tls-capture.js.map +1 -1
- package/packages/cli/package.json +11 -11
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
3
|
import { AUTH_REQUEST_HEADERS, AUTH_RESPONSE_HEADERS, BINARY_BODY_PREFIX, Redactor, fetchPinned, } from '@agenticreplay/core';
|
|
4
|
-
import { anthropicToCanonicalRequest, anthropicToCanonicalResponse,
|
|
4
|
+
import { anthropicToCanonicalRequest, anthropicToCanonicalResponse, openaiToCanonicalRequest, openaiToCanonicalResponse, parseAnthropicSse, parseOpenaiSse, parseResponsesSse, responsesToCanonicalRequest, responsesToCanonicalResponse, } from '@agenticreplay/providers';
|
|
5
5
|
import { anthropicDialect, codexDialect, openaiDialect, responsesDialect, selectDialect, } from './dialects.js';
|
|
6
6
|
import { decodeForwardPath } from './forward.js';
|
|
7
|
-
import { consume, defaultRetrievalRules, indexRetrievals, selectRetrievalRule, } from './retrieval.js';
|
|
8
7
|
import { attachTlsIntercept, decodeBody, DEFAULT_MAX_CAPTURED_BYTES, } from './intercept.js';
|
|
9
|
-
import { RequestMatcher } from './matching.js';
|
|
10
8
|
function scalarHeaders(headers) {
|
|
11
9
|
const out = {};
|
|
12
10
|
for (const [key, value] of Object.entries(headers)) {
|
|
@@ -87,24 +85,6 @@ function captureEncodingTransformation(location, encoding) {
|
|
|
87
85
|
},
|
|
88
86
|
];
|
|
89
87
|
}
|
|
90
|
-
function protocolTranslationTransformation(from, to, location = 'response.body') {
|
|
91
|
-
return {
|
|
92
|
-
kind: 'encoding',
|
|
93
|
-
location,
|
|
94
|
-
rule: 'provider-protocol-translation',
|
|
95
|
-
count: 1,
|
|
96
|
-
reason: `${from} response translated to ${to} for the client`,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
function modelOverrideTransformation(from, to) {
|
|
100
|
-
return {
|
|
101
|
-
kind: 'encoding',
|
|
102
|
-
location: 'request.body',
|
|
103
|
-
rule: 'model-override-and-serialization',
|
|
104
|
-
count: 1,
|
|
105
|
-
reason: `${from} request model overridden with ${to} and serialized for the live fork`,
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
88
|
function captureUrlTransformations(origin) {
|
|
109
89
|
const scrubbed = recordableOrigin(origin);
|
|
110
90
|
if (scrubbed === undefined || scrubbed === origin)
|
|
@@ -132,44 +112,6 @@ function binaryBodyTransformation(location, body) {
|
|
|
132
112
|
},
|
|
133
113
|
];
|
|
134
114
|
}
|
|
135
|
-
/**
|
|
136
|
-
* Beta flags that belong to the model that was recorded, not to the request.
|
|
137
|
-
*
|
|
138
|
-
* `anthropic-beta` is a header, so substituting a model in the body leaves it untouched — and a
|
|
139
|
-
* fork of a run made on a 1M-context model onto one without that entitlement comes back
|
|
140
|
-
* `400 The long context beta is not yet available for this subscription`. The failure names the
|
|
141
|
-
* subscription rather than the flag, so it reads as an account problem and not as agenticreplay carrying
|
|
142
|
-
* over something it should have dropped.
|
|
143
|
-
*
|
|
144
|
-
* Only entitlement-gated flags are removed. The rest of the header is what the harness needs to
|
|
145
|
-
* speak its own protocol — tool shapes, output formats — and dropping those would break the fork
|
|
146
|
-
* in a way that is much harder to see than a 400.
|
|
147
|
-
*/
|
|
148
|
-
const MODEL_GATED_BETAS = [/^context-\d+m\b/i];
|
|
149
|
-
/** `anthropic-beta` with the recorded model's entitlements taken out, or undefined if empty. */
|
|
150
|
-
export function betasForModelChange(value) {
|
|
151
|
-
const kept = value
|
|
152
|
-
.split(',')
|
|
153
|
-
.map((flag) => flag.trim())
|
|
154
|
-
.filter((flag) => flag !== '' && !MODEL_GATED_BETAS.some((re) => re.test(flag)));
|
|
155
|
-
return kept.length > 0 ? kept.join(',') : undefined;
|
|
156
|
-
}
|
|
157
|
-
/** The outbound headers for a live call, reconciled with a model the recording did not use. */
|
|
158
|
-
function headersForModel(headers, forkModel) {
|
|
159
|
-
if (forkModel === undefined)
|
|
160
|
-
return headers;
|
|
161
|
-
const out = {};
|
|
162
|
-
for (const [key, value] of Object.entries(headers)) {
|
|
163
|
-
if (key.toLowerCase() !== 'anthropic-beta') {
|
|
164
|
-
out[key] = value;
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
const kept = betasForModelChange(value);
|
|
168
|
-
if (kept !== undefined)
|
|
169
|
-
out[key] = kept;
|
|
170
|
-
}
|
|
171
|
-
return out;
|
|
172
|
-
}
|
|
173
115
|
/**
|
|
174
116
|
* An origin with the parts a credential rides in taken out.
|
|
175
117
|
*
|
|
@@ -359,26 +301,17 @@ export function defaultDialects() {
|
|
|
359
301
|
toCanonicalRequest: anthropicToCanonicalRequest,
|
|
360
302
|
toCanonicalResponse: anthropicToCanonicalResponse,
|
|
361
303
|
parseSse: parseAnthropicSse,
|
|
362
|
-
fromCanonicalRequest: canonicalToAnthropicRequest,
|
|
363
|
-
fromCanonicalResponse: canonicalToAnthropicResponse,
|
|
364
|
-
toSse: canonicalToAnthropicSse,
|
|
365
304
|
}),
|
|
366
305
|
openaiDialect({
|
|
367
306
|
toCanonicalRequest: openaiToCanonicalRequest,
|
|
368
307
|
toCanonicalResponse: openaiToCanonicalResponse,
|
|
369
308
|
parseSse: parseOpenaiSse,
|
|
370
|
-
fromCanonicalRequest: canonicalToOpenaiRequest,
|
|
371
|
-
fromCanonicalResponse: canonicalToOpenaiResponse,
|
|
372
|
-
toSse: canonicalToOpenaiSse,
|
|
373
309
|
}),
|
|
374
310
|
// After the chat dialect deliberately: see the note on `responsesDialect`.
|
|
375
311
|
responsesDialect({
|
|
376
312
|
toCanonicalRequest: responsesToCanonicalRequest,
|
|
377
313
|
toCanonicalResponse: responsesToCanonicalResponse,
|
|
378
314
|
parseSse: parseResponsesSse,
|
|
379
|
-
fromCanonicalRequest: canonicalToResponsesRequest,
|
|
380
|
-
fromCanonicalResponse: canonicalToResponsesResponse,
|
|
381
|
-
toSse: canonicalToResponsesSse,
|
|
382
315
|
}),
|
|
383
316
|
];
|
|
384
317
|
}
|
|
@@ -444,75 +377,16 @@ export async function createProxy(options) {
|
|
|
444
377
|
//
|
|
445
378
|
// `fetchImpl` stays the seam the tests inject through; only the default moves.
|
|
446
379
|
const doFetch = options.fetchImpl ?? fetchPinned;
|
|
447
|
-
const recorded = options.exchanges ?? [];
|
|
448
380
|
const captured = [];
|
|
449
381
|
// Plain base-URL traffic needs the same aggregate bound as intercepted traffic. This counter
|
|
450
382
|
// covers bodies retained by concurrent fetch responses while each response still streams to the
|
|
451
383
|
// caller; it is released only after that response has been materialized into evidence.
|
|
452
384
|
let plainBufferedBytes = 0;
|
|
453
385
|
const plainBufferLimit = options.maxBufferedBytes ?? 256 * 1024 * 1024;
|
|
454
|
-
const stats = {
|
|
455
|
-
mode: options.mode,
|
|
456
|
-
recorded: recorded.length,
|
|
457
|
-
matchedExact: 0,
|
|
458
|
-
matchedInexact: 0,
|
|
459
|
-
divergences: 0,
|
|
460
|
-
liveCalls: 0,
|
|
461
|
-
unmatched: 0,
|
|
462
|
-
intercepted: 0,
|
|
463
|
-
tunnelled: 0,
|
|
464
|
-
passedThrough: 0,
|
|
465
|
-
retrievalServed: 0,
|
|
466
|
-
retrievalTotal: 0,
|
|
467
|
-
retrievalLive: 0,
|
|
468
|
-
retrievalReordered: 0,
|
|
469
|
-
reordered: 0,
|
|
470
|
-
};
|
|
471
|
-
// In hybrid mode only the exchanges below the fork point are replayable; everything at or above
|
|
472
|
-
// it must go live, which is exactly what makes a fork a fork.
|
|
473
|
-
const replayable = options.mode === 'hybrid' ? recorded.slice(0, options.forkAt ?? recorded.length) : recorded;
|
|
474
|
-
/**
|
|
475
|
-
* The most requests this proxy has had open at once, and the count it is derived from.
|
|
476
|
-
*
|
|
477
|
-
* Only the proxy can know this, and the matcher needs it: a pipeline that issues ten requests
|
|
478
|
-
* together is answered in whatever order the origin finished them, so a replay's request can
|
|
479
|
-
* arrive up to nine positions from the cursor. See {@link LOOKAHEAD}.
|
|
480
|
-
*/
|
|
481
|
-
let inFlight = 0;
|
|
482
|
-
let peakInFlight = 0;
|
|
483
|
-
function opened() {
|
|
484
|
-
inFlight += 1;
|
|
485
|
-
if (inFlight > peakInFlight)
|
|
486
|
-
peakInFlight = inFlight;
|
|
487
|
-
}
|
|
488
|
-
function closed() {
|
|
489
|
-
if (inFlight > 0)
|
|
490
|
-
inFlight -= 1;
|
|
491
|
-
}
|
|
492
|
-
// The recorded requests carry placeholders where secrets were; a live replay request carries the
|
|
493
|
-
// secrets themselves. Without a redactor on this side the two are never in the same
|
|
494
|
-
// representation, and a recording of any real harness — whose own system prompt carries a
|
|
495
|
-
// session id — cannot match itself.
|
|
496
|
-
// Shared with the passthrough failure path, which writes an upstream's own error message into
|
|
497
|
-
// the trace: a gateway that quotes the call it refused can quote the credential with it, and §7
|
|
498
|
-
// says never write it — not "write it and let a later layer catch it".
|
|
386
|
+
const stats = { liveCalls: 0, intercepted: 0, tunnelled: 0, passedThrough: 0 };
|
|
499
387
|
const redactor = new Redactor();
|
|
500
|
-
const matcher = new RequestMatcher(replayable.map((e) => e.canonicalRequest), { redactor, concurrency: () => peakInFlight });
|
|
501
|
-
/**
|
|
502
|
-
* The recording's retrieval calls, by key, ready to be consumed one at a time.
|
|
503
|
-
*
|
|
504
|
-
* Not filtered by the fork point. A fork keeps replaying these past the cursor on purpose: the
|
|
505
|
-
* vector for a paragraph does not depend on which chat model answers the question afterwards,
|
|
506
|
-
* so re-embedding it live would spend money to recompute an identical value and leave the
|
|
507
|
-
* fork's index differing from its parent's for no reason. See `RetrievalRule.forkable`.
|
|
508
|
-
*/
|
|
509
|
-
const retrievalRules = options.retrievalRules ?? defaultRetrievalRules();
|
|
510
|
-
const retrievalIndex = indexRetrievals(options.retrievals ?? []);
|
|
511
|
-
stats.retrievalTotal = (options.retrievals ?? []).length;
|
|
512
388
|
const server = createServer((req, res) => {
|
|
513
|
-
|
|
514
|
-
void handle(req, res)
|
|
515
|
-
.catch((err) => {
|
|
389
|
+
void handle(req, res).catch((err) => {
|
|
516
390
|
// Whatever failed, its message may name the origin it was given — and this body reaches
|
|
517
391
|
// the agent, which prints it. See {@link withoutCredentials}.
|
|
518
392
|
if (res.destroyed || res.writableEnded)
|
|
@@ -522,8 +396,7 @@ export async function createProxy(options) {
|
|
|
522
396
|
return;
|
|
523
397
|
}
|
|
524
398
|
json(res, 500, { error: { message: withoutCredentials(String(err)) } });
|
|
525
|
-
})
|
|
526
|
-
.finally(closed);
|
|
399
|
+
});
|
|
527
400
|
});
|
|
528
401
|
/**
|
|
529
402
|
* A decrypted exchange that turns out to be a model call is recorded as one.
|
|
@@ -594,151 +467,6 @@ export async function createProxy(options) {
|
|
|
594
467
|
}
|
|
595
468
|
options.tls?.onNetExchange?.(exchange);
|
|
596
469
|
}
|
|
597
|
-
/**
|
|
598
|
-
* Replay hook for requests inside an intercepted TLS session. The ordinary HTTP proxy reaches
|
|
599
|
-
* `handle()` below, but the TLS interceptor has already terminated the outer CONNECT and needs
|
|
600
|
-
* the same matcher before it opens an origin connection.
|
|
601
|
-
*
|
|
602
|
-
* Live calls used to return `undefined` here, which forwarded the client's bytes unchanged —
|
|
603
|
-
* so `agenticreplay replay --from N --model X` on a TLS-intercepted recording echoed `model=X` and then
|
|
604
|
-
* called the recorded model. `replayed=0 live=0` was the tell: `goLive` never ran, substitution
|
|
605
|
-
* never had a chance. Same-provider `--model` now rewrites the body and still talks to the
|
|
606
|
-
* intercepted origin (the only origin that origin's credential can reach). A cross-provider
|
|
607
|
-
* target would have to leave that host, which this path cannot do; that fails loudly instead of
|
|
608
|
-
* succeeding with the old model.
|
|
609
|
-
*/
|
|
610
|
-
function onInterceptedRequest(request) {
|
|
611
|
-
const path = request.path.split('?')[0] ?? '/';
|
|
612
|
-
const dialect = selectDialect(dialects, path);
|
|
613
|
-
if (!dialect || request.method !== 'POST' || request.requestTruncated)
|
|
614
|
-
return undefined;
|
|
615
|
-
let rawBody;
|
|
616
|
-
try {
|
|
617
|
-
rawBody = decodeBody(request.requestBytes, Array.isArray(request.requestHeaders['content-encoding'])
|
|
618
|
-
? request.requestHeaders['content-encoding'].join(', ')
|
|
619
|
-
: request.requestHeaders['content-encoding'], options.maxCapturedBytes ?? DEFAULT_MAX_CAPTURED_BYTES);
|
|
620
|
-
}
|
|
621
|
-
catch (err) {
|
|
622
|
-
if (options.mode !== 'replay' || !options.loose) {
|
|
623
|
-
stats.unmatched += 1;
|
|
624
|
-
return replayError(`cannot decode intercepted request body: ${String(err)}`);
|
|
625
|
-
}
|
|
626
|
-
return undefined;
|
|
627
|
-
}
|
|
628
|
-
const result = tryReplay(dialect, rawBody);
|
|
629
|
-
if (result?.exchange) {
|
|
630
|
-
return {
|
|
631
|
-
status: result.exchange.status,
|
|
632
|
-
headers: {
|
|
633
|
-
'content-type': result.exchange.streamed ? 'text/event-stream' : 'application/json',
|
|
634
|
-
},
|
|
635
|
-
body: result.exchange.rawResponse,
|
|
636
|
-
};
|
|
637
|
-
}
|
|
638
|
-
if (result?.error)
|
|
639
|
-
return replayError(result.error);
|
|
640
|
-
return liveIntercepted(dialect, request, rawBody);
|
|
641
|
-
}
|
|
642
|
-
/**
|
|
643
|
-
* A decrypted model call the recording will not serve — past the fork point, or unmatched on a
|
|
644
|
-
* hybrid / `--loose` run. Counted as live the way `handle()` → `goLive` is, so `fork.done`
|
|
645
|
-
* `live=N` is the number of exchanges the fork actually owned.
|
|
646
|
-
*/
|
|
647
|
-
function liveIntercepted(dialect, request, rawBody) {
|
|
648
|
-
if (options.forkModel === undefined) {
|
|
649
|
-
stats.liveCalls += 1;
|
|
650
|
-
return undefined;
|
|
651
|
-
}
|
|
652
|
-
const target = dialect.ownsModel(options.forkModel)
|
|
653
|
-
? dialect
|
|
654
|
-
: (dialects.find((d) => d.ownsModel(options.forkModel)) ?? dialect);
|
|
655
|
-
const crossProvider = target.id !== dialect.id;
|
|
656
|
-
if (crossProvider) {
|
|
657
|
-
return replayError(`--model ${options.forkModel} is served by ${target.id}, not the intercepted ` +
|
|
658
|
-
`${dialect.id} origin ${request.host}:${request.port}. A TLS-intercepted fork cannot ` +
|
|
659
|
-
`leave the host the recording talked to. Pick a model that origin serves`);
|
|
660
|
-
}
|
|
661
|
-
let parsed;
|
|
662
|
-
try {
|
|
663
|
-
parsed = JSON.parse(rawBody);
|
|
664
|
-
}
|
|
665
|
-
catch (err) {
|
|
666
|
-
return replayError(`unparseable request body: ${String(err)}`);
|
|
667
|
-
}
|
|
668
|
-
const outboundBody = Buffer.from(JSON.stringify(dialect.withModel(parsed, options.forkModel)), 'utf8');
|
|
669
|
-
stats.liveCalls += 1;
|
|
670
|
-
const origin = `https://${request.host}${request.port === 443 ? '' : `:${request.port}`}`;
|
|
671
|
-
const recordable = recordableOrigin(origin);
|
|
672
|
-
options.onRoute?.({
|
|
673
|
-
model: options.forkModel,
|
|
674
|
-
target: target.id,
|
|
675
|
-
recorded: dialect.id,
|
|
676
|
-
...(recordable === undefined ? {} : { origin: recordable }),
|
|
677
|
-
crossProvider: false,
|
|
678
|
-
reason: `served by the recorded dialect ${dialect.id}`,
|
|
679
|
-
});
|
|
680
|
-
return { outboundBody };
|
|
681
|
-
}
|
|
682
|
-
function tryReplay(dialect, rawBody) {
|
|
683
|
-
let canonical;
|
|
684
|
-
try {
|
|
685
|
-
canonical = dialect.toCanonicalRequest(JSON.parse(rawBody));
|
|
686
|
-
}
|
|
687
|
-
catch (err) {
|
|
688
|
-
return { error: `unparseable request body: ${String(err)}` };
|
|
689
|
-
}
|
|
690
|
-
const beyondFork = options.mode === 'hybrid' && matcher.cursor >= (options.forkAt ?? replayable.length);
|
|
691
|
-
if (beyondFork)
|
|
692
|
-
return undefined;
|
|
693
|
-
const result = matcher.match(canonical);
|
|
694
|
-
if (result.matched) {
|
|
695
|
-
const exchange = replayable[result.index];
|
|
696
|
-
if (result.reordered)
|
|
697
|
-
stats.reordered += 1;
|
|
698
|
-
if (result.divergence) {
|
|
699
|
-
stats.matchedInexact += 1;
|
|
700
|
-
stats.divergences += 1;
|
|
701
|
-
options.onDivergence?.({ ...result.divergence, seq: exchange.seq });
|
|
702
|
-
}
|
|
703
|
-
else {
|
|
704
|
-
stats.matchedExact += 1;
|
|
705
|
-
}
|
|
706
|
-
return { exchange };
|
|
707
|
-
}
|
|
708
|
-
stats.unmatched += 1;
|
|
709
|
-
if (options.mode === 'hybrid' || options.loose) {
|
|
710
|
-
stats.divergences += 1;
|
|
711
|
-
options.onDivergence?.({
|
|
712
|
-
level: 'major',
|
|
713
|
-
rung: 4,
|
|
714
|
-
distance: -1,
|
|
715
|
-
detail: result.reason ?? 'request does not match the recording; served live instead',
|
|
716
|
-
seq: replayable[result.index]?.seq ?? -1,
|
|
717
|
-
});
|
|
718
|
-
return undefined;
|
|
719
|
-
}
|
|
720
|
-
const reason = result.reason ?? 'request does not match the recording';
|
|
721
|
-
options.onUnmatched?.({
|
|
722
|
-
seq: replayable[result.index]?.seq ?? -1,
|
|
723
|
-
index: result.index,
|
|
724
|
-
reason,
|
|
725
|
-
});
|
|
726
|
-
return { error: `agenticreplay: replay halted — ${reason}` };
|
|
727
|
-
}
|
|
728
|
-
function replayError(message) {
|
|
729
|
-
return {
|
|
730
|
-
status: 400,
|
|
731
|
-
headers: { 'content-type': 'application/json' },
|
|
732
|
-
body: JSON.stringify({
|
|
733
|
-
type: 'error',
|
|
734
|
-
error: {
|
|
735
|
-
type: 'invalid_request_error',
|
|
736
|
-
message: `${message}. Re-run with \`agenticreplay replay <run> --loose\` to continue live from this point, ` +
|
|
737
|
-
'or `agenticreplay show <run>` to see what was recorded.',
|
|
738
|
-
},
|
|
739
|
-
}),
|
|
740
|
-
};
|
|
741
|
-
}
|
|
742
470
|
const interception = options.tls
|
|
743
471
|
? attachTlsIntercept(server, {
|
|
744
472
|
...options.tls,
|
|
@@ -749,7 +477,6 @@ export async function createProxy(options) {
|
|
|
749
477
|
? {}
|
|
750
478
|
: { maxBufferedBytes: options.maxBufferedBytes }),
|
|
751
479
|
onRequestStart: options.onRequestStart,
|
|
752
|
-
...(options.mode === 'record' ? {} : { onRequest: onInterceptedRequest }),
|
|
753
480
|
onNetExchange: onDecrypted,
|
|
754
481
|
}, stats)
|
|
755
482
|
: undefined;
|
|
@@ -784,115 +511,23 @@ export async function createProxy(options) {
|
|
|
784
511
|
*/
|
|
785
512
|
forwardBase) {
|
|
786
513
|
stats.liveCalls += 1;
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
// and translate a turn that never needed translating.
|
|
795
|
-
const target = options.forkModel === undefined || dialect.ownsModel(options.forkModel)
|
|
796
|
-
? dialect
|
|
797
|
-
: (dialects.find((d) => d.ownsModel(options.forkModel)) ?? dialect);
|
|
798
|
-
const crossProvider = target.id !== dialect.id;
|
|
799
|
-
let parsed = JSON.parse(rawBody);
|
|
800
|
-
if (crossProvider) {
|
|
801
|
-
// Through canonical, which is the only representation both dialects agree on.
|
|
802
|
-
const canonical = dialect.toCanonicalRequest(parsed);
|
|
803
|
-
parsed = target.fromCanonicalRequest({ ...canonical, model: options.forkModel });
|
|
804
|
-
}
|
|
805
|
-
else if (options.forkModel) {
|
|
806
|
-
parsed = dialect.withModel(parsed, options.forkModel);
|
|
807
|
-
}
|
|
808
|
-
// A recording must retain the provider's serialized bytes. Re-serializing an unchanged
|
|
809
|
-
// request silently loses whitespace, field ordering, and unknown textual fields before the
|
|
810
|
-
// endpoint oracle can compare it. Only a model/provider fork is allowed to construct a new
|
|
811
|
-
// request body.
|
|
812
|
-
const outboundBody = options.forkModel === undefined ? rawBody : JSON.stringify(parsed);
|
|
813
|
-
const requestTranslation = options.forkModel === undefined
|
|
814
|
-
? []
|
|
815
|
-
: [
|
|
816
|
-
crossProvider
|
|
817
|
-
? protocolTranslationTransformation(dialect.id, target.id, 'request.body')
|
|
818
|
-
: modelOverrideTransformation(dialect.id, options.forkModel),
|
|
819
|
-
];
|
|
820
|
-
// Prefer an override for the provider we are actually calling; fall back to the override for
|
|
821
|
-
// the recorded provider before the vendor default. That fallback is the gateway case, and it
|
|
822
|
-
// is the common one: someone who passed --upstream-anthropic pointed *this run* at a specific
|
|
823
|
-
// origin, and a gateway serves both dialects from it. Going to api.openai.com instead because
|
|
824
|
-
// the fork changed model would ignore an instruction the user gave explicitly.
|
|
825
|
-
// Where this call goes, in strict order of who decided it. A `/forward/` path names its own
|
|
826
|
-
// destination — that is the whole point of the encoding — so on a live call that agenticreplay is
|
|
827
|
-
// merely relaying (record, or a loose replay continuing past its recording) the request's own
|
|
828
|
-
// base outranks every configured origin: a gateway picked up from `agenticreplay setup` is a default
|
|
829
|
-
// for calls agenticreplay would otherwise have to guess at, not a licence to readdress one that
|
|
830
|
-
// announces where it was headed. A fork is different: substituting the model is the operator
|
|
831
|
-
// choosing where answers come from, so the fork target's explicit upstream — or the gateway —
|
|
832
|
-
// decides, and the forwarded base is dropped as incoherent for the substitution.
|
|
833
|
-
const relaying = options.forkModel === undefined && !crossProvider;
|
|
834
|
-
const origin = relaying && forwardBase !== undefined
|
|
835
|
-
? forwardBase
|
|
836
|
-
: (options.upstream?.[target.id] ??
|
|
837
|
-
options.upstream?.[dialect.id] ??
|
|
838
|
-
(target.id === 'openai-responses' || target.id === 'codex'
|
|
839
|
-
? options.upstream?.openai
|
|
840
|
-
: undefined) ??
|
|
841
|
-
target.defaultUpstream);
|
|
842
|
-
// A relayed call keeps the path the client appended, because the base it names expects
|
|
843
|
-
// exactly that. Everything else is answered by an origin agenticreplay chose, which expects the
|
|
844
|
-
// dialect's own path — and the incoming path cannot be trusted to be it: a bare base url
|
|
845
|
-
// (no `/v1`) reaches this proxy as `/chat/completions`, and a gateway handed that without the
|
|
846
|
-
// version segment answers 404.
|
|
847
|
-
//
|
|
848
|
-
// The query survives the substitution either way, because it is not part of the endpoint's
|
|
849
|
-
// shape. A path says which endpoint; a query says how to call it, and the client set it —
|
|
850
|
-
// `?api-version=` is required on every Azure OpenAI call, and dropping it turns a working
|
|
851
|
-
// configuration into `404 Resource not found` with nothing in the run explaining why.
|
|
852
|
-
// Replacing a path is agenticreplay normalising an address it chose; replacing the parameters would
|
|
853
|
-
// be agenticreplay editing the request.
|
|
514
|
+
const outboundBody = rawBody;
|
|
515
|
+
const origin = forwardBase ??
|
|
516
|
+
options.upstream?.[dialect.id] ??
|
|
517
|
+
(dialect.id === 'openai-responses' || dialect.id === 'codex'
|
|
518
|
+
? options.upstream?.openai
|
|
519
|
+
: undefined) ??
|
|
520
|
+
dialect.defaultUpstream;
|
|
854
521
|
const queryAt = path.indexOf('?');
|
|
855
522
|
const query = queryAt === -1 ? '' : path.slice(queryAt);
|
|
856
|
-
const upstreamPath =
|
|
857
|
-
// Spec §2: "a gateway chose a model". AgenticReplay *is* the gateway on this path — it substitutes the
|
|
858
|
-
// model, picks the wire format that serves it, and picks the origin — and it was making all
|
|
859
|
-
// three of those choices silently. Which is the one thing a comparison must not do: reading
|
|
860
|
-
// `claude-opus-5 vs gpt-5.2` in a verdict table tells you nothing about where either went, and
|
|
861
|
-
// a fork that quietly fell back to the recorded provider's origin looked identical to one that
|
|
862
|
-
// did not. Emitted only when a decision was actually taken, so an ordinary recording — where
|
|
863
|
-
// agenticreplay forwards what it was given — stays free of an event saying "nothing was chosen".
|
|
864
|
-
if (options.forkModel !== undefined) {
|
|
865
|
-
// Sanitised here for the reason {@link buildExchange} sanitises the exchange's `upstream`,
|
|
866
|
-
// and it is the same value: an origin that came from configuration rather than off the wire,
|
|
867
|
-
// so a gateway that authenticates by URL carries its key in it. This payload is not just
|
|
868
|
-
// reported — `agenticreplay replay --model` and `agenticreplay compare --models` write it into the fork's
|
|
869
|
-
// trace as `route.decision.attrs` unchanged, and the trace redactor cannot help, because it
|
|
870
|
-
// matches key shapes and field names and this is a password inside a URL under `origin`.
|
|
871
|
-
// Omitted rather than replaced when it will not parse: no consumer reads it — the viewer
|
|
872
|
-
// renders model, target and reason — and a placeholder in a field others may parse as a URL
|
|
873
|
-
// is worse than the field being absent.
|
|
874
|
-
const recordable = recordableOrigin(origin);
|
|
875
|
-
options.onRoute?.({
|
|
876
|
-
model: options.forkModel,
|
|
877
|
-
target: target.id,
|
|
878
|
-
recorded: dialect.id,
|
|
879
|
-
...(recordable === undefined ? {} : { origin: recordable }),
|
|
880
|
-
crossProvider,
|
|
881
|
-
// Deliberately does not open with the model name: the viewer already renders that as the
|
|
882
|
-
// row's label, so a reason that repeats it produces `gpt-5.2 gpt-5.2 is served by…` and
|
|
883
|
-
// spends the row's width saying the same thing twice.
|
|
884
|
-
reason: crossProvider
|
|
885
|
-
? `served by ${target.id}, not the recorded ${dialect.id}`
|
|
886
|
-
: `served by the recorded dialect ${dialect.id}`,
|
|
887
|
-
});
|
|
888
|
-
}
|
|
523
|
+
const upstreamPath = forwardBase !== undefined ? path : `${dialect.requestPath}${query}`;
|
|
889
524
|
let upstreamRes;
|
|
890
525
|
try {
|
|
891
526
|
upstreamRes = await doFetch(`${origin}${upstreamPath}`, {
|
|
892
527
|
method: 'POST',
|
|
893
528
|
headers: {
|
|
894
529
|
'content-type': 'application/json',
|
|
895
|
-
...
|
|
530
|
+
...headers,
|
|
896
531
|
...headersForOrigin(origin),
|
|
897
532
|
},
|
|
898
533
|
body: outboundBody,
|
|
@@ -961,104 +596,6 @@ export async function createProxy(options) {
|
|
|
961
596
|
const upstreamStreamed = upstreamType.includes('event-stream');
|
|
962
597
|
const responseEncoding = upstreamRes.headers.get('content-encoding') ?? undefined;
|
|
963
598
|
const responseDecoded = responseEncoding !== undefined && responseEncoding.trim() !== '';
|
|
964
|
-
// The agent asked one provider and must be answered in that provider's shape, whatever served
|
|
965
|
-
// the request. Handing an agent a `chat.completion` body it cannot parse is indistinguishable
|
|
966
|
-
// from the model having failed.
|
|
967
|
-
//
|
|
968
|
-
// A cross-provider reply cannot be teed: translating it means having all of it, so this path
|
|
969
|
-
// buffers where the same-provider path streams. That is a real cost and it only applies to a
|
|
970
|
-
// fork that deliberately changed provider — the recording path, which is what an interactive
|
|
971
|
-
// session runs through, still streams.
|
|
972
|
-
if (crossProvider) {
|
|
973
|
-
const received = await readBoundedResponse(upstreamRes, options.maxCapturedBytes ?? DEFAULT_MAX_CAPTURED_BYTES, plainBufferLimit);
|
|
974
|
-
if (received.truncated) {
|
|
975
|
-
emitNetExchange({
|
|
976
|
-
path: upstreamPath,
|
|
977
|
-
responseHeaders: captureResponseHeaders(upstreamRes.headers, responseDecoded),
|
|
978
|
-
responseBody: received.text,
|
|
979
|
-
responseBytes: received.bytes,
|
|
980
|
-
responseTruncated: true,
|
|
981
|
-
status: upstreamRes.status,
|
|
982
|
-
responseComplete: false,
|
|
983
|
-
responseError: 'cross-provider response exceeded capture limit',
|
|
984
|
-
streamed: upstreamStreamed,
|
|
985
|
-
modelDialect: target.id,
|
|
986
|
-
extraTransformations: [protocolTranslationTransformation(target.id, dialect.id)],
|
|
987
|
-
});
|
|
988
|
-
res.writeHead(502, { 'content-type': 'application/json' });
|
|
989
|
-
res.end(JSON.stringify({ error: { message: 'cross-provider response exceeded capture limit' } }));
|
|
990
|
-
return;
|
|
991
|
-
}
|
|
992
|
-
const raw = received.text;
|
|
993
|
-
// Keep the origin bytes even when protocol translation fails. The client receives an error,
|
|
994
|
-
// but the recorder must still prove that the origin answered and preserve the response that
|
|
995
|
-
// caused the translation failure.
|
|
996
|
-
let body;
|
|
997
|
-
const wantsStream = requestedStream(rawBody);
|
|
998
|
-
try {
|
|
999
|
-
const canonical = upstreamStreamed
|
|
1000
|
-
? target.parseStream(raw)
|
|
1001
|
-
: target.toCanonicalResponse(JSON.parse(raw));
|
|
1002
|
-
// Match the shape the agent asked for, not the shape the other provider happened to send.
|
|
1003
|
-
body = dialect.fromCanonicalResponse(canonical, wantsStream);
|
|
1004
|
-
}
|
|
1005
|
-
catch (err) {
|
|
1006
|
-
emitNetExchange({
|
|
1007
|
-
path: upstreamPath,
|
|
1008
|
-
responseHeaders: captureResponseHeaders(upstreamRes.headers, responseDecoded),
|
|
1009
|
-
responseBody: raw,
|
|
1010
|
-
responseBytes: received.bytes,
|
|
1011
|
-
responseTruncated: false,
|
|
1012
|
-
status: upstreamRes.status,
|
|
1013
|
-
responseComplete: received.encodingError === undefined,
|
|
1014
|
-
responseError: received.encodingError ?? `cross-provider response translation failed: ${String(err)}`,
|
|
1015
|
-
streamed: upstreamStreamed,
|
|
1016
|
-
modelDialect: target.id,
|
|
1017
|
-
extraTransformations: [protocolTranslationTransformation(target.id, dialect.id)],
|
|
1018
|
-
});
|
|
1019
|
-
res.writeHead(502, { 'content-type': 'application/json' });
|
|
1020
|
-
res.end(JSON.stringify({ error: { message: 'cross-provider response translation failed' } }));
|
|
1021
|
-
return;
|
|
1022
|
-
}
|
|
1023
|
-
if (Buffer.byteLength(body) > (options.maxCapturedBytes ?? DEFAULT_MAX_CAPTURED_BYTES)) {
|
|
1024
|
-
emitNetExchange({
|
|
1025
|
-
path: upstreamPath,
|
|
1026
|
-
responseHeaders: captureResponseHeaders(upstreamRes.headers, responseDecoded),
|
|
1027
|
-
responseBody: raw,
|
|
1028
|
-
responseBytes: received.bytes,
|
|
1029
|
-
responseTruncated: false,
|
|
1030
|
-
status: upstreamRes.status,
|
|
1031
|
-
responseComplete: false,
|
|
1032
|
-
responseError: 'cross-provider translated response exceeded capture limit',
|
|
1033
|
-
streamed: wantsStream,
|
|
1034
|
-
modelDialect: target.id,
|
|
1035
|
-
extraTransformations: [protocolTranslationTransformation(target.id, dialect.id)],
|
|
1036
|
-
});
|
|
1037
|
-
res.writeHead(502, { 'content-type': 'application/json' });
|
|
1038
|
-
res.end(JSON.stringify({
|
|
1039
|
-
error: { message: 'cross-provider translated response exceeded capture limit' },
|
|
1040
|
-
}));
|
|
1041
|
-
return;
|
|
1042
|
-
}
|
|
1043
|
-
res.writeHead(upstreamRes.status, {
|
|
1044
|
-
'content-type': wantsStream ? 'text/event-stream' : 'application/json',
|
|
1045
|
-
});
|
|
1046
|
-
res.end(body);
|
|
1047
|
-
emitNetExchange({
|
|
1048
|
-
path: upstreamPath,
|
|
1049
|
-
responseHeaders: captureResponseHeaders(upstreamRes.headers, responseDecoded),
|
|
1050
|
-
responseBody: raw,
|
|
1051
|
-
responseBytes: received.bytes,
|
|
1052
|
-
status: upstreamRes.status,
|
|
1053
|
-
responseComplete: received.encodingError === undefined,
|
|
1054
|
-
...(received.encodingError === undefined ? {} : { responseError: received.encodingError }),
|
|
1055
|
-
streamed: wantsStream,
|
|
1056
|
-
modelDialect: target.id,
|
|
1057
|
-
extraTransformations: [protocolTranslationTransformation(target.id, dialect.id)],
|
|
1058
|
-
});
|
|
1059
|
-
recordExchange(body, upstreamRes.status, wantsStream);
|
|
1060
|
-
return;
|
|
1061
|
-
}
|
|
1062
599
|
const contentType = upstreamType;
|
|
1063
600
|
const streamed = upstreamStreamed;
|
|
1064
601
|
res.writeHead(upstreamRes.status, { 'content-type': contentType });
|
|
@@ -1126,7 +663,6 @@ export async function createProxy(options) {
|
|
|
1126
663
|
modelDialect: input.modelDialect ?? dialect.id,
|
|
1127
664
|
transformations: [
|
|
1128
665
|
...captureHeaderTransformations(headers, recordableHeaders),
|
|
1129
|
-
...requestTranslation,
|
|
1130
666
|
...captureEncodingTransformation('request.body', requestEncoding),
|
|
1131
667
|
...captureResponseHeaderTransformations(upstreamRes.headers),
|
|
1132
668
|
...captureEncodingTransformation('response.body', responseEncoding),
|
|
@@ -1177,7 +713,7 @@ export async function createProxy(options) {
|
|
|
1177
713
|
* a base URL; the client's own headers say which provider that was, and its own credential is
|
|
1178
714
|
* already on the request addressed to them.
|
|
1179
715
|
*/
|
|
1180
|
-
function passthroughOrigin(headers, forwardBase
|
|
716
|
+
function passthroughOrigin(headers, forwardBase) {
|
|
1181
717
|
if (options.passthroughUpstream !== undefined)
|
|
1182
718
|
return options.passthroughUpstream;
|
|
1183
719
|
// A relayed request's own destination, before anything configured: on a live call with no
|
|
@@ -1191,7 +727,7 @@ export async function createProxy(options) {
|
|
|
1191
727
|
// of one is how a fork's live embedding call left for the chat origin carrying the embedding
|
|
1192
728
|
// provider's own key: a 401 at best, and at worst an OpenAI-compatible gateway that answers
|
|
1193
729
|
// `/v1/embeddings` from a different model, putting foreign vectors in the fork's index.
|
|
1194
|
-
if (forwardBase !== undefined
|
|
730
|
+
if (forwardBase !== undefined) {
|
|
1195
731
|
return forwardBase;
|
|
1196
732
|
}
|
|
1197
733
|
const configured = [...new Set(Object.values(options.upstream ?? {}))];
|
|
@@ -1215,335 +751,35 @@ export async function createProxy(options) {
|
|
|
1215
751
|
return anthropic ? 'https://api.anthropic.com' : 'https://api.openai.com';
|
|
1216
752
|
}
|
|
1217
753
|
/**
|
|
1218
|
-
*
|
|
754
|
+
* An origin string as a host and a port, for the two places that record one.
|
|
1219
755
|
*
|
|
1220
|
-
*
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
*
|
|
756
|
+
* An origin that does not parse is still worth recording under the string we were given.
|
|
757
|
+
*/
|
|
758
|
+
/**
|
|
759
|
+
* Why the call did not go out, with the origin in it made safe first.
|
|
1224
760
|
*
|
|
1225
|
-
*
|
|
1226
|
-
*
|
|
1227
|
-
*
|
|
761
|
+
* The message names the request, so it carries the origin — and undici quotes it verbatim:
|
|
762
|
+
* `Failed to parse URL from gw?key=SECRET123/v1/...`. Running only the prose rules over that
|
|
763
|
+
* leaves the key in, for the same shapes `scrubOrigin` exists to handle, so the known origin is
|
|
764
|
+
* replaced by its scrubbed form before the prose pass ever looks at the sentence.
|
|
1228
765
|
*/
|
|
1229
|
-
async function serveRetrieval(rule, path, rawBody, headers, recordableHeaders, res, startedAt, requestId, forwardBase) {
|
|
1230
|
-
let key;
|
|
1231
|
-
let parsed;
|
|
1232
|
-
try {
|
|
1233
|
-
parsed = JSON.parse(rawBody);
|
|
1234
|
-
key = rule.key(parsed);
|
|
1235
|
-
}
|
|
1236
|
-
catch (err) {
|
|
1237
|
-
// A body this rule cannot key is a body agenticreplay cannot promise anything about. Passthrough is
|
|
1238
|
-
// the honest fallback: forward it, record the bytes, say it is not replayable.
|
|
1239
|
-
if (options.mode === 'replay') {
|
|
1240
|
-
stats.unmatched += 1;
|
|
1241
|
-
const reason = `${path} could not be keyed for replay: ${String(err)}`;
|
|
1242
|
-
options.onUnmatched?.({ seq: -1, index: -1, reason });
|
|
1243
|
-
json(res, 502, {
|
|
1244
|
-
error: { message: `agenticreplay replay cannot reproduce ${path}: ${reason}` },
|
|
1245
|
-
});
|
|
1246
|
-
return;
|
|
1247
|
-
}
|
|
1248
|
-
await passThrough(path, rawBody, headers, recordableHeaders, res, startedAt, requestId, forwardBase);
|
|
1249
|
-
return;
|
|
1250
|
-
}
|
|
1251
|
-
// Across the fork cursor deliberately, in every mode that has a recording. A fork asks the
|
|
1252
|
-
// same documents about a different chat model; re-embedding them live would spend money to
|
|
1253
|
-
// recompute a value that cannot have changed, and would make the fork's index differ from the
|
|
1254
|
-
// parent's for no reason anyone asked for. `RetrievalRule.forkable` is `false` for the same
|
|
1255
|
-
// reason, one level up.
|
|
1256
|
-
//
|
|
1257
|
-
// Exact first, then the batch. A pipeline assembles its batch from a worker pool, so the same
|
|
1258
|
-
// set of texts arrives in a different order on every run and an exact key alone found nothing
|
|
1259
|
-
// — measured: two replays of one recording, two different keys, neither recorded. Where the
|
|
1260
|
-
// endpoint's own contract says `data[i]` answers `input[i]`, the recorded answer for each
|
|
1261
|
-
// text can be handed back against that text's new position, which is not an approximation.
|
|
1262
|
-
// See {@link BatchRetrieval}.
|
|
1263
|
-
let match = retrievalIndex.byKey.get(key)?.[0];
|
|
1264
|
-
let rebuilt;
|
|
1265
|
-
if (match === undefined) {
|
|
1266
|
-
const batchKey = rule.batch?.key(parsed);
|
|
1267
|
-
const candidate = batchKey === undefined ? undefined : retrievalIndex.byBatch.get(batchKey)?.[0];
|
|
1268
|
-
if (candidate !== undefined && candidate.rawResponse !== '') {
|
|
1269
|
-
rebuilt = rule.batch?.reorder(safeParse(candidate.rawRequest), candidate.rawResponse, parsed);
|
|
1270
|
-
// An inexact reorder is no match at all. Filling the gaps would put another document's
|
|
1271
|
-
// vector in the index, and nothing downstream could tell.
|
|
1272
|
-
if (rebuilt !== undefined)
|
|
1273
|
-
match = candidate;
|
|
1274
|
-
}
|
|
1275
|
-
else if (candidate !== undefined) {
|
|
1276
|
-
match = candidate;
|
|
1277
|
-
}
|
|
1278
|
-
}
|
|
1279
|
-
if (match !== undefined) {
|
|
1280
|
-
// Consumed only where it is actually served. A recorded call agenticreplay cannot serve is still the
|
|
1281
|
-
// answer to this request, and dropping it turned a client's retry — langchain retries a 502
|
|
1282
|
-
// — into "no recorded retrieval call matches this request", which is false and sends the
|
|
1283
|
-
// reader looking for a mismatch that is not there.
|
|
1284
|
-
if (rebuilt !== undefined) {
|
|
1285
|
-
consume(retrievalIndex, match);
|
|
1286
|
-
stats.retrievalServed += 1;
|
|
1287
|
-
stats.retrievalReordered += 1;
|
|
1288
|
-
res.writeHead(match.status, { 'content-type': match.contentType });
|
|
1289
|
-
res.end(rebuilt);
|
|
1290
|
-
return;
|
|
1291
|
-
}
|
|
1292
|
-
if (match.rawResponse === '') {
|
|
1293
|
-
// Recorded with `--retrieval-store=digest`: agenticreplay kept proof of the answer, not the answer.
|
|
1294
|
-
const reason = `${path} was recorded with --retrieval-store=digest, so the trace holds a digest of ` +
|
|
1295
|
-
'the response and not the response. Re-record with the default (full) to replay it';
|
|
1296
|
-
if (options.mode === 'replay' && !options.loose) {
|
|
1297
|
-
stats.unmatched += 1;
|
|
1298
|
-
options.onUnmatched?.({ seq: match.seq, index: -1, reason });
|
|
1299
|
-
json(res, 502, {
|
|
1300
|
-
error: { message: `agenticreplay replay cannot reproduce ${path}: ${reason}` },
|
|
1301
|
-
});
|
|
1302
|
-
return;
|
|
1303
|
-
}
|
|
1304
|
-
}
|
|
1305
|
-
else {
|
|
1306
|
-
consume(retrievalIndex, match);
|
|
1307
|
-
stats.retrievalServed += 1;
|
|
1308
|
-
res.writeHead(match.status, { 'content-type': match.contentType });
|
|
1309
|
-
res.end(match.rawResponse);
|
|
1310
|
-
return;
|
|
1311
|
-
}
|
|
1312
|
-
}
|
|
1313
|
-
if (options.mode === 'replay' && !options.loose) {
|
|
1314
|
-
stats.unmatched += 1;
|
|
1315
|
-
const reason = `no recorded retrieval call matches this request (rule ${rule.id}, key ` +
|
|
1316
|
-
`${key.slice(0, 12)}…). The recording holds ${stats.retrievalTotal} retrieval ` +
|
|
1317
|
-
`call${stats.retrievalTotal === 1 ? '' : 's'}, and this is not one of them`;
|
|
1318
|
-
options.onUnmatched?.({ seq: -1, index: -1, reason });
|
|
1319
|
-
json(res, 502, {
|
|
1320
|
-
error: { message: `agenticreplay replay cannot reproduce ${path}: ${reason}` },
|
|
1321
|
-
});
|
|
1322
|
-
return;
|
|
1323
|
-
}
|
|
1324
|
-
// Record, a fork past what it can serve, or a loose replay: make the call and keep the answer.
|
|
1325
|
-
if (options.mode !== 'record')
|
|
1326
|
-
stats.retrievalLive += 1;
|
|
1327
|
-
await forwardRetrieval(rule, key, rule.batch?.key(parsed), path, rawBody, headers, recordableHeaders, res, startedAt, requestId, forwardBase);
|
|
1328
|
-
}
|
|
1329
|
-
/** A body that has already parsed once. Undefined rather than a throw where it has not. */
|
|
1330
|
-
function safeParse(raw) {
|
|
1331
|
-
try {
|
|
1332
|
-
return JSON.parse(raw);
|
|
1333
|
-
}
|
|
1334
|
-
catch {
|
|
1335
|
-
return undefined;
|
|
1336
|
-
}
|
|
1337
|
-
}
|
|
1338
|
-
/** Make a retrieval call for real, and file it as a replayable `net.*` pair. */
|
|
1339
|
-
async function forwardRetrieval(rule, key, batchKey, path, rawBody, headers, recordableHeaders, res, startedAt, requestId, forwardBase) {
|
|
1340
|
-
stats.passedThrough += 1;
|
|
1341
|
-
// On a recording, the count of retrieval calls *captured*. It is the same field replay uses
|
|
1342
|
-
// for "how many the recording holds", which is the same quantity one command later — and it
|
|
1343
|
-
// is what lets `capture.empty` tell an index build, whose every call is a retrieval call and
|
|
1344
|
-
// every one of them replayable, apart from a run that captured nothing at all.
|
|
1345
|
-
if (options.mode === 'record')
|
|
1346
|
-
stats.retrievalTotal += 1;
|
|
1347
|
-
// Verbatim: everything below forwards this request unchanged, so where it says it was going
|
|
1348
|
-
// stands, fork or no fork. See `passthroughOrigin`.
|
|
1349
|
-
const origin = passthroughOrigin(headers, forwardBase, { verbatim: true });
|
|
1350
|
-
let upstreamRes;
|
|
1351
|
-
try {
|
|
1352
|
-
upstreamRes = await doFetch(`${origin}${path}`, {
|
|
1353
|
-
method: 'POST',
|
|
1354
|
-
headers: { 'content-type': 'application/json', ...headers, ...headersForOrigin(origin) },
|
|
1355
|
-
body: rawBody,
|
|
1356
|
-
});
|
|
1357
|
-
}
|
|
1358
|
-
catch (err) {
|
|
1359
|
-
// The third of the three forwarding paths, and it had the same hole. An index build issues
|
|
1360
|
-
// these hundreds at a time; a gateway that stops answering half way through used to leave
|
|
1361
|
-
// a trace that simply had fewer retrieval calls in it than the run made, with nothing
|
|
1362
|
-
// saying which ones or why.
|
|
1363
|
-
//
|
|
1364
|
-
// `status: 0` and the `net.*` shape, matching `passThrough`: no response header was ever
|
|
1365
|
-
// seen, and a retrieval call is already recorded as network traffic rather than as a
|
|
1366
|
-
// replayable model exchange.
|
|
1367
|
-
options.onNetExchange?.({
|
|
1368
|
-
requestId,
|
|
1369
|
-
requestStartedAt: startedAt,
|
|
1370
|
-
...originParts(origin),
|
|
1371
|
-
method: 'POST',
|
|
1372
|
-
path,
|
|
1373
|
-
intercepted: false,
|
|
1374
|
-
rule: rule.id,
|
|
1375
|
-
replayKey: key,
|
|
1376
|
-
...(batchKey === undefined ? {} : { batchKey }),
|
|
1377
|
-
requestHeaders: recordableHeaders,
|
|
1378
|
-
requestBody: rawBody,
|
|
1379
|
-
requestBytes: Buffer.byteLength(rawBody),
|
|
1380
|
-
requestTruncated: false,
|
|
1381
|
-
status: 0,
|
|
1382
|
-
responseHeaders: {},
|
|
1383
|
-
responseBody: `agenticreplay did not forward this call: ${reasonFor(err, origin)}`,
|
|
1384
|
-
responseTruncated: false,
|
|
1385
|
-
responseComplete: false,
|
|
1386
|
-
responseError: reasonFor(err, origin),
|
|
1387
|
-
responseBytes: 0,
|
|
1388
|
-
durationMs: Date.now() - startedAt,
|
|
1389
|
-
});
|
|
1390
|
-
throw err;
|
|
1391
|
-
}
|
|
1392
|
-
const responseEncoding = upstreamRes.headers.get('content-encoding') ?? undefined;
|
|
1393
|
-
const responseHeaders = captureResponseHeaders(upstreamRes.headers, responseEncoding !== undefined && responseEncoding.trim() !== '');
|
|
1394
|
-
res.writeHead(upstreamRes.status, {
|
|
1395
|
-
'content-type': responseHeaders['content-type'] ?? 'application/json',
|
|
1396
|
-
});
|
|
1397
|
-
let piped;
|
|
1398
|
-
try {
|
|
1399
|
-
piped = await pipeThrough(upstreamRes, res, options.maxCapturedBytes ?? DEFAULT_MAX_CAPTURED_BYTES, plainBufferLimit);
|
|
1400
|
-
}
|
|
1401
|
-
catch (err) {
|
|
1402
|
-
const partial = err instanceof ResponsePipeAbortedError
|
|
1403
|
-
? err.partial
|
|
1404
|
-
: err.partial;
|
|
1405
|
-
options.onNetExchange?.({
|
|
1406
|
-
requestId,
|
|
1407
|
-
requestStartedAt: startedAt,
|
|
1408
|
-
...originParts(origin),
|
|
1409
|
-
method: 'POST',
|
|
1410
|
-
path,
|
|
1411
|
-
intercepted: false,
|
|
1412
|
-
rule: rule.id,
|
|
1413
|
-
replayKey: key,
|
|
1414
|
-
...(batchKey === undefined ? {} : { batchKey }),
|
|
1415
|
-
requestHeaders: recordableHeaders,
|
|
1416
|
-
requestBody: rawBody,
|
|
1417
|
-
requestBytes: Buffer.byteLength(rawBody),
|
|
1418
|
-
requestTruncated: false,
|
|
1419
|
-
status: upstreamRes.status,
|
|
1420
|
-
responseHeaders,
|
|
1421
|
-
responseBody: partial?.text ?? '',
|
|
1422
|
-
responseTruncated: partial?.truncated ?? false,
|
|
1423
|
-
responseComplete: false,
|
|
1424
|
-
responseError: String(err),
|
|
1425
|
-
responseBytes: partial?.bytes ?? 0,
|
|
1426
|
-
...(err instanceof ResponsePipeAbortedError ? { abandoned: true } : {}),
|
|
1427
|
-
durationMs: Date.now() - startedAt,
|
|
1428
|
-
});
|
|
1429
|
-
if (err instanceof ResponsePipeAbortedError)
|
|
1430
|
-
return;
|
|
1431
|
-
throw err;
|
|
1432
|
-
}
|
|
1433
|
-
const body = piped.text;
|
|
1434
|
-
const digest = createHash('sha256').update(body).digest('hex');
|
|
1435
|
-
const keepBody = (options.retrievalStore ?? 'full') === 'full';
|
|
1436
|
-
const { host, port } = originParts(origin);
|
|
1437
|
-
options.onNetExchange?.({
|
|
1438
|
-
requestId,
|
|
1439
|
-
requestStartedAt: startedAt,
|
|
1440
|
-
host,
|
|
1441
|
-
port,
|
|
1442
|
-
method: 'POST',
|
|
1443
|
-
path,
|
|
1444
|
-
intercepted: false,
|
|
1445
|
-
rule: rule.id,
|
|
1446
|
-
replayKey: key,
|
|
1447
|
-
// Only where the rule offers one. Its absence in a trace means exactly what it says: this
|
|
1448
|
-
// call can be found again by its own body and by nothing else.
|
|
1449
|
-
...(batchKey === undefined ? {} : { batchKey }),
|
|
1450
|
-
requestHeaders: recordableHeaders,
|
|
1451
|
-
requestBody: rawBody,
|
|
1452
|
-
requestBytes: Buffer.byteLength(rawBody),
|
|
1453
|
-
requestTruncated: false,
|
|
1454
|
-
status: upstreamRes.status,
|
|
1455
|
-
responseHeaders,
|
|
1456
|
-
// Dropped rather than trimmed under `digest`: half a vector is not a smaller answer, it is
|
|
1457
|
-
// a wrong one, and the digest already says whether a later run agreed.
|
|
1458
|
-
responseBody: keepBody ? body : '',
|
|
1459
|
-
responseDigest: digest,
|
|
1460
|
-
responseTruncated: piped.truncated,
|
|
1461
|
-
responseComplete: !piped.truncated && piped.encodingError === undefined,
|
|
1462
|
-
...(piped.truncated
|
|
1463
|
-
? { responseError: 'response exceeded capture limit' }
|
|
1464
|
-
: piped.encodingError === undefined
|
|
1465
|
-
? {}
|
|
1466
|
-
: { responseError: piped.encodingError }),
|
|
1467
|
-
responseBytes: piped.bytes,
|
|
1468
|
-
durationMs: Date.now() - startedAt,
|
|
1469
|
-
transformations: [
|
|
1470
|
-
...captureHeaderTransformations(headers, recordableHeaders),
|
|
1471
|
-
...captureResponseHeaderTransformations(upstreamRes.headers),
|
|
1472
|
-
...captureEncodingTransformation('response.body', responseEncoding),
|
|
1473
|
-
...captureUrlTransformations(origin),
|
|
1474
|
-
...binaryBodyTransformation('request.body', rawBody),
|
|
1475
|
-
...binaryBodyTransformation('response.body', body),
|
|
1476
|
-
],
|
|
1477
|
-
});
|
|
1478
|
-
}
|
|
1479
|
-
/** Split a configured origin into the host and port an exchange records. */
|
|
1480
766
|
function originParts(origin) {
|
|
1481
767
|
try {
|
|
1482
768
|
const url = new URL(origin);
|
|
1483
769
|
return {
|
|
1484
770
|
host: url.hostname,
|
|
1485
|
-
port: url.port
|
|
771
|
+
port: url.port ? Number(url.port) : url.protocol === 'http:' ? 80 : 443,
|
|
1486
772
|
};
|
|
1487
773
|
}
|
|
1488
774
|
catch {
|
|
1489
|
-
// An origin that does not parse is still worth recording under the string we were given —
|
|
1490
|
-
// less anything secret in it. `host` goes into the trace verbatim (`persistNetExchange`
|
|
1491
|
-
// writes it as a `net.request` attr), and §7 says a credential never gets written down.
|
|
1492
|
-
// The trace's own redactor does not cover this: `hunter2` and `key=SECRET123` are under
|
|
1493
|
-
// the 20-character entropy floor and match no shape rule.
|
|
1494
|
-
//
|
|
1495
|
-
// Dead above, live below. A caller that already has a response held a URL undici parsed,
|
|
1496
|
-
// so the fallback cannot run on the success path; `passThrough`'s failure path is the one
|
|
1497
|
-
// place it does, because an origin `new URL` rejects is exactly what made `doFetch` throw.
|
|
1498
|
-
//
|
|
1499
|
-
// `redactString` after the cut, not instead of it: its shape rules and 20-character
|
|
1500
|
-
// entropy floor catch neither `hunter2` nor `key=SECRET123`, so it is a backstop for a
|
|
1501
|
-
// real key that happens to be in there, not the thing doing the work.
|
|
1502
775
|
return { host: redactor.redactString(scrubOrigin(origin)).value, port: 443 };
|
|
1503
776
|
}
|
|
1504
777
|
}
|
|
1505
|
-
/**
|
|
1506
|
-
* Forward a POST no dialect claims, and record that it happened.
|
|
1507
|
-
*
|
|
1508
|
-
* It is recorded as `NetExchange` rather than `RecordedExchange` on purpose: agenticreplay holds the bytes
|
|
1509
|
-
* but not the meaning, so it cannot match this request on replay or rewrite its model on a fork.
|
|
1510
|
-
* Filing it with the model exchanges would inflate `reused=n/m` with turns replay will never
|
|
1511
|
-
* serve, and an operator would be reading a fidelity number that is not one.
|
|
1512
|
-
*/
|
|
1513
|
-
/**
|
|
1514
|
-
* An origin string as a host and a port, for the two places that record one.
|
|
1515
|
-
*
|
|
1516
|
-
* An origin that does not parse is still worth recording under the string we were given.
|
|
1517
|
-
*/
|
|
1518
|
-
/**
|
|
1519
|
-
* Why the call did not go out, with the origin in it made safe first.
|
|
1520
|
-
*
|
|
1521
|
-
* The message names the request, so it carries the origin — and undici quotes it verbatim:
|
|
1522
|
-
* `Failed to parse URL from gw?key=SECRET123/v1/...`. Running only the prose rules over that
|
|
1523
|
-
* leaves the key in, for the same shapes `scrubOrigin` exists to handle, so the known origin is
|
|
1524
|
-
* replaced by its scrubbed form before the prose pass ever looks at the sentence.
|
|
1525
|
-
*/
|
|
1526
778
|
function reasonFor(err, origin) {
|
|
1527
779
|
const scrubbed = String(err).split(origin).join(scrubOrigin(origin));
|
|
1528
780
|
return redactor.redactString(withoutCredentials(scrubbed)).value;
|
|
1529
781
|
}
|
|
1530
782
|
async function passThrough(path, rawBody, headers, recordableHeaders, res, startedAt, requestId, forwardBase, requestEncoding) {
|
|
1531
|
-
// `hybrid` is a fork, and a fork runs a live agent — so it forwards, exactly as `record` does.
|
|
1532
|
-
// Refusing here killed the fork on the first call agenticreplay could not read, which is the failure
|
|
1533
|
-
// passthrough exists to prevent, reintroduced one mode over.
|
|
1534
|
-
if (options.mode === 'replay') {
|
|
1535
|
-
// Strict replay has the network blocked and no recording to serve from — agenticreplay never
|
|
1536
|
-
// understood this call well enough to match it. Say so: a bare 502 mid-replay reads as agenticreplay
|
|
1537
|
-
// being broken, when the honest fact is narrower and more useful than that.
|
|
1538
|
-
stats.unmatched += 1;
|
|
1539
|
-
const reason = `${path} was captured as opaque network traffic, not as a model exchange, so it ` +
|
|
1540
|
-
'cannot be replayed. Recorded with --tls-intercept, or on a path no dialect claims.';
|
|
1541
|
-
options.onUnmatched?.({ seq: -1, index: -1, reason });
|
|
1542
|
-
json(res, 502, {
|
|
1543
|
-
error: { message: `agenticreplay replay cannot reproduce ${path}: ${reason}` },
|
|
1544
|
-
});
|
|
1545
|
-
return;
|
|
1546
|
-
}
|
|
1547
783
|
stats.passedThrough += 1;
|
|
1548
784
|
const origin = passthroughOrigin(headers, forwardBase);
|
|
1549
785
|
let upstreamRes;
|
|
@@ -2142,14 +1378,6 @@ export async function createProxy(options) {
|
|
|
2142
1378
|
recordableHeaders['content-length'] = length;
|
|
2143
1379
|
}
|
|
2144
1380
|
if (!dialect) {
|
|
2145
|
-
// Before passthrough, and only where no dialect claims the path: a dialect is the richer
|
|
2146
|
-
// reading of a call — replayable, forkable, counted in `exact` — and a rule that outranked
|
|
2147
|
-
// one would turn a chat completion into opaque bytes keyed by their own body.
|
|
2148
|
-
const rule = selectRetrievalRule(retrievalRules, dialectPath, forwardHost(forward));
|
|
2149
|
-
if (rule && isReadable(rawBody)) {
|
|
2150
|
-
await serveRetrieval(rule, requestedPath, rawBody, headers, recordableHeaders, res, startedAt, requestId, forward?.base);
|
|
2151
|
-
return;
|
|
2152
|
-
}
|
|
2153
1381
|
await passThrough(requestedPath, rawBody, headers, recordableHeaders, res, startedAt, requestId, forward?.base, inboundRequestEncoding);
|
|
2154
1382
|
return;
|
|
2155
1383
|
}
|
|
@@ -2188,83 +1416,6 @@ export async function createProxy(options) {
|
|
|
2188
1416
|
json(res, 400, errorBody);
|
|
2189
1417
|
return;
|
|
2190
1418
|
}
|
|
2191
|
-
if (options.mode === 'record') {
|
|
2192
|
-
await goLive(dialect, requestedPath, rawBody, headers, recordableHeaders, res, startedAt, requestId, inboundRequestEncoding, forward?.base);
|
|
2193
|
-
return;
|
|
2194
|
-
}
|
|
2195
|
-
// Replay and hybrid: try the recording first.
|
|
2196
|
-
let canonical;
|
|
2197
|
-
try {
|
|
2198
|
-
canonical = dialect.toCanonicalRequest(JSON.parse(rawBody));
|
|
2199
|
-
}
|
|
2200
|
-
catch (err) {
|
|
2201
|
-
json(res, 400, { error: { message: `unparseable request body: ${String(err)}` } });
|
|
2202
|
-
return;
|
|
2203
|
-
}
|
|
2204
|
-
const beyondFork = options.mode === 'hybrid' && matcher.cursor >= (options.forkAt ?? replayable.length);
|
|
2205
|
-
if (!beyondFork) {
|
|
2206
|
-
const result = matcher.match(canonical);
|
|
2207
|
-
if (result.matched) {
|
|
2208
|
-
const exchange = replayable[result.index];
|
|
2209
|
-
if (result.reordered)
|
|
2210
|
-
stats.reordered += 1;
|
|
2211
|
-
if (result.divergence) {
|
|
2212
|
-
stats.matchedInexact += 1;
|
|
2213
|
-
stats.divergences += 1;
|
|
2214
|
-
options.onDivergence?.({ ...result.divergence, seq: exchange.seq });
|
|
2215
|
-
}
|
|
2216
|
-
else {
|
|
2217
|
-
stats.matchedExact += 1;
|
|
2218
|
-
}
|
|
2219
|
-
res.writeHead(exchange.status, {
|
|
2220
|
-
'content-type': exchange.streamed ? 'text/event-stream' : 'application/json',
|
|
2221
|
-
});
|
|
2222
|
-
res.end(exchange.rawResponse);
|
|
2223
|
-
return;
|
|
2224
|
-
}
|
|
2225
|
-
stats.unmatched += 1;
|
|
2226
|
-
// Hybrid mode is *supposed* to continue live here — that is what forking means — but spec §4
|
|
2227
|
-
// says replay must not silently approximate, and every inexact match is an event in the
|
|
2228
|
-
// trace. Without this a fork could start diverging below its own fork point and every
|
|
2229
|
-
// artifact it produced would look clean. Going live is the right behaviour; being quiet
|
|
2230
|
-
// about it is not.
|
|
2231
|
-
if (options.mode === 'hybrid' || options.loose) {
|
|
2232
|
-
stats.divergences += 1;
|
|
2233
|
-
options.onDivergence?.({
|
|
2234
|
-
level: 'major',
|
|
2235
|
-
rung: 4,
|
|
2236
|
-
distance: -1,
|
|
2237
|
-
detail: result.reason ?? 'request does not match the recording; served live instead',
|
|
2238
|
-
seq: replayable[result.index]?.seq ?? -1,
|
|
2239
|
-
});
|
|
2240
|
-
}
|
|
2241
|
-
if (!options.loose && options.mode === 'replay') {
|
|
2242
|
-
// Halt loudly. Inventing a reply here would make every downstream conclusion worthless.
|
|
2243
|
-
const reason = result.reason ?? 'request does not match the recording';
|
|
2244
|
-
options.onUnmatched?.({
|
|
2245
|
-
seq: replayable[result.index]?.seq ?? -1,
|
|
2246
|
-
index: result.index,
|
|
2247
|
-
reason,
|
|
2248
|
-
});
|
|
2249
|
-
// 400, emphatically not 409. Every mainstream client retries 408/409/429/5xx by default,
|
|
2250
|
-
// so a 409 halt is re-sent until the retry budget is gone — the operator gets a stalled
|
|
2251
|
-
// terminal and no reason, which is strictly worse than a wrong answer because it looks
|
|
2252
|
-
// like a hang in agenticreplay rather than a mismatch in the recording. 400 is terminal everywhere.
|
|
2253
|
-
//
|
|
2254
|
-
// The envelope is the provider's own error shape for the same reason: clients read
|
|
2255
|
-
// `error.message` and print it, so this sentence is what actually reaches the human.
|
|
2256
|
-
json(res, 400, {
|
|
2257
|
-
type: 'error',
|
|
2258
|
-
error: {
|
|
2259
|
-
type: 'invalid_request_error',
|
|
2260
|
-
message: `agenticreplay: replay halted — ${reason}. ` +
|
|
2261
|
-
'Re-run with `agenticreplay replay <run> --loose` to continue live from this point, ' +
|
|
2262
|
-
'or `agenticreplay show <run>` to see what was recorded.',
|
|
2263
|
-
},
|
|
2264
|
-
});
|
|
2265
|
-
return;
|
|
2266
|
-
}
|
|
2267
|
-
}
|
|
2268
1419
|
await goLive(dialect, requestedPath, rawBody, headers, recordableHeaders, res, startedAt, requestId, inboundRequestEncoding, forward?.base);
|
|
2269
1420
|
}
|
|
2270
1421
|
finally {
|