Arbitrary inspection — message count, specific content at any position, custom
@@ -442,6 +454,15 @@ Gotchas
instance. See Sequential Responses for when
sequenceIndex is the right tool.
+
+ context is an additional discriminator, not a replacement.
+ context scopes fixtures by integration identity (X-AIMock-Context
+ header). It combines with all other match fields via AND. Two fixtures with the same
+ userMessage but different context
+ values are not duplicates — the validator accounts for this.
+
diff --git a/docs/record-replay/index.html b/docs/record-replay/index.html
index 9b225d15..7c1c76c0 100644
--- a/docs/record-replay/index.html
+++ b/docs/record-replay/index.html
@@ -556,6 +556,49 @@ Model-Aware Recording
});
+ Context-Aware Recording
+
+ When a request carries an X-AIMock-Context header, the recorder automatically
+ captures the context value in match.context. On replay, fixtures with
+ context only match requests carrying that exact header value — fixtures
+ without context remain shared across all callers.
+
+
+ Directory routing
+
+ Without snapshot-style recording (X-Test-Id), recorded fixtures for a given
+ context are written to a <fixturePath>/<context>/ subdirectory:
+
+
+
+
+ fixtures/recorded/
+ openai-2026-05-18T10-30-00-000Z-a1b2c3d4.json # no context (shared)
+ langgraph-python/
+ openai-2026-05-18T10-30-01-000Z-e5f6a7b8.json # context = langgraph-python
+ crewai/
+ openai-2026-05-18T10-30-02-000Z-c9d0e1f2.json # context = crewai
+
+
+
+ When X-Test-Id is also present, snapshot-style paths take precedence and the
+ context is captured only in match.context within the fixture file, not in the
+ directory structure.
+
+
+ Sending X-AIMock-Context
+
+
+ // Set as a default header on your LLM client
+const client = new OpenAI({
+ baseURL: "http://localhost:4010/v1",
+ apiKey: "mock",
+ defaultHeaders: { "X-AIMock-Context": "langgraph-python" },
+});
+
+
Upstream Timeouts
@@ -858,7 +901,10 @@ Recording Multi-Turn Conversations
}
// Chat/multimedia — key on the LAST user message only
const lastUser = getLastMessageByRole(request.messages, "user");
- return { userMessage: getTextContent(lastUser.content) };
+ const match = { userMessage: getTextContent(lastUser.content) };
+ // Capture context from X-AIMock-Context header if present
+ if (request._context) match.context = request._context;
+ return match;
}
diff --git a/src/__tests__/fixture-loader.test.ts b/src/__tests__/fixture-loader.test.ts
index d62f3c3e..fca5178c 100644
--- a/src/__tests__/fixture-loader.test.ts
+++ b/src/__tests__/fixture-loader.test.ts
@@ -1006,6 +1006,30 @@ describe("validateFixtures", () => {
).toBe(true);
});
+ // --- match.context type checks ---
+
+ it("validateFixtures reports error for non-string context", () => {
+ const fixtures = [
+ makeFixture({ match: { userMessage: "test", context: 42 as unknown as string } }),
+ ];
+ const results = validateFixtures(fixtures);
+ expect(
+ results.some((r) => r.severity === "error" && r.message.includes("must be a string")),
+ ).toBe(true);
+ });
+
+ it("context is a valid discriminator", () => {
+ const fixtures = [
+ makeFixture({ match: { context: "x" } }),
+ makeFixture({ match: { userMessage: "hello" } }),
+ ];
+ const results = validateFixtures(fixtures);
+ const catchAllWarnings = results.filter(
+ (r) => r.severity === "warning" && r.message.includes("catch-all"),
+ );
+ expect(catchAllWarnings).toHaveLength(0);
+ });
+
// --- Warning checks ---
it("warning: duplicate userMessage", () => {
@@ -1545,6 +1569,15 @@ describe("auto-stringify JSON objects in fixture entries", () => {
expect((fixture.response as TextResponse).content).toBe("Hello, world!");
});
+ it("entryToFixture preserves context field", () => {
+ const entry: FixtureFileEntry = {
+ match: { context: "my-ctx", userMessage: "hi" },
+ response: { content: "ok" },
+ };
+ const fixture = entryToFixture(entry);
+ expect(fixture.match.context).toBe("my-ctx");
+ });
+
it("passes systemMessage through entryToFixture", () => {
const entry: FixtureFileEntry = {
match: { userMessage: "test", systemMessage: "name=Atai" },
diff --git a/src/__tests__/helpers.test.ts b/src/__tests__/helpers.test.ts
index 9634320c..e11cd540 100644
--- a/src/__tests__/helpers.test.ts
+++ b/src/__tests__/helpers.test.ts
@@ -1,3 +1,4 @@
+import http from "node:http";
import { describe, it, expect } from "vitest";
import {
generateId,
@@ -11,6 +12,7 @@ import {
buildToolCallChunks,
buildTextCompletion,
buildToolCallCompletion,
+ getContext,
} from "../helpers.js";
describe("generateId", () => {
@@ -292,6 +294,32 @@ describe("buildToolCallChunks", () => {
});
});
+describe("getContext", () => {
+ it("returns header value", () => {
+ const req = {
+ headers: { "x-aimock-context": "langgraph-python" },
+ } as unknown as http.IncomingMessage;
+ expect(getContext(req)).toBe("langgraph-python");
+ });
+
+ it("returns undefined when header absent", () => {
+ const req = { headers: {} } as unknown as http.IncomingMessage;
+ expect(getContext(req)).toBeUndefined();
+ });
+
+ it("returns first value from array header", () => {
+ const req = {
+ headers: { "x-aimock-context": ["first", "second"] },
+ } as unknown as http.IncomingMessage;
+ expect(getContext(req)).toBe("first");
+ });
+
+ it("returns undefined for empty string", () => {
+ const req = { headers: { "x-aimock-context": "" } } as unknown as http.IncomingMessage;
+ expect(getContext(req)).toBeUndefined();
+ });
+});
+
describe("buildTextCompletion", () => {
it("returns a valid chat.completion object", () => {
const result = buildTextCompletion("Hello!", "gpt-4");
diff --git a/src/__tests__/recorder.test.ts b/src/__tests__/recorder.test.ts
index 65498c87..25ae6e64 100644
--- a/src/__tests__/recorder.test.ts
+++ b/src/__tests__/recorder.test.ts
@@ -5,7 +5,7 @@ import * as os from "node:os";
import * as path from "node:path";
import type { Fixture, FixtureFile } from "../types.js";
import { createServer, type ServerInstance } from "../server.js";
-import { proxyAndRecord, type ProxyCapturedResponse } from "../recorder.js";
+import { proxyAndRecord, buildFixtureMatch, type ProxyCapturedResponse } from "../recorder.js";
import type { RecordConfig } from "../types.js";
import { Logger } from "../logger.js";
import { LLMock } from "../llmock.js";
@@ -4105,6 +4105,29 @@ describe("buildFixtureMatch model recording", () => {
});
});
+// ---------------------------------------------------------------------------
+// buildFixtureMatch context
+// ---------------------------------------------------------------------------
+
+describe("buildFixtureMatch context", () => {
+ it("captures _context in match criteria", () => {
+ const match = buildFixtureMatch({
+ model: "gpt-4o",
+ messages: [{ role: "user", content: "hello" }],
+ _context: "langgraph-python",
+ });
+ expect(match.context).toBe("langgraph-python");
+ });
+
+ it("omits context when _context is absent", () => {
+ const match = buildFixtureMatch({
+ model: "gpt-4o",
+ messages: [{ role: "user", content: "hello" }],
+ });
+ expect(match.context).toBeUndefined();
+ });
+});
+
async function setupUpstreamAndRecorder(
upstreamFixtures: Fixture[],
providerKey: string = "openai",
diff --git a/src/__tests__/router.test.ts b/src/__tests__/router.test.ts
index cea9315f..3a2590b8 100644
--- a/src/__tests__/router.test.ts
+++ b/src/__tests__/router.test.ts
@@ -1107,6 +1107,43 @@ describe("matchFixture — hasToolResult", () => {
});
});
+// ---------------------------------------------------------------------------
+// matchFixture — context matching
+// ---------------------------------------------------------------------------
+
+describe("matchFixture — context matching", () => {
+ it("matches fixture with matching context", () => {
+ const fixture = makeFixture({ context: "foo" });
+ const req = makeReq({ _context: "foo" });
+ expect(matchFixture([fixture], req)).toBe(fixture);
+ });
+
+ it("skips fixture with non-matching context", () => {
+ const fixture = makeFixture({ context: "foo" });
+ const req = makeReq({ _context: "bar" });
+ expect(matchFixture([fixture], req)).toBeNull();
+ });
+
+ it("matches fixture without context regardless of request context", () => {
+ const fixture = makeFixture({});
+ const req = makeReq({ _context: "bar" });
+ expect(matchFixture([fixture], req)).toBe(fixture);
+ });
+
+ it("skips context fixture when request has no context", () => {
+ const fixture = makeFixture({ context: "foo" });
+ const req = makeReq();
+ expect(matchFixture([fixture], req)).toBeNull();
+ });
+
+ it("context fixture wins over shared when listed first", () => {
+ const contextual = makeFixture({ context: "foo" }, { content: "contextual" });
+ const shared = makeFixture({}, { content: "shared" });
+ const req = makeReq({ _context: "foo" });
+ expect(matchFixture([contextual, shared], req)).toBe(contextual);
+ });
+});
+
// ---------------------------------------------------------------------------
// matchFixture — first-match-wins
// ---------------------------------------------------------------------------
diff --git a/src/__tests__/strict-header.test.ts b/src/__tests__/strict-header.test.ts
index 45e38184..2561f085 100644
--- a/src/__tests__/strict-header.test.ts
+++ b/src/__tests__/strict-header.test.ts
@@ -220,6 +220,28 @@ describe("X-AIMock-Strict header integration", () => {
expect(entries[0].response.strictOverride).toBe(false);
});
+ it("strict mode 503 fires when context filtering removes all matches", async () => {
+ const contextFixture: Fixture = {
+ match: { context: "foo", userMessage: "hello" },
+ response: { content: "Hello from foo context" },
+ };
+ server = await createServer([contextFixture], { port: 0, strict: true });
+
+ // Context "bar" filters out the only fixture → strict 503
+ const mismatched = await httpPost(`${server.url}/v1/chat/completions`, chatRequest("hello"), {
+ "X-AIMock-Context": "bar",
+ });
+ expect(mismatched.status).toBe(503);
+ const body503 = JSON.parse(mismatched.body);
+ expect(body503.error.message).toBe("Strict mode: no fixture matched");
+
+ // Context "foo" matches → 200
+ const matched = await httpPost(`${server.url}/v1/chat/completions`, chatRequest("hello"), {
+ "X-AIMock-Context": "foo",
+ });
+ expect(matched.status).toBe(200);
+ });
+
it("strict header prevents proxy in record mode", async () => {
server = await createServer([], {
port: 0,
diff --git a/src/bedrock-converse.ts b/src/bedrock-converse.ts
index 4077cd97..e9d51315 100644
--- a/src/bedrock-converse.ts
+++ b/src/bedrock-converse.ts
@@ -25,6 +25,7 @@ import {
isContentWithToolCallsResponse,
isErrorResponse,
flattenHeaders,
+ getContext,
getTestId,
resolveResponse,
resolveStrictMode,
@@ -560,6 +561,7 @@ export async function handleConverse(
const completionReq = converseToCompletionRequest(converseReq, modelId, logger);
completionReq._endpointType = "chat";
+ completionReq._context = getContext(req);
const testId = getTestId(req);
const fixture = matchFixture(
@@ -853,6 +855,7 @@ export async function handleConverseStream(
const completionReq = converseToCompletionRequest(converseReq, modelId, logger);
completionReq.stream = true;
completionReq._endpointType = "chat";
+ completionReq._context = getContext(req);
const testId = getTestId(req);
const fixture = matchFixture(
diff --git a/src/bedrock.ts b/src/bedrock.ts
index 543a91cc..33ae5e56 100644
--- a/src/bedrock.ts
+++ b/src/bedrock.ts
@@ -36,6 +36,7 @@ import {
isContentWithToolCallsResponse,
isErrorResponse,
flattenHeaders,
+ getContext,
getTestId,
resolveResponse,
resolveStrictMode,
@@ -388,6 +389,7 @@ export async function handleBedrock(
// Convert to ChatCompletionRequest for fixture matching
const completionReq = bedrockToCompletionRequest(bedrockReq, modelId, logger);
completionReq._endpointType = "chat";
+ completionReq._context = getContext(req);
const testId = getTestId(req);
const fixture = matchFixture(
@@ -1031,6 +1033,7 @@ export async function handleBedrockStream(
const completionReq = bedrockToCompletionRequest(bedrockReq, modelId, logger);
completionReq.stream = true;
completionReq._endpointType = "chat";
+ completionReq._context = getContext(req);
const testId = getTestId(req);
const fixture = matchFixture(
diff --git a/src/cohere.ts b/src/cohere.ts
index 3461a80b..6caaaf6d 100644
--- a/src/cohere.ts
+++ b/src/cohere.ts
@@ -37,6 +37,7 @@ import {
resolveResponse,
resolveStrictMode,
strictOverrideField,
+ getContext,
} from "./helpers.js";
import { matchFixture } from "./router.js";
import { writeErrorResponse, delay, calculateDelay } from "./sse-writer.js";
@@ -833,6 +834,7 @@ export async function handleCohere(
// Convert to ChatCompletionRequest for fixture matching
const completionReq = cohereToCompletionRequest(cohereReq);
completionReq._endpointType = "chat";
+ completionReq._context = getContext(req);
const testId = getTestId(req);
const fixture = matchFixture(
@@ -1238,6 +1240,7 @@ export async function handleCohereEmbed(
messages: [],
embeddingInput: combinedInput,
_endpointType: "embedding",
+ _context: getContext(req),
};
const testId = getTestId(req);
diff --git a/src/elevenlabs-audio.ts b/src/elevenlabs-audio.ts
index d2a60f89..38394315 100644
--- a/src/elevenlabs-audio.ts
+++ b/src/elevenlabs-audio.ts
@@ -7,6 +7,7 @@ import {
serializeErrorResponse,
flattenHeaders,
FORMAT_TO_CONTENT_TYPE,
+ getContext,
getTestId,
resolveResponse,
resolveStrictMode,
@@ -65,6 +66,7 @@ export async function handleElevenLabsTTS(
model: (parsed.model_id as string) ?? "eleven_multilingual_v2",
messages: [{ role: "user", content: promptText ?? "" }],
_endpointType: "elevenlabs-tts",
+ _context: getContext(req),
};
// Validate required field
@@ -316,6 +318,7 @@ export async function handleElevenLabsAudio(
(subType === "sound-generation" ? "eleven_text_to_sound_v2" : "music_v1"),
messages: [{ role: "user", content: promptText ?? "" }],
_endpointType: "audio-gen",
+ _context: getContext(req),
};
// Validate required field
diff --git a/src/embeddings.ts b/src/embeddings.ts
index 9243cbf6..44c0fc74 100644
--- a/src/embeddings.ts
+++ b/src/embeddings.ts
@@ -24,6 +24,7 @@ import {
resolveResponse,
resolveStrictMode,
strictOverrideField,
+ getContext,
} from "./helpers.js";
import { matchFixture } from "./router.js";
import { writeErrorResponse } from "./sse-writer.js";
@@ -119,6 +120,7 @@ export async function handleEmbeddings(
messages: [],
embeddingInput: combinedInput,
_endpointType: "embedding",
+ _context: getContext(req),
};
const testId = getTestId(req);
diff --git a/src/fal-audio.ts b/src/fal-audio.ts
index 46ac27c8..9c4d654f 100644
--- a/src/fal-audio.ts
+++ b/src/fal-audio.ts
@@ -14,6 +14,7 @@ import {
serializeErrorResponse,
flattenHeaders,
FORMAT_TO_CONTENT_TYPE,
+ getContext,
getTestId,
resolveResponse,
resolveStrictMode,
@@ -297,6 +298,7 @@ async function handleQueueSubmit(
model: modelId,
messages: [{ role: "user", content: prompt }],
_endpointType: "fal-audio",
+ _context: getContext(req),
};
const fixture = matchFixture(fixtures, syntheticReq, matchCounts, defaults.requestTransform);
@@ -824,6 +826,7 @@ async function handleSyncRun(
model: modelId,
messages: [{ role: "user", content: prompt }],
_endpointType: "fal-audio",
+ _context: getContext(req),
};
const fixture = matchFixture(fixtures, syntheticReq, matchCounts, defaults.requestTransform);
diff --git a/src/fal.ts b/src/fal.ts
index 315fca06..d9813f20 100644
--- a/src/fal.ts
+++ b/src/fal.ts
@@ -16,6 +16,7 @@ import {
serializeErrorResponse,
isJSONResponse,
flattenHeaders,
+ getContext,
getTestId,
resolveResponse,
resolveStrictMode,
@@ -522,6 +523,7 @@ export async function handleFal(
model: modelId,
messages: [{ role: "user", content: prompt || JSON.stringify(parsedBody ?? {}) }],
_endpointType: "fal",
+ _context: getContext(req),
};
const matchCounts = journal.getFixtureMatchCountsForTest(testId);
diff --git a/src/fixture-loader.ts b/src/fixture-loader.ts
index adedd8af..4f80b06e 100644
--- a/src/fixture-loader.ts
+++ b/src/fixture-loader.ts
@@ -67,6 +67,7 @@ export function entryToFixture(entry: FixtureFileEntry, logger?: Logger): Fixtur
...(entry.match.hasToolResult !== undefined && {
hasToolResult: entry.match.hasToolResult,
}),
+ ...(entry.match.context !== undefined && { context: entry.match.context }),
},
response: normalizeResponse(entry.response),
...(entry.latency !== undefined && { latency: entry.latency }),
@@ -682,6 +683,13 @@ export function validateFixtures(fixtures: Fixture[]): ValidationResult[] {
});
}
}
+ if (f.match.context !== undefined && typeof f.match.context !== "string") {
+ results.push({
+ severity: "error",
+ fixtureIndex: i,
+ message: `match.context must be a string, got ${typeof f.match.context}`,
+ });
+ }
// --- Warning checks ---
@@ -690,7 +698,7 @@ export function validateFixtures(fixtures: Fixture[]): ValidationResult[] {
// but differ on those fields are NOT considered duplicates.
const um = f.match.userMessage;
if (typeof um === "string" && um) {
- const dedupKey = `${um}|${f.match.turnIndex}|${f.match.hasToolResult}|${f.match.sequenceIndex}`;
+ const dedupKey = `${um}|${f.match.turnIndex}|${f.match.hasToolResult}|${f.match.sequenceIndex}|${f.match.context}`;
const prev = seenUserMessages.get(dedupKey);
if (prev !== undefined) {
results.push({
@@ -707,6 +715,7 @@ export function validateFixtures(fixtures: Fixture[]): ValidationResult[] {
const match = f.match;
const hasDiscriminator =
match.endpoint !== undefined ||
+ match.context !== undefined ||
match.userMessage !== undefined ||
match.systemMessage !== undefined ||
match.inputText !== undefined ||
diff --git a/src/gemini-embeddings.ts b/src/gemini-embeddings.ts
index 810015e6..19f70894 100644
--- a/src/gemini-embeddings.ts
+++ b/src/gemini-embeddings.ts
@@ -26,6 +26,7 @@ import {
resolveResponse,
resolveStrictMode,
strictOverrideField,
+ getContext,
} from "./helpers.js";
import { matchFixture } from "./router.js";
import { writeErrorResponse } from "./sse-writer.js";
@@ -117,6 +118,7 @@ export async function handleGeminiEmbedContent(
messages: [],
embeddingInput: inputText,
_endpointType: "embedding",
+ _context: getContext(req),
};
const testId = getTestId(req);
diff --git a/src/gemini-interactions.ts b/src/gemini-interactions.ts
index a310179c..86d74461 100644
--- a/src/gemini-interactions.ts
+++ b/src/gemini-interactions.ts
@@ -28,6 +28,7 @@ import {
generateToolCallId,
flattenHeaders,
getTestId,
+ getContext,
resolveResponse,
resolveStrictMode,
strictOverrideField,
@@ -668,6 +669,7 @@ export async function handleGeminiInteractions(
// Convert to ChatCompletionRequest for fixture matching
const completionReq = geminiInteractionsToCompletionRequest(interactionsReq);
completionReq._endpointType = "chat";
+ completionReq._context = getContext(req);
const streaming = interactionsReq.stream !== false; // default true
const model = completionReq.model;
diff --git a/src/gemini.ts b/src/gemini.ts
index 0b98f362..a10d13ef 100644
--- a/src/gemini.ts
+++ b/src/gemini.ts
@@ -29,6 +29,7 @@ import {
extractOverrides,
formatToMime,
flattenHeaders,
+ getContext,
getTestId,
resolveResponse,
resolveStrictMode,
@@ -632,6 +633,7 @@ export async function handleGemini(
// Convert to ChatCompletionRequest for fixture matching
const completionReq = geminiToCompletionRequest(geminiReq, model, streaming);
completionReq._endpointType = "chat";
+ completionReq._context = getContext(req);
const testId = getTestId(req);
const fixture = matchFixture(
diff --git a/src/helpers.ts b/src/helpers.ts
index 1ba19128..d444b6a3 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -861,6 +861,16 @@ export function getTestId(req: http.IncomingMessage): string {
return DEFAULT_TEST_ID;
}
+export function getContext(req: http.IncomingMessage): string | undefined {
+ const headerValue = req.headers["x-aimock-context"];
+ if (Array.isArray(headerValue)) {
+ if (headerValue.length > 0 && headerValue[0]) return headerValue[0];
+ } else if (typeof headerValue === "string" && headerValue) {
+ return headerValue;
+ }
+ return undefined;
+}
+
// ─── Snapshot recording helpers ──────────────────────────────────────────────
/**
diff --git a/src/images.ts b/src/images.ts
index 48191d0a..b5a7f2a7 100644
--- a/src/images.ts
+++ b/src/images.ts
@@ -5,6 +5,7 @@ import {
isErrorResponse,
serializeErrorResponse,
flattenHeaders,
+ getContext,
getTestId,
resolveResponse,
resolveStrictMode,
@@ -32,11 +33,16 @@ interface GeminiPredictRequest {
[key: string]: unknown;
}
-function buildSyntheticRequest(model: string, prompt: string): ChatCompletionRequest {
+function buildSyntheticRequest(
+ model: string,
+ prompt: string,
+ context?: string,
+): ChatCompletionRequest {
return {
model,
messages: [{ role: "user", content: prompt }],
_endpointType: "image",
+ ...(context !== undefined && { _context: context }),
};
}
@@ -110,7 +116,7 @@ export async function handleImages(
return;
}
- const syntheticReq = buildSyntheticRequest(model, prompt);
+ const syntheticReq = buildSyntheticRequest(model, prompt, getContext(req));
const testId = getTestId(req);
const fixture = matchFixture(
fixtures,
@@ -342,7 +348,7 @@ export async function handleImageEdit(
return;
}
- const syntheticReq = buildSyntheticRequest(model, prompt);
+ const syntheticReq = buildSyntheticRequest(model, prompt, getContext(req));
const testId = getTestId(req);
const fixture = matchFixture(
fixtures,
@@ -526,7 +532,7 @@ export async function handleImageVariations(
const model = extractFormField(raw, "model", boundary) ?? "dall-e-2";
// Variations don't have a prompt — use a synthetic placeholder for fixture matching
- const syntheticReq = buildSyntheticRequest(model, "[variation]");
+ const syntheticReq = buildSyntheticRequest(model, "[variation]", getContext(req));
const testId = getTestId(req);
const fixture = matchFixture(
fixtures,
diff --git a/src/messages.ts b/src/messages.ts
index 96e70c40..6e0a9ce0 100644
--- a/src/messages.ts
+++ b/src/messages.ts
@@ -31,6 +31,7 @@ import {
resolveResponse,
resolveStrictMode,
strictOverrideField,
+ getContext,
} from "./helpers.js";
import { matchFixture } from "./router.js";
import { writeErrorResponse, delay, calculateDelay } from "./sse-writer.js";
@@ -747,6 +748,7 @@ export async function handleMessages(
// Convert to ChatCompletionRequest for fixture matching
const completionReq = claudeToCompletionRequest(claudeReq);
+ completionReq._context = getContext(req);
const testId = getTestId(req);
const fixture = matchFixture(
diff --git a/src/ollama.ts b/src/ollama.ts
index 0491572e..6ee5c7b3 100644
--- a/src/ollama.ts
+++ b/src/ollama.ts
@@ -35,6 +35,7 @@ import {
resolveResponse,
resolveStrictMode,
strictOverrideField,
+ getContext,
} from "./helpers.js";
import { matchFixture } from "./router.js";
import { writeErrorResponse } from "./sse-writer.js";
@@ -539,6 +540,7 @@ export async function handleOllama(
// Convert to ChatCompletionRequest for fixture matching
const completionReq = ollamaToCompletionRequest(ollamaReq);
completionReq._endpointType = "chat";
+ completionReq._context = getContext(req);
const testId = getTestId(req);
const fixture = matchFixture(
@@ -889,6 +891,7 @@ export async function handleOllamaGenerate(
// Convert to ChatCompletionRequest for fixture matching
const completionReq = ollamaGenerateToCompletionRequest(generateReq);
completionReq._endpointType = "chat";
+ completionReq._context = getContext(req);
const testId = getTestId(req);
const fixture = matchFixture(
@@ -1212,6 +1215,7 @@ export async function handleOllamaEmbeddings(
messages: [],
embeddingInput: inputText,
_endpointType: "embedding",
+ _context: getContext(req),
};
const testId = getTestId(req);
diff --git a/src/recorder.ts b/src/recorder.ts
index 990accd5..8b2f2e8a 100644
--- a/src/recorder.ts
+++ b/src/recorder.ts
@@ -163,10 +163,10 @@ export function persistFixture(opts: {
}
} else {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
- filepath = path.join(
- fixturePath,
- `${providerKey}-${timestamp}-${crypto.randomUUID().slice(0, 8)}.json`,
- );
+ const timestampFile = `${providerKey}-${timestamp}-${crypto.randomUUID().slice(0, 8)}.json`;
+ filepath = fixture.match.context
+ ? path.join(fixturePath, fixture.match.context, timestampFile)
+ : path.join(fixturePath, timestampFile);
}
const fileWarnings = [
@@ -175,7 +175,7 @@ export function persistFixture(opts: {
];
try {
- fs.mkdirSync(isSnapshotMode ? path.dirname(filepath) : fixturePath, { recursive: true });
+ fs.mkdirSync(path.dirname(filepath), { recursive: true });
// Auth headers are forwarded to upstream but excluded from saved fixtures.
// The persisted fixture is always the real upstream response, even when
@@ -1274,6 +1274,7 @@ export function buildFixtureMatch(
endpoint?: EndpointType;
turnIndex?: number;
hasToolResult?: boolean;
+ context?: string;
} {
const match: {
userMessage?: string;
@@ -1282,6 +1283,7 @@ export function buildFixtureMatch(
endpoint?: EndpointType;
turnIndex?: number;
hasToolResult?: boolean;
+ context?: string;
} = {};
// Include endpoint type for multimedia fixtures
@@ -1321,6 +1323,10 @@ export function buildFixtureMatch(
match.hasToolResult = messages.some((m) => m.role === "tool");
}
+ if (request._context) {
+ match.context = request._context;
+ }
+
return match;
}
diff --git a/src/responses.ts b/src/responses.ts
index 6560252e..e5b475f5 100644
--- a/src/responses.ts
+++ b/src/responses.ts
@@ -31,6 +31,7 @@ import {
resolveResponse,
resolveStrictMode,
strictOverrideField,
+ getContext,
} from "./helpers.js";
import { matchFixture } from "./router.js";
import { writeErrorResponse, delay, calculateDelay } from "./sse-writer.js";
@@ -946,6 +947,7 @@ export async function handleResponses(
// Convert to ChatCompletionRequest for fixture matching
const completionReq = responsesToCompletionRequest(responsesReq);
completionReq._endpointType = "chat";
+ completionReq._context = getContext(req);
const testId = getTestId(req);
const fixture = matchFixture(
diff --git a/src/router.ts b/src/router.ts
index ede7598b..5d8861f3 100644
--- a/src/router.ts
+++ b/src/router.ts
@@ -97,6 +97,13 @@ export function matchFixture(
}
}
+ // context — opt-in exact match against the request's _context field.
+ // If fixture specifies a context, only match requests with that exact context.
+ // If fixture omits context, match any request regardless of _context.
+ if (match.context !== undefined) {
+ if (effective._context !== match.context) continue;
+ }
+
// userMessage — case-sensitive match against the last user message content.
// String matching is intentionally case-sensitive so fixture authors can
// rely on exact string values. This differs from the case-insensitive
diff --git a/src/server.ts b/src/server.ts
index c6e8dda9..8f632085 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -36,6 +36,7 @@ import {
resolveResponse,
resolveStrictMode,
strictOverrideField,
+ getContext,
} from "./helpers.js";
import { handleResponses } from "./responses.js";
import { handleMessages } from "./messages.js";
@@ -486,6 +487,7 @@ async function handleCompletions(
// Set endpoint type once early so router/recorder and journal see it
body._endpointType = "chat";
+ body._context = getContext(req);
// Match fixture first — chaos resolution depends on fixture-level overrides
// (headers > fixture.chaos > server defaults), so the fixture has to be
diff --git a/src/speech.ts b/src/speech.ts
index 8ecba2df..22e5b7d0 100644
--- a/src/speech.ts
+++ b/src/speech.ts
@@ -10,6 +10,7 @@ import {
resolveResponse,
resolveStrictMode,
strictOverrideField,
+ getContext,
} from "./helpers.js";
import { matchFixture } from "./router.js";
import { writeErrorResponse } from "./sse-writer.js";
@@ -87,6 +88,7 @@ export async function handleSpeech(
model: speechReq.model ?? "tts-1",
messages: [{ role: "user", content: speechReq.input }],
_endpointType: "speech",
+ _context: getContext(req),
};
const testId = getTestId(req);
diff --git a/src/transcription.ts b/src/transcription.ts
index f9a49666..69a8aa11 100644
--- a/src/transcription.ts
+++ b/src/transcription.ts
@@ -9,6 +9,7 @@ import {
resolveResponse,
resolveStrictMode,
strictOverrideField,
+ getContext,
} from "./helpers.js";
import { matchFixture } from "./router.js";
import { writeErrorResponse } from "./sse-writer.js";
@@ -100,6 +101,7 @@ export async function handleTranscription(
model,
messages: [],
_endpointType: endpointType,
+ _context: getContext(req),
};
const testId = getTestId(req);
diff --git a/src/types.ts b/src/types.ts
index 997d4355..3c68c7be 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -53,6 +53,8 @@ export interface ChatCompletionRequest {
embeddingInput?: string;
/** Endpoint type, set by handlers for fixture endpoint filtering. */
_endpointType?: string;
+ /** Context identifier, set by handlers for fixture context routing. */
+ _context?: string;
[key: string]: unknown;
}
@@ -105,6 +107,7 @@ export interface FixtureMatch {
| "realtime"
| "realtime-transcription"
| "realtime-translation";
+ context?: string;
}
// Fixture response types
@@ -406,6 +409,7 @@ export interface FixtureFileEntry {
| "realtime"
| "realtime-transcription"
| "realtime-translation";
+ context?: string;
// predicate not supported in JSON files
};
response: FixtureFileResponse;
diff --git a/src/video.ts b/src/video.ts
index ad29427b..dd0f52ff 100644
--- a/src/video.ts
+++ b/src/video.ts
@@ -9,6 +9,7 @@ import {
resolveResponse,
resolveStrictMode,
strictOverrideField,
+ getContext,
} from "./helpers.js";
import { matchFixture } from "./router.js";
import { writeErrorResponse } from "./sse-writer.js";
@@ -166,6 +167,7 @@ export async function handleVideoCreate(
model: videoReq.model ?? "sora-2",
messages: [{ role: "user", content: videoReq.prompt }],
_endpointType: "video",
+ _context: getContext(req),
};
const testId = getTestId(req);
diff --git a/src/ws-gemini-live.ts b/src/ws-gemini-live.ts
index 94ec26fe..6705659f 100644
--- a/src/ws-gemini-live.ts
+++ b/src/ws-gemini-live.ts
@@ -363,12 +363,21 @@ async function processMessage(
}
// Build completion request for fixture matching (include new messages speculatively)
+ const geminiContextHeader = defaults.upgradeHeaders?.["x-aimock-context"];
+ const geminiContext =
+ typeof geminiContextHeader === "string"
+ ? geminiContextHeader
+ : Array.isArray(geminiContextHeader) && geminiContextHeader.length > 0
+ ? geminiContextHeader[0]
+ : undefined;
+
const completionReq: ChatCompletionRequest = {
model: session.model,
messages: [...session.conversationHistory, ...newMessages],
stream: true,
tools: session.tools.length > 0 ? session.tools : undefined,
_endpointType: "chat",
+ _context: geminiContext,
};
const testId = defaults.testId ?? DEFAULT_TEST_ID;
diff --git a/src/ws-realtime.ts b/src/ws-realtime.ts
index 46496c96..489b1645 100644
--- a/src/ws-realtime.ts
+++ b/src/ws-realtime.ts
@@ -680,10 +680,19 @@ async function handleResponseCreate(
};
const endpointType = endpointTypeMap[session.type] ?? "realtime";
+ const realtimeContextHeader = defaults.upgradeHeaders?.["x-aimock-context"];
+ const realtimeContext =
+ typeof realtimeContextHeader === "string"
+ ? realtimeContextHeader
+ : Array.isArray(realtimeContextHeader) && realtimeContextHeader.length > 0
+ ? realtimeContextHeader[0]
+ : undefined;
+
const completionReq: ChatCompletionRequest = {
model: session.model,
messages,
_endpointType: endpointType,
+ _context: realtimeContext,
};
const testId = defaults.testId ?? DEFAULT_TEST_ID;
diff --git a/src/ws-responses.ts b/src/ws-responses.ts
index b8c5f06e..a5f2ea85 100644
--- a/src/ws-responses.ts
+++ b/src/ws-responses.ts
@@ -173,6 +173,13 @@ async function processMessage(
const completionReq = responsesToCompletionRequest(responsesReq);
completionReq._endpointType = "chat";
+ const contextHeader = defaults.upgradeHeaders?.["x-aimock-context"];
+ completionReq._context =
+ typeof contextHeader === "string"
+ ? contextHeader
+ : Array.isArray(contextHeader) && contextHeader.length > 0
+ ? contextHeader[0]
+ : undefined;
const testId = defaults.testId ?? DEFAULT_TEST_ID;
const fixture = matchFixture(
fixtures,
|