diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index 2e71ec8..7e4c690 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -9,7 +9,7 @@
"source": {
"source": "npm",
"package": "@copilotkit/aimock",
- "version": "^1.9.0"
+ "version": "^1.10.0"
},
"description": "Fixture authoring skill for @copilotkit/aimock — match fields, response types, embeddings, structured output, sequential responses, streaming physics, agent loop patterns, gotchas, and debugging"
}
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 727c190..9bf930e 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "llmock",
- "version": "1.9.0",
+ "version": "1.10.0",
"description": "Fixture authoring guidance for @copilotkit/aimock",
"author": {
"name": "CopilotKit"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bd911c7..4df1210 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# @copilotkit/aimock
+## 1.10.0
+
+### Minor Changes
+
+- Add `--proxy-only` flag — proxy unmatched requests to upstream providers without saving fixtures to disk or caching in memory. Every unmatched request always hits the real provider, preventing stale recorded responses in demo/live environments (#99)
+
## 1.9.0
### Minor Changes
diff --git a/charts/aimock/Chart.yaml b/charts/aimock/Chart.yaml
index af314a3..a3dab29 100644
--- a/charts/aimock/Chart.yaml
+++ b/charts/aimock/Chart.yaml
@@ -3,4 +3,4 @@ name: aimock
description: Mock infrastructure for AI application testing (OpenAI, Anthropic, Gemini, MCP, A2A, vector)
type: application
version: 0.1.0
-appVersion: "1.9.0"
+appVersion: "1.10.0"
diff --git a/docs/record-replay/index.html b/docs/record-replay/index.html
index d29ff04..a7089e2 100644
--- a/docs/record-replay/index.html
+++ b/docs/record-replay/index.html
@@ -70,6 +70,72 @@
How It Works
Subsequent identical requests match the newly recorded fixture
+ Proxy-Only Mode
+
+ Use --proxy-only instead of --record when you want unmatched
+ requests to always reach the real provider — no fixture files are written to disk
+ and no responses are cached in memory. Matched fixtures still work normally.
+
+
+ This is ideal for demos and live environments where you have canned
+ fixtures for repeatable demo scenarios you want to show off, but also want regular
+ interactions to work normally by proxying to the real provider. Without
+ --proxy-only, the first real API call would get recorded and cached, and
+ subsequent identical requests would get the stale recorded response instead of hitting the
+ live provider.
+
+
+
+
+
+
+
$ npx aimock --fixtures ./fixtures \
+ --proxy-only \
+ --provider-openai https://api.openai.com
+
+
+
+
+
+
$ docker run -d -p 4010:4010 \
+ -v ./fixtures:/fixtures \
+ ghcr.io/copilotkit/aimock \
+ npx aimock -f /fixtures \
+ --proxy-only \
+ --provider-openai https://api.openai.com
+
+
+
+
+
+
+
+ | Mode |
+ Unmatched request |
+ Writes to disk |
+ Caches in memory |
+
+
+
+
+ --record |
+ Proxy → save → replay next time |
+ Yes |
+ Yes |
+
+
+ --proxy-only |
+ Proxy → relay → proxy again next time |
+ No |
+ No |
+
+
+
+
Quick Start
@@ -107,7 +173,11 @@
CLI Flags
--record |
- Enable record mode (proxy-on-miss) |
+ Enable record mode (proxy, save, and cache on miss) |
+
+
+ --proxy-only |
+ Proxy mode (forward on miss, no saving or caching) |
--strict |
@@ -166,6 +236,7 @@ Programmatic API
anthropic: "https://api.anthropic.com",
},
fixturePath: "./fixtures/recorded",
+ proxyOnly: true, // omit to record fixtures
});
// Make requests — unmatched ones are proxied and recorded
diff --git a/package.json b/package.json
index 0ec1146..53a9066 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@copilotkit/aimock",
- "version": "1.9.0",
+ "version": "1.10.0",
"description": "Mock infrastructure for AI application testing — LLM APIs, MCP tools, A2A agents, vector databases, search, and more. Zero dependencies.",
"license": "MIT",
"repository": {
diff --git a/src/__tests__/proxy-only.test.ts b/src/__tests__/proxy-only.test.ts
new file mode 100644
index 0000000..f0f2cc8
--- /dev/null
+++ b/src/__tests__/proxy-only.test.ts
@@ -0,0 +1,376 @@
+import { describe, it, expect, afterEach } from "vitest";
+import * as http from "node:http";
+import * as fs from "node:fs";
+import * as os from "node:os";
+import * as path from "node:path";
+import type { Fixture } from "../types.js";
+import { createServer, type ServerInstance } from "../server.js";
+
+// ---------------------------------------------------------------------------
+// HTTP helpers
+// ---------------------------------------------------------------------------
+
+function post(
+ url: string,
+ body: unknown,
+ headers?: Record,
+): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
+ return new Promise((resolve, reject) => {
+ const data = JSON.stringify(body);
+ const parsed = new URL(url);
+ const req = http.request(
+ {
+ hostname: parsed.hostname,
+ port: parsed.port,
+ path: parsed.pathname,
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Content-Length": Buffer.byteLength(data),
+ ...headers,
+ },
+ },
+ (res) => {
+ const chunks: Buffer[] = [];
+ res.on("data", (c: Buffer) => chunks.push(c));
+ res.on("end", () => {
+ resolve({
+ status: res.statusCode ?? 0,
+ headers: res.headers,
+ body: Buffer.concat(chunks).toString(),
+ });
+ });
+ },
+ );
+ req.on("error", reject);
+ req.write(data);
+ req.end();
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Test state
+// ---------------------------------------------------------------------------
+
+let upstream: ServerInstance | undefined;
+let recorder: ServerInstance | undefined;
+let tmpDir: string | undefined;
+
+afterEach(async () => {
+ if (recorder) {
+ await new Promise((resolve) => recorder!.server.close(() => resolve()));
+ recorder = undefined;
+ }
+ if (upstream) {
+ await new Promise((resolve) => upstream!.server.close(() => resolve()));
+ upstream = undefined;
+ }
+ if (tmpDir) {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ tmpDir = undefined;
+ }
+});
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Spin up an upstream llmock + a recording proxy pointed at it. */
+async function setupProxyOnly(
+ upstreamFixtures: Fixture[],
+ proxyOnly: boolean,
+): Promise<{ upstreamUrl: string; recorderUrl: string; fixturePath: string }> {
+ upstream = await createServer(upstreamFixtures, { port: 0 });
+
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "llmock-proxy-only-"));
+
+ recorder = await createServer([], {
+ port: 0,
+ record: {
+ providers: { openai: upstream.url },
+ fixturePath: tmpDir,
+ proxyOnly,
+ },
+ });
+
+ return {
+ upstreamUrl: upstream.url,
+ recorderUrl: recorder.url,
+ fixturePath: tmpDir,
+ };
+}
+
+/**
+ * Spin up a counting HTTP server that tracks how many requests it receives
+ * and always returns the same OpenAI-shaped chat completion response.
+ */
+function createCountingUpstream(
+ responseContent: string,
+): Promise<{ server: http.Server; url: string; getCount: () => number }> {
+ return new Promise((resolve) => {
+ let count = 0;
+ const server = http.createServer((_req, res) => {
+ count++;
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(
+ JSON.stringify({
+ id: "chatcmpl-counting",
+ object: "chat.completion",
+ created: Date.now(),
+ model: "gpt-4",
+ choices: [
+ {
+ index: 0,
+ message: { role: "assistant", content: responseContent },
+ finish_reason: "stop",
+ },
+ ],
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
+ }),
+ );
+ });
+ server.listen(0, "127.0.0.1", () => {
+ const { port } = server.address() as { port: number };
+ resolve({
+ server,
+ url: `http://127.0.0.1:${port}`,
+ getCount: () => count,
+ });
+ });
+ });
+}
+
+const CHAT_REQUEST = {
+ model: "gpt-4",
+ messages: [{ role: "user", content: "What is the capital of France?" }],
+};
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+describe("proxy-only mode", () => {
+ it("proxies and returns upstream response", async () => {
+ const { recorderUrl } = await setupProxyOnly(
+ [
+ {
+ match: { userMessage: "capital of France" },
+ response: { content: "Paris is the capital of France." },
+ },
+ ],
+ true,
+ );
+
+ const resp = await post(`${recorderUrl}/v1/chat/completions`, CHAT_REQUEST);
+
+ expect(resp.status).toBe(200);
+ const body = JSON.parse(resp.body);
+ expect(body.choices[0].message.content).toBe("Paris is the capital of France.");
+ });
+
+ it("does NOT write fixture files to disk", async () => {
+ const { recorderUrl, fixturePath } = await setupProxyOnly(
+ [
+ {
+ match: { userMessage: "capital of France" },
+ response: { content: "Paris is the capital of France." },
+ },
+ ],
+ true,
+ );
+
+ await post(`${recorderUrl}/v1/chat/completions`, CHAT_REQUEST);
+
+ // The fixture directory might not even be created, or if it exists it should be empty
+ if (fs.existsSync(fixturePath)) {
+ const files = fs.readdirSync(fixturePath);
+ const fixtureFiles = files.filter((f) => f.endsWith(".json"));
+ expect(fixtureFiles).toHaveLength(0);
+ }
+ // If the directory doesn't exist, that's also correct — no writes happened
+ });
+
+ it("does NOT cache in memory — every request hits upstream", async () => {
+ // Use a counting upstream to verify both requests are proxied
+ const countingUpstream = await createCountingUpstream("counted response");
+
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "llmock-proxy-only-cache-"));
+
+ recorder = await createServer([], {
+ port: 0,
+ record: {
+ providers: { openai: countingUpstream.url },
+ fixturePath: tmpDir,
+ proxyOnly: true,
+ },
+ });
+
+ // First request
+ const resp1 = await post(`${recorder.url}/v1/chat/completions`, CHAT_REQUEST);
+ expect(resp1.status).toBe(200);
+ expect(countingUpstream.getCount()).toBe(1);
+
+ // Second identical request — should ALSO hit upstream (not served from cache)
+ const resp2 = await post(`${recorder.url}/v1/chat/completions`, CHAT_REQUEST);
+ expect(resp2.status).toBe(200);
+ expect(countingUpstream.getCount()).toBe(2);
+
+ // Both responses should have the upstream content
+ const body1 = JSON.parse(resp1.body);
+ const body2 = JSON.parse(resp2.body);
+ expect(body1.choices[0].message.content).toBe("counted response");
+ expect(body2.choices[0].message.content).toBe("counted response");
+
+ // Clean up counting upstream
+ await new Promise((resolve) => countingUpstream.server.close(() => resolve()));
+ });
+
+ it("regular record mode DOES cache in memory — second request served from cache", async () => {
+ // Use a counting upstream to verify only the first request is proxied
+ const countingUpstream = await createCountingUpstream("cached response");
+
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "llmock-record-cache-"));
+
+ recorder = await createServer([], {
+ port: 0,
+ record: {
+ providers: { openai: countingUpstream.url },
+ fixturePath: tmpDir,
+ proxyOnly: false,
+ },
+ });
+
+ // First request — proxied to upstream, recorded
+ const resp1 = await post(`${recorder.url}/v1/chat/completions`, CHAT_REQUEST);
+ expect(resp1.status).toBe(200);
+ expect(countingUpstream.getCount()).toBe(1);
+
+ // Second identical request — should be served from in-memory cache, NOT hitting upstream
+ const resp2 = await post(`${recorder.url}/v1/chat/completions`, CHAT_REQUEST);
+ expect(resp2.status).toBe(200);
+ expect(countingUpstream.getCount()).toBe(1); // still 1 — no second proxy
+
+ // Both responses should have the same content
+ const body1 = JSON.parse(resp1.body);
+ const body2 = JSON.parse(resp2.body);
+ expect(body1.choices[0].message.content).toBe("cached response");
+ expect(body2.choices[0].message.content).toBe("cached response");
+
+ // Clean up counting upstream
+ await new Promise((resolve) => countingUpstream.server.close(() => resolve()));
+ });
+
+ it("regular record mode DOES write fixture files to disk", async () => {
+ const { recorderUrl, fixturePath } = await setupProxyOnly(
+ [
+ {
+ match: { userMessage: "capital of France" },
+ response: { content: "Paris is the capital of France." },
+ },
+ ],
+ false, // proxyOnly = false → normal record mode
+ );
+
+ await post(`${recorderUrl}/v1/chat/completions`, CHAT_REQUEST);
+
+ const files = fs.readdirSync(fixturePath);
+ const fixtureFiles = files.filter((f) => f.startsWith("openai-") && f.endsWith(".json"));
+ expect(fixtureFiles).toHaveLength(1);
+ });
+
+ it("matched fixtures still work in proxy-only mode", async () => {
+ // Set up an upstream, but pre-register a fixture on the recorder itself
+ upstream = await createServer(
+ [
+ {
+ match: { userMessage: "capital of France" },
+ response: { content: "Upstream says Paris." },
+ },
+ ],
+ { port: 0 },
+ );
+
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "llmock-proxy-only-matched-"));
+
+ // Recorder has its own fixture that should match BEFORE proxying
+ const localFixture: Fixture = {
+ match: { userMessage: "capital of France" },
+ response: { content: "Local fixture says Paris." },
+ };
+
+ recorder = await createServer([localFixture], {
+ port: 0,
+ record: {
+ providers: { openai: upstream.url },
+ fixturePath: tmpDir,
+ proxyOnly: true,
+ },
+ });
+
+ const resp = await post(`${recorder.url}/v1/chat/completions`, CHAT_REQUEST);
+
+ expect(resp.status).toBe(200);
+ const body = JSON.parse(resp.body);
+ // Should get the LOCAL fixture response, not the upstream one
+ expect(body.choices[0].message.content).toBe("Local fixture says Paris.");
+
+ // No files written to disk (the fixture matched locally, no proxy needed)
+ if (fs.existsSync(tmpDir)) {
+ const files = fs.readdirSync(tmpDir);
+ const fixtureFiles = files.filter((f) => f.endsWith(".json"));
+ expect(fixtureFiles).toHaveLength(0);
+ }
+ });
+
+ it("returns 503 in strict mode when provider is not configured", async () => {
+ // Set up recorder with proxy-only mode but no anthropic provider configured
+ upstream = await createServer([], { port: 0 });
+
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "llmock-proxy-only-noprovider-"));
+
+ recorder = await createServer([], {
+ port: 0,
+ strict: true,
+ record: {
+ providers: { openai: upstream.url }, // only openai configured
+ fixturePath: tmpDir,
+ proxyOnly: true,
+ },
+ });
+
+ // Send to Anthropic endpoint — no provider configured for anthropic
+ const resp = await post(`${recorder.url}/v1/messages`, {
+ model: "claude-3-opus-20240229",
+ max_tokens: 100,
+ messages: [{ role: "user", content: "Hello" }],
+ });
+
+ // Should get 503 (strict mode) since no fixture matches and no anthropic upstream
+ expect(resp.status).toBe(503);
+ });
+
+ it("returns 404 in non-strict mode when provider is not configured", async () => {
+ upstream = await createServer([], { port: 0 });
+
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "llmock-proxy-only-404-"));
+
+ recorder = await createServer([], {
+ port: 0,
+ record: {
+ providers: { openai: upstream.url }, // only openai configured
+ fixturePath: tmpDir,
+ proxyOnly: true,
+ },
+ });
+
+ // Send to Anthropic endpoint — no provider configured for anthropic
+ const resp = await post(`${recorder.url}/v1/messages`, {
+ model: "claude-3-opus-20240229",
+ max_tokens: 100,
+ messages: [{ role: "user", content: "Hello" }],
+ });
+
+ // Should get 404 (non-strict default) since no fixture matches and no anthropic upstream
+ expect(resp.status).toBe(404);
+ });
+});
diff --git a/src/cli.ts b/src/cli.ts
index 42262bf..0fc7d27 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -21,7 +21,8 @@ Options:
--log-level Log verbosity: silent, info, debug (default: info)
--validate-on-load Validate fixture schemas at startup
--metrics Enable Prometheus metrics at GET /metrics
- --record Record mode: proxy unmatched requests to real APIs
+ --record Record mode: proxy unmatched requests and save fixtures
+ --proxy-only Proxy mode: forward unmatched requests without saving
--strict Strict mode: fail on unmatched requests
--provider-openai Upstream URL for OpenAI (used with --record)
--provider-anthropic Upstream URL for Anthropic
@@ -49,6 +50,7 @@ const { values } = parseArgs({
"validate-on-load": { type: "boolean", default: false },
metrics: { type: "boolean", default: false },
record: { type: "boolean", default: false },
+ "proxy-only": { type: "boolean", default: false },
strict: { type: "boolean", default: false },
"provider-openai": { type: "string" },
"provider-anthropic": { type: "string" },
@@ -139,9 +141,9 @@ let chaos: ChaosConfig | undefined;
}
}
-// Parse record config from CLI flags
+// Parse record/proxy config from CLI flags
let record: RecordConfig | undefined;
-if (values.record) {
+if (values.record || values["proxy-only"]) {
const providers: RecordConfig["providers"] = {};
if (values["provider-openai"]) providers.openai = values["provider-openai"];
if (values["provider-anthropic"]) providers.anthropic = values["provider-anthropic"];
@@ -153,11 +155,17 @@ if (values.record) {
if (values["provider-cohere"]) providers.cohere = values["provider-cohere"];
if (Object.keys(providers).length === 0) {
- console.error("Error: --record requires at least one --provider-* flag");
+ console.error(
+ `Error: --${values["proxy-only"] ? "proxy-only" : "record"} requires at least one --provider-* flag`,
+ );
process.exit(1);
}
- record = { providers, fixturePath: resolve(fixturePath, "recorded") };
+ record = {
+ providers,
+ fixturePath: resolve(fixturePath, "recorded"),
+ proxyOnly: values["proxy-only"],
+ };
}
async function main() {
diff --git a/src/recorder.ts b/src/recorder.ts
index 20f5a9b..d5348a1 100644
--- a/src/recorder.ts
+++ b/src/recorder.ts
@@ -190,45 +190,50 @@ export async function proxyAndRecord(
);
}
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
- const filename = `${providerKey}-${timestamp}-${crypto.randomUUID().slice(0, 8)}.json`;
- const filepath = path.join(fixturePath, filename);
+ // In proxy-only mode, skip recording to disk and in-memory caching
+ if (!defaults.record?.proxyOnly) {
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
+ const filename = `${providerKey}-${timestamp}-${crypto.randomUUID().slice(0, 8)}.json`;
+ const filepath = path.join(fixturePath, filename);
- let writtenToDisk = false;
- try {
- // Ensure fixture directory exists
- fs.mkdirSync(fixturePath, { recursive: true });
+ let writtenToDisk = false;
+ try {
+ // Ensure fixture directory exists
+ fs.mkdirSync(fixturePath, { recursive: true });
- // Collect warnings for the fixture file
- const warnings: string[] = [];
- if (isEmptyMatch) {
- warnings.push("Empty match criteria — this fixture will not match any request");
- }
- if (collapsed?.truncated) {
- warnings.push("Stream response was truncated — fixture may be incomplete");
- }
+ // Collect warnings for the fixture file
+ const warnings: string[] = [];
+ if (isEmptyMatch) {
+ warnings.push("Empty match criteria — this fixture will not match any request");
+ }
+ if (collapsed?.truncated) {
+ warnings.push("Stream response was truncated — fixture may be incomplete");
+ }
- // Auth headers are forwarded to upstream but excluded from saved fixtures for security
- const fileContent: Record = { fixtures: [fixture] };
- if (warnings.length > 0) {
- fileContent._warning = warnings.join("; ");
+ // Auth headers are forwarded to upstream but excluded from saved fixtures for security
+ const fileContent: Record = { fixtures: [fixture] };
+ if (warnings.length > 0) {
+ fileContent._warning = warnings.join("; ");
+ }
+ fs.writeFileSync(filepath, JSON.stringify(fileContent, null, 2), "utf-8");
+ writtenToDisk = true;
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : "Unknown filesystem error";
+ defaults.logger.error(`Failed to save fixture to disk: ${msg}`);
+ res.setHeader("X-LLMock-Record-Error", msg);
}
- fs.writeFileSync(filepath, JSON.stringify(fileContent, null, 2), "utf-8");
- writtenToDisk = true;
- } catch (err) {
- const msg = err instanceof Error ? err.message : "Unknown filesystem error";
- defaults.logger.error(`Failed to save fixture to disk: ${msg}`);
- res.setHeader("X-LLMock-Record-Error", msg);
- }
- if (writtenToDisk) {
- // Register in memory so subsequent identical requests match (skip if empty match)
- if (!isEmptyMatch) {
- fixtures.push(fixture);
+ if (writtenToDisk) {
+ // Register in memory so subsequent identical requests match (skip if empty match)
+ if (!isEmptyMatch) {
+ fixtures.push(fixture);
+ }
+ defaults.logger.warn(`Response recorded → ${filepath}`);
+ } else {
+ defaults.logger.warn(`Response relayed but NOT saved to disk — see error above`);
}
- defaults.logger.warn(`Response recorded → ${filepath}`);
} else {
- defaults.logger.warn(`Response relayed but NOT saved to disk — see error above`);
+ defaults.logger.info(`Proxied ${providerKey} request (proxy-only mode)`);
}
// Relay upstream response to client
diff --git a/src/types.ts b/src/types.ts
index b53b46e..ea64d8a 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -265,6 +265,8 @@ export type RecordProviderKey =
export interface RecordConfig {
providers: Partial>;
fixturePath?: string;
+ /** Proxy unmatched requests without saving fixtures or caching in memory. */
+ proxyOnly?: boolean;
}
export interface MockServerOptions {