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. +

    + +
    +
    +
    +
    + Proxy-only mode shell +
    +
    $ npx aimock --fixtures ./fixtures \
    +  --proxy-only \
    +  --provider-openai https://api.openai.com
    +
    +
    +
    +
    +
    + Proxy-only mode shell +
    +
    $ 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
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    ModeUnmatched requestWrites to diskCaches in memory
    --recordProxy → save → replay next timeYesYes
    --proxy-onlyProxy → relay → proxy again next timeNoNo
    +

    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 {