Skip to content

Commit bac282e

Browse files
iskhakovtclaude
andcommitted
feat: add requestTransform for deterministic matching and recording
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 604e447 commit bac282e

16 files changed

Lines changed: 255 additions & 36 deletions

src/__tests__/router.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,3 +601,98 @@ describe("matchFixture — first-match-wins", () => {
601601
expect(matchFixture([noMatch, match], req)).toBe(match);
602602
});
603603
});
604+
605+
// ---------------------------------------------------------------------------
606+
// matchFixture — requestTransform
607+
// ---------------------------------------------------------------------------
608+
609+
describe("matchFixture — requestTransform", () => {
610+
const stripTimestamp = (req: ChatCompletionRequest): ChatCompletionRequest => ({
611+
...req,
612+
messages: req.messages.map((m) => ({
613+
...m,
614+
content:
615+
typeof m.content === "string"
616+
? m.content.replace(/\d{4}-\d{2}-\d{2}T[\d:.Z+-]+/g, "")
617+
: m.content,
618+
})),
619+
});
620+
621+
it("uses exact equality for string userMessage when transform is set", () => {
622+
const fixture = makeFixture({ userMessage: "Event: happened" });
623+
const req = makeReq({
624+
messages: [{ role: "user", content: "Event: 2026-03-30T13:19:38Z happened" }],
625+
});
626+
// Without transform: substring "Event: happened" is NOT in the original text
627+
expect(matchFixture([fixture], req)).toBeNull();
628+
// With transform: timestamps stripped, exact match succeeds
629+
expect(matchFixture([fixture], req, undefined, stripTimestamp)).toBe(fixture);
630+
});
631+
632+
it("substring match still works without transform", () => {
633+
const fixture = makeFixture({ userMessage: "hello" });
634+
const req = makeReq({ messages: [{ role: "user", content: "say hello world" }] });
635+
expect(matchFixture([fixture], req)).toBe(fixture);
636+
});
637+
638+
it("exact match rejects substring matches when transform is set", () => {
639+
const identity = (req: ChatCompletionRequest): ChatCompletionRequest => req;
640+
const fixture = makeFixture({ userMessage: "hello" });
641+
const req = makeReq({ messages: [{ role: "user", content: "say hello world" }] });
642+
// With transform (identity): exact match fails because "say hello world" !== "hello"
643+
expect(matchFixture([fixture], req, undefined, identity)).toBeNull();
644+
});
645+
646+
it("uses exact equality for string inputText when transform is set", () => {
647+
const stripPrefix = (req: ChatCompletionRequest): ChatCompletionRequest => ({
648+
...req,
649+
embeddingInput: req.embeddingInput?.split(" | ")[0],
650+
});
651+
const fixture = makeFixture({ inputText: "concept" });
652+
const req = {
653+
...makeReq(),
654+
embeddingInput: "concept | session-abc-123",
655+
} as ChatCompletionRequest;
656+
// Without transform: substring "concept" is in "concept | session-abc-123"
657+
expect(matchFixture([fixture], req)).toBe(fixture);
658+
// With transform: embeddingInput becomes "concept", exact match succeeds
659+
expect(matchFixture([fixture], req, undefined, stripPrefix)).toBe(fixture);
660+
});
661+
662+
it("exact inputText rejects substring matches when transform is set", () => {
663+
const identity = (req: ChatCompletionRequest): ChatCompletionRequest => req;
664+
const fixture = makeFixture({ inputText: "concept" });
665+
const req = {
666+
...makeReq(),
667+
embeddingInput: "concept | session-abc-123",
668+
} as ChatCompletionRequest;
669+
// With transform (identity): exact match fails
670+
expect(matchFixture([fixture], req, undefined, identity)).toBeNull();
671+
});
672+
673+
it("regexp matching still works with transform", () => {
674+
// After stripTimestamp, content becomes "Event: happened" (double space)
675+
const fixture = makeFixture({ userMessage: /^Event:\s+happened$/ });
676+
const req = makeReq({
677+
messages: [{ role: "user", content: "Event: 2026-03-30T13:19:38Z happened" }],
678+
});
679+
// Regexp matches the transformed text
680+
expect(matchFixture([fixture], req, undefined, stripTimestamp)).toBe(fixture);
681+
});
682+
683+
it("predicate receives transformed request", () => {
684+
let capturedModel = "";
685+
const transform = (req: ChatCompletionRequest): ChatCompletionRequest => ({
686+
...req,
687+
model: "transformed-model",
688+
});
689+
const fixture = makeFixture({
690+
predicate: (r) => {
691+
capturedModel = r.model;
692+
return true;
693+
},
694+
});
695+
matchFixture([fixture], makeReq({ model: "original-model" }), undefined, transform);
696+
expect(capturedModel).toBe("transformed-model");
697+
});
698+
});

src/bedrock-converse.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,12 @@ export async function handleConverse(
263263

264264
const completionReq = converseToCompletionRequest(converseReq, modelId);
265265

266-
const fixture = matchFixture(fixtures, completionReq, journal.fixtureMatchCounts);
266+
const fixture = matchFixture(
267+
fixtures,
268+
completionReq,
269+
journal.fixtureMatchCounts,
270+
defaults.requestTransform,
271+
);
267272

268273
if (fixture) {
269274
journal.incrementFixtureMatchCount(fixture, fixtures);
@@ -466,7 +471,12 @@ export async function handleConverseStream(
466471

467472
const completionReq = converseToCompletionRequest(converseReq, modelId);
468473

469-
const fixture = matchFixture(fixtures, completionReq, journal.fixtureMatchCounts);
474+
const fixture = matchFixture(
475+
fixtures,
476+
completionReq,
477+
journal.fixtureMatchCounts,
478+
defaults.requestTransform,
479+
);
470480

471481
if (fixture) {
472482
journal.incrementFixtureMatchCount(fixture, fixtures);

src/bedrock.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,12 @@ export async function handleBedrock(
309309
// Convert to ChatCompletionRequest for fixture matching
310310
const completionReq = bedrockToCompletionRequest(bedrockReq, modelId);
311311

312-
const fixture = matchFixture(fixtures, completionReq, journal.fixtureMatchCounts);
312+
const fixture = matchFixture(
313+
fixtures,
314+
completionReq,
315+
journal.fixtureMatchCounts,
316+
defaults.requestTransform,
317+
);
313318

314319
if (fixture) {
315320
journal.incrementFixtureMatchCount(fixture, fixtures);
@@ -626,7 +631,12 @@ export async function handleBedrockStream(
626631

627632
const completionReq = bedrockToCompletionRequest(bedrockReq, modelId);
628633

629-
const fixture = matchFixture(fixtures, completionReq, journal.fixtureMatchCounts);
634+
const fixture = matchFixture(
635+
fixtures,
636+
completionReq,
637+
journal.fixtureMatchCounts,
638+
defaults.requestTransform,
639+
);
630640

631641
if (fixture) {
632642
journal.incrementFixtureMatchCount(fixture, fixtures);

src/cohere.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,12 @@ export async function handleCohere(
465465
// Convert to ChatCompletionRequest for fixture matching
466466
const completionReq = cohereToCompletionRequest(cohereReq);
467467

468-
const fixture = matchFixture(fixtures, completionReq, journal.fixtureMatchCounts);
468+
const fixture = matchFixture(
469+
fixtures,
470+
completionReq,
471+
journal.fixtureMatchCounts,
472+
defaults.requestTransform,
473+
);
469474

470475
if (fixture) {
471476
journal.incrementFixtureMatchCount(fixture, fixtures);

src/embeddings.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,12 @@ export async function handleEmbeddings(
8686
embeddingInput: combinedInput,
8787
};
8888

89-
const fixture = matchFixture(fixtures, syntheticReq, journal.fixtureMatchCounts);
89+
const fixture = matchFixture(
90+
fixtures,
91+
syntheticReq,
92+
journal.fixtureMatchCounts,
93+
defaults.requestTransform,
94+
);
9095

9196
if (fixture) {
9297
journal.incrementFixtureMatchCount(fixture, fixtures);

src/gemini.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,12 @@ export async function handleGemini(
415415
// Convert to ChatCompletionRequest for fixture matching
416416
const completionReq = geminiToCompletionRequest(geminiReq, model, streaming);
417417

418-
const fixture = matchFixture(fixtures, completionReq, journal.fixtureMatchCounts);
418+
const fixture = matchFixture(
419+
fixtures,
420+
completionReq,
421+
journal.fixtureMatchCounts,
422+
defaults.requestTransform,
423+
);
419424
const path = req.url ?? `/v1beta/models/${model}:generateContent`;
420425

421426
if (fixture) {

src/messages.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,12 @@ export async function handleMessages(
464464
// Convert to ChatCompletionRequest for fixture matching
465465
const completionReq = claudeToCompletionRequest(claudeReq);
466466

467-
const fixture = matchFixture(fixtures, completionReq, journal.fixtureMatchCounts);
467+
const fixture = matchFixture(
468+
fixtures,
469+
completionReq,
470+
journal.fixtureMatchCounts,
471+
defaults.requestTransform,
472+
);
468473

469474
if (fixture) {
470475
journal.incrementFixtureMatchCount(fixture, fixtures);

src/ollama.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -342,7 +342,12 @@ export async function handleOllama(
342342
// Convert to ChatCompletionRequest for fixture matching
343343
const completionReq = ollamaToCompletionRequest(ollamaReq);
344344

345-
const fixture = matchFixture(fixtures, completionReq, journal.fixtureMatchCounts);
345+
const fixture = matchFixture(
346+
fixtures,
347+
completionReq,
348+
journal.fixtureMatchCounts,
349+
defaults.requestTransform,
350+
);
346351

347352
if (fixture) {
348353
journal.incrementFixtureMatchCount(fixture, fixtures);
@@ -585,7 +590,12 @@ export async function handleOllamaGenerate(
585590
// Convert to ChatCompletionRequest for fixture matching
586591
const completionReq = ollamaGenerateToCompletionRequest(generateReq);
587592

588-
const fixture = matchFixture(fixtures, completionReq, journal.fixtureMatchCounts);
593+
const fixture = matchFixture(
594+
fixtures,
595+
completionReq,
596+
journal.fixtureMatchCounts,
597+
defaults.requestTransform,
598+
);
589599

590600
if (fixture) {
591601
journal.incrementFixtureMatchCount(fixture, fixtures);

src/recorder.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@ export async function proxyAndRecord(
3131
providerKey: RecordProviderKey,
3232
pathname: string,
3333
fixtures: Fixture[],
34-
defaults: { record?: RecordConfig; logger: Logger },
34+
defaults: {
35+
record?: RecordConfig;
36+
logger: Logger;
37+
requestTransform?: (req: ChatCompletionRequest) => ChatCompletionRequest;
38+
},
3539
rawBody?: string,
3640
): Promise<boolean> {
3741
const record = defaults.record;
@@ -146,8 +150,9 @@ export async function proxyAndRecord(
146150
fixtureResponse = buildFixtureResponse(parsedResponse, upstreamStatus);
147151
}
148152

149-
// Build the match criteria from the original request
150-
const fixtureMatch = buildFixtureMatch(request);
153+
// Build the match criteria from the (optionally transformed) request
154+
const effectiveRequest = defaults.requestTransform ? defaults.requestTransform(request) : request;
155+
const fixtureMatch = buildFixtureMatch(effectiveRequest);
151156

152157
// Build and save the fixture
153158
const fixture: Fixture = { match: fixtureMatch, response: fixtureResponse };

src/responses.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,12 @@ export async function handleResponses(
527527
// Convert to ChatCompletionRequest for fixture matching
528528
const completionReq = responsesToCompletionRequest(responsesReq);
529529

530-
const fixture = matchFixture(fixtures, completionReq, journal.fixtureMatchCounts);
530+
const fixture = matchFixture(
531+
fixtures,
532+
completionReq,
533+
journal.fixtureMatchCounts,
534+
defaults.requestTransform,
535+
);
531536

532537
if (fixture) {
533538
journal.incrementFixtureMatchCount(fixture, fixtures);

0 commit comments

Comments
 (0)