Mock HTTP server for LLM provider APIs (OpenAI, Anthropic, Gemini, Responses API). Use when writing integration tests that need deterministic, controllable LLM API responses without calling real providers. Supports fixture-based request matching, SSE streaming, failure injection, auth simulation, and all four provider response formats.
Resources
16Install
npx skillscat add skilldoai/llmposter Install via the SkillsCat registry.
llmposter
Mock HTTP server for LLM provider APIs. Clients point their base URL at llmposter and interact using real API paths — no code changes beyond the URL swap. Fixtures define request matchers and canned responses for Anthropic (/v1/messages), OpenAI (/v1/chat/completions), Gemini (/v1beta/models/{model}:generateContent non-streaming, /v1beta/models/{model}:streamGenerateContent streaming), and Responses API (/v1/responses). No provider prefix in routes — clients use the same paths as real APIs.
Imports
// Core types (re-exported at crate root)
use llmposter::{Fixture, Provider, ServerBuilder};
// Fixture sub-types (in llmposter::fixture module)
use llmposter::fixture::{FailureConfig, FixtureResponse, StreamingConfig, ToolCall};[dependencies]
llmposter = "0.4.8"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.13", default-features = false, features = ["json"] }
serde_json = "1"Optional feature flags:
# Default features: ["oauth", "watch", "jsonpath"] — opt-in: ui, templating
llmposter = { version = "0.4", features = ["ui", "templating"] }
# Minimal (no oauth, no watch, no jsonpath)
llmposter = { version = "0.4", default-features = false }Quick Start
use llmposter::{Fixture, ServerBuilder};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let server = ServerBuilder::new()
.fixture(
Fixture::new()
.match_user_message("hello")
.respond_with_content("Hi from Claude mock!"),
)
.build()
.await?;
// Point any LLM client's base_url at server.url()
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/v1/messages", server.url()))
.json(&serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "hello world"}]
}))
.send()
.await?;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await?;
assert_eq!(body["type"], "message");
assert_eq!(body["content"][0]["text"], "Hi from Claude mock!");
assert_eq!(body["stop_reason"], "end_turn");
Ok(())
}API Reference
ServerBuilder
Builder for MockServer. Re-exported at crate root.
ServerBuilder::new() -> Self— create an empty builder.fixture(f: Fixture) -> Self— add a single fixture (chainable).fixtures(fixtures: Vec<Fixture>) -> Self— append a vector of fixtures.load_yaml(path: &Path) -> Result<Self, Box<dyn Error>>— load fixtures from a YAML file.load_yaml_dir(dir: &Path) -> Result<Self, Box<dyn Error>>— load all YAML files in a directory.fixture_count(&self) -> usize— number of fixtures currently loaded.models(models: Vec<String>) -> Self— set explicit model list returned byGET /v1/models. When unset, models are auto-derived from fixtures..bind(addr: &str) -> Self— bind address. Default: random port on127.0.0.1..verbose(v: bool) -> Self— whentrue, 404 responses include the"No fixture matched"diagnostic detail..diagnostics(enabled: bool) -> Self— whentrue, 404 responses include the nearest-match fixture and per-field pass/fail breakdown..capture_capacity(max: usize) -> Self— max captured requests in ring buffer. Library default: unbounded.0disables capture entirely..with_auth(enabled: bool) -> Self— toggle bearer-token auth enforcement. Optional —with_bearer_token/with_bearer_token_usesalready enable enforcement on their own..with_bearer_token(token: &str) -> Self— registers a bearer token AND enables auth enforcement (calling.with_auth(true)alongside is redundant).with_bearer_token_uses(token: &str, max_uses: u64) -> Self— registers a bearer token capped atmax_usesrequests AND enables auth enforcement (calling.with_auth(true)alongside is redundant).with_oauth(config: OAuthConfig) -> Self— enable embedded OAuth mock with a custom config (oauthfeature, on by default).with_oauth_defaults(self) -> Self— enable embedded OAuth mock with defaults (oauthfeature, on by default).watch(enabled: bool) -> Self— enable hot-reload of fixture files (watchfeature).ui(enabled: bool) -> Self— enable debug UI at/ui(uifeature).build(self) -> Result<MockServer, Box<dyn Error>>— async. Validates fixtures and starts the server on the configured bind (random port by default).
MockServer
Running server handle. Re-exported at crate root.
.url(&self) -> String— base URL (e.g.,http://127.0.0.1:PORT).port(&self) -> u16— bound port number.check_error(&self) -> Result<(), String>— async. Returns the latest background error (e.g., fixture reload failure) orOk(())if none..get_requests(&self) -> Vec<CapturedRequest>— all captured requests in chronological order.request_count(&self) -> usize— total captured request count.fixture_count(&self) -> usize— currently loaded fixture count.explicit_models(&self) -> Option<&[String]>— explicit model list if set viaServerBuilder::models(), elseNone.matched_requests(&self) -> Vec<CapturedRequest>— captured requests whose outcome isMatched.matched_count(&self) -> usize— number of matched requests.assert_matched(&self, substring: &str)— panics unless a matched request body containssubstring.assert_not_matched(&self, substring: &str)— panics if any matched request body containssubstring.scenario_state(&self, name: &str) -> Option<String>— current state for the named scenario.set_fixtures(&self, fixtures: Vec<Fixture>) -> Result<(), Box<dyn Error + Send + Sync>>— atomically swap the active fixture set.reset(&self)— clear captured requests and reset scenario state.oauth_url(&self) -> Option<String>— OAuth mock base URL when enabled (oauthfeature).oauth_client_credentials(&self) -> Option<(String, String)>— async.(client_id, client_secret)when enabled (oauthfeature).approve_device_code(&self, user_code: &str) -> Result<(), Box<dyn Error>>— async. Approves a pending OAuth device-code grant (oauthfeature)
Fixture
Central type for defining mock behavior. Re-exported at crate root. All builder methods return Self (chainable). The struct uses #[serde(deny_unknown_fields)].
Constructor & priority:
Fixture::new() -> Self.with_priority(priority: i32) -> Self— higher matches first. Default is0..as_catch_all(self) -> Self— marks the fixture as a fallback; catch-all fixtures are checked only after all non-catch-all fixtures regardless of priority.
Match methods:
.match_user_message(pattern: &str)— substring match on the last user message.match_model(pattern: &str)— substring match on the model field.match_header(name: &str, value: &str)— substring match on a request header.match_system_prompt(pattern: &str)— substring match on the system prompt.match_temperature(value: f64)— exact temperature match.match_temperature_range(min: Option<f64>, max: Option<f64>)— inclusive range match on temperature.match_metadata(key: &str, value: &str)— substring match on a metadata field.match_tool_schema(pattern: &str)— substring match against tool/function schema JSON.match_body_jsonpath(path: &str)— request body matches JSONPath expression (jsonpathfeature, on by default).for_provider(provider: Provider)— restrict to one provider endpoint
Response methods:
.respond_with_content(content: &str)— text response.respond_with_tool_calls(tool_calls: Vec<ToolCall>)— tool-use response (mutually exclusive with text content).respond_with_embedding(embedding: Vec<f64>)— explicit embedding vector for/v1/embeddings.respond_with_refusal(reason: &str)— OpenAI-style refusal.with_stop_reason(reason: &str)— override Anthropic stop reason.with_finish_reason(reason: &str)— override OpenAI finish reason.with_error(status: u16, message: &str)— HTTP error response.with_error_headers<I, K, V>(status, message, headers) -> Result<Self, String>— error response with custom headers. Rejects duplicate (case-insensitive) header names at construction time..with_streaming(latency: Option<u64>, chunk_size: Option<usize>)— enable SSE streaming with optional inter-chunk latency (ms) and chunk size (chars). Builder parameter name islatency; theStreamingConfigstruct field is alsolatency..with_failure(failure: FailureConfig)— inject failure behaviors.with_scenario(name: &str, required_state: Option<&str>, set_state: Option<&str>) -> Self— make this fixture match only when the named scenario is inrequired_state; on match, advance toset_state..validate(&mut self) -> Result<(), String>— verify field combinations; called automatically byServerBuilder::build().
Public struct fields (for direct construction):
Fixture.match_rule: Option<FixtureMatch>— match criteriaFixture.provider: Option<Provider>— provider restrictionFixture.response: Option<FixtureResponse>— response configurationFixture.error: Option<FixtureError>— error responseFixture.refusal: Option<Refusal>— refusal payloadFixture.failure: Option<FailureConfig>— failure injectionFixture.streaming: Option<StreamingConfig>— SSE streaming configFixture.scenario: Option<ScenarioConfig>— stateful multi-turn scenario matchingFixture.priority: Option<i32>— whenNone, treated as0. Higher values match first.Fixture.catch_all: bool— whentrue, fixture is checked only after all non-catch-all fixtures.
FixtureResponse
In llmposter::fixture. Derives Default. For custom response construction.
FixtureResponse.content: Option<String>— text body. WhenNone, no text content block is returned.FixtureResponse.content_template: Option<String>— minijinja template (templatingfeature). WhenNone,contentis used as-is.FixtureResponse.tool_calls: Option<Vec<ToolCall>>— tool-use responses. Mutually exclusive withcontent.FixtureResponse.stop_reason: Option<String>— Anthropic stop reason. WhenNone, defaults to"end_turn"for text,"tool_use"for tool calls.FixtureResponse.finish_reason: Option<String>— OpenAI finish reason. WhenNone, defaults to"stop"for text.FixtureResponse.embedding: Option<Vec<f64>>— explicit embedding vector for/v1/embeddings. WhenNone, a deterministic FNV-1a-seeded 1536-dim L2-normalized vector is generated.
ToolCall
In llmposter::fixture. Re-exported at crate root.
ToolCall.name: String— tool function name (required)ToolCall.arguments: serde_json::Value— tool input as a parsed JSON Value (required). NOT a stringified JSON string. Useserde_json::json!({...}).
FailureConfig
In llmposter::fixture. Re-exported at crate root. Derives Default.
FailureConfig.latency_ms: Option<u64>— delay (ms) before response. WhenNone, no delay.FailureConfig.corrupt_body: Option<bool>— whenSome(true), returns literal string"overloaded"astext/plainwith HTTP 200. Configured content is ignored entirely.FailureConfig.truncate_after_frames: Option<u32>— cut SSE stream after N frames. Stream ends withoutmessage_stop. Ignored (with warning) on non-streaming.FailureConfig.disconnect_after_ms: Option<u64>— abort connection after N ms. Requires streaming withlatency > 0; withlatency = 0, frames may complete before the disconnect timer fires.FailureConfig.probability: Option<f32>— probability ([0.0, 1.0]) that failure applies. WhenNone, failure always applies.FailureConfig.latency_jitter_ms: Option<u64>— random jitter added to latency. Requireslatency_msto beSome; rejected at fixture load time without it.FailureConfig.duplicate_frames: Option<bool>— whenSome(true), duplicate SSE frames.FailureConfig.chaos_seed: Option<u64>— seed for deterministic chaos reproduction. Set this alongsideprobability,latency_jitter_ms, orduplicate_framesfor reproducible runs.
StreamingConfig
Re-exported at crate root.
StreamingConfig.latency: Option<u64>— inter-chunk delay (ms). Note: the struct field islatencyand the builder parameter is alsolatency.StreamingConfig.chunk_size: Option<usize>— characters per chunk for text content. Ignored for tool-call streaming across all four providers.
ScenarioConfig
Re-exported at crate root.
ScenarioConfig.name: String— scenario identifier (required)ScenarioConfig.required_state: Option<String>— fixture matches only when the scenario is in this state. WhenNone, matches in any state.ScenarioConfig.set_state: Option<String>— on match, advance the scenario to this state.
Refusal
Re-exported at crate root.
Refusal.reason: String— refusal text injected into the response
Provider
Enum (exactly 4 variants). Re-exported at crate root. Serde uses rename_all = "lowercase", so YAML uses openai, anthropic, gemini, responses.
Provider::OpenAI— servesPOST /v1/chat/completionsProvider::Anthropic— servesPOST /v1/messagesProvider::Gemini— servesPOST /v1beta/models/{model}:generateContent(non-streaming) andPOST /v1beta/models/{model}:streamGenerateContent(streaming)Provider::Responses— servesPOST /v1/responses(OpenAI Responses API).as_str(&self) -> &'static str— lowercase string representation
The OpenAI Responses API variant is named Provider::Responses, not OpenAIResponses.
AuthState and TokenStatus
Re-exported at crate root. Auth only protects LLM routes — /health, /code/{N}, /v1/models, /v1/embeddings, /v1/moderations, and /ui are never auth-protected.
AuthState::new() -> Self.add_token(&self, token: &str, max_uses: Option<u64>)— register a bearer token.Nonemeans unlimited uses..check_and_use(&self, token: &str) -> TokenStatus— atomically check and decrement use count.revoke(&self, token: &str)— move a token to the deny-list
TokenStatus enum (3 variants):
TokenStatus::Valid— token is registered and has remaining usesTokenStatus::Exhausted— token is deny-listed (uses ran out ORrevoke()was called)TokenStatus::Unknown— token was never registered
OAuthConfig
Requires oauth feature (on by default). Re-exported at crate root.
OAuthConfig.client_id: StringOAuthConfig.client_secret: StringOAuthConfig.redirect_uris: Vec<String>OAuthConfig.scopes: Vec<String>
CapturedRequest and RequestOutcome
Both re-exported at crate root. CapturedRequest is #[non_exhaustive].
CapturedRequest fields:
method: Stringpath: Stringbody: Stringoutcome: RequestOutcomematched_scenario: Option<String>— scenario name if matchedcapture_id: u64— monotonic capture indexstatus_code: u16— HTTP status returned (pre-chaos; see Pitfalls)timestamp: Instant.was_matched(&self) -> bool— true whenoutcome == Matched
RequestOutcome enum (#[non_exhaustive]):
RequestOutcome::Matched— fixture matched and returned a responseRequestOutcome::NoFixtureMatch— 404 pathRequestOutcome::BadRequest— 400 validation failureRequestOutcome::AuthRejected— 401 from auth middlewareRequestOutcome::CodeEndpoint—/code/{N}requestRequestOutcome::ModerationEndpoint—/v1/moderationsrequest.label(&self) -> &'static strand.default_status(&self) -> u16
Matching Types
In llmposter::fixture (NOT re-exported at crate root):
FixtureMatch— match criteria struct:user_message,model,headers: HashMap<String, StringMatch>,system_prompt,temperature: Option<F64Match>,metadata: HashMap<String, StringMatch>,tool_schema,body_jsonpath: Option<String>FixtureError—{ status: u16, message: String, headers: HashMap<String, String> }StringMatch::Substring(String)— default variant, substring/contains matchingStringMatch::Regex(RegexMatch)— regex matching; construct viaStringMatch::regex(pattern: &str). Use anchors^...$for exact matching (noExactvariant exists).RegexMatch { regex: String }F64Match::Exact(f64)|F64Match::Range(F64Range)F64Range { min: Option<f64>, max: Option<f64> }— inclusive bounds
CLI Module
In llmposter::cli. Useful when embedding the binary in test harnesses.
Cli— clap-derived argument struct with fieldsfixtures,validate,port,bind,verbose,capture_capacity,diagnostics, and feature-gatedwatch,ui.cli::run(cli: &Cli) -> Result<Option<MockServer>, Box<dyn Error>>— async. Runs the CLI flow, returningNonefor--validateandSome(MockServer)otherwise.cli::run_with_output(cli: &Cli, out: &mut (dyn Write + Send))— async. Variant that writes startup messages to a custom sink.
Core Patterns
Tool Use Response
mod tool_use_example {
use llmposter::fixture::ToolCall;
use llmposter::{Fixture, ServerBuilder};
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let server = ServerBuilder::new()
.fixture(
Fixture::new()
.match_user_message("weather")
.respond_with_tool_calls(vec![ToolCall {
name: "get_weather".to_string(),
arguments: serde_json::json!({
"location": "London",
"unit": "celsius"
}),
}]),
)
.build()
.await?;
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/v1/messages", server.url()))
.json(&serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "What's the weather?"}]
}))
.send()
.await?;
let body: serde_json::Value = resp.json().await?;
assert_eq!(body["stop_reason"], "tool_use");
assert_eq!(body["content"][0]["type"], "tool_use");
assert_eq!(body["content"][0]["name"], "get_weather");
// Tool-call IDs are deterministic: toolu_llmposter_{N} (1-indexed
// process-wide counter). Use starts_with — the exact N depends on
// how many other requests the process has served.
let tool_id = body["content"][0]["id"].as_str().unwrap();
assert!(tool_id.starts_with("toolu_llmposter_"));
// Anthropic uses "input" field for tool-call arguments
assert_eq!(body["content"][0]["input"]["location"], "London");
Ok(())
}
}SSE Streaming
mod streaming_example {
use llmposter::{Fixture, ServerBuilder};
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let server = ServerBuilder::new()
.fixture(
Fixture::new()
.match_user_message("hello")
.respond_with_content("Hello world")
.with_streaming(Some(0), Some(5)), // latency=0ms, chunk_size=5 chars
)
.build()
.await?;
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/v1/messages", server.url()))
.json(&serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "hello"}],
"stream": true
}))
.send()
.await?;
assert_eq!(resp.status(), 200);
let text = resp.text().await?;
// SSE events: message_start, content_block_start, content_block_delta,
// content_block_stop, message_delta, message_stop
assert!(text.contains("event: message_start"));
assert!(text.contains("event: content_block_delta"));
assert!(text.contains("event: message_stop"));
Ok(())
}
}Error Simulation
mod error_simulation {
use llmposter::{Fixture, ServerBuilder};
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let server = ServerBuilder::new()
.fixture(
Fixture::new()
.match_user_message("rate limit")
.with_error(429, "Rate limit exceeded"),
)
.build()
.await?;
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/v1/messages", server.url()))
.json(&serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "rate limit test"}]
}))
.send()
.await?;
assert_eq!(resp.status(), 429);
// Error shape is {"error": {"message": "..."}} for ALL providers
let body: serde_json::Value = resp.json().await?;
assert_eq!(body["error"]["message"], "Rate limit exceeded");
Ok(())
}
}Failure Injection (Latency)
mod latency_injection {
use llmposter::fixture::FailureConfig;
use llmposter::{Fixture, ServerBuilder};
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let server = ServerBuilder::new()
.fixture(
Fixture::new()
.respond_with_content("delayed response")
.with_failure(FailureConfig {
latency_ms: Some(200),
..Default::default()
}),
)
.build()
.await?;
let start = std::time::Instant::now();
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/v1/messages", server.url()))
.json(&serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "test"}]
}))
.send()
.await?;
assert_eq!(resp.status(), 200);
assert!(start.elapsed().as_millis() >= 180);
let body: serde_json::Value = resp.json().await?;
assert_eq!(body["content"][0]["text"], "delayed response");
Ok(())
}
}Corrupt Body (Overloaded Simulation)
mod corrupt_body_example {
use llmposter::fixture::FailureConfig;
use llmposter::{Fixture, ServerBuilder};
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let server = ServerBuilder::new()
.fixture(
Fixture::new()
.respond_with_content("should not appear")
.with_failure(FailureConfig {
corrupt_body: Some(true),
..Default::default()
}),
)
.build()
.await?;
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/v1/messages", server.url()))
.json(&serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "test"}]
}))
.send()
.await?;
// Returns HTTP 200 with text/plain body "overloaded" — NOT JSON
assert_eq!(resp.status(), 200);
let text = resp.text().await?;
assert_eq!(text, "overloaded");
Ok(())
}
}Provider Filtering
mod provider_filtering {
use llmposter::{Fixture, Provider, ServerBuilder};
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let server = ServerBuilder::new()
.fixture(
Fixture::new()
.for_provider(Provider::OpenAI)
.respond_with_content("openai only"),
)
.fixture(
Fixture::new()
.for_provider(Provider::Anthropic)
.respond_with_content("anthropic only"),
)
.build()
.await?;
let client = reqwest::Client::new();
// OpenAI fixture matches on /v1/chat/completions
let resp = client
.post(format!("{}/v1/chat/completions", server.url()))
.json(&serde_json::json!({
"model": "gpt-4",
"messages": [{"role": "user", "content": "test"}]
}))
.send()
.await?;
assert_eq!(resp.status(), 200);
// OpenAI fixture does NOT match /v1/messages — Anthropic fixture does
let resp = client
.post(format!("{}/v1/messages", server.url()))
.json(&serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "test"}]
}))
.send()
.await?;
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await?;
assert_eq!(body["content"][0]["text"], "anthropic only");
Ok(())
}
}Custom Stop Reason
mod custom_stop_reason {
use llmposter::{Fixture, ServerBuilder};
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let server = ServerBuilder::new()
.fixture(
Fixture::new()
.respond_with_content("hit max tokens")
.with_stop_reason("max_tokens"),
)
.build()
.await?;
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/v1/messages", server.url()))
.json(&serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "test"}]
}))
.send()
.await?;
let body: serde_json::Value = resp.json().await?;
assert_eq!(body["stop_reason"], "max_tokens");
assert_eq!(body["content"][0]["text"], "hit max tokens");
Ok(())
}
}Stream Truncation
mod stream_truncation {
use llmposter::fixture::FailureConfig;
use llmposter::{Fixture, ServerBuilder};
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let server = ServerBuilder::new()
.fixture(
Fixture::new()
.respond_with_content("long text that gets truncated")
.with_streaming(Some(0), Some(5))
.with_failure(FailureConfig {
truncate_after_frames: Some(2),
..Default::default()
}),
)
.build()
.await?;
let client = reqwest::Client::new();
let resp = client
.post(format!("{}/v1/messages", server.url()))
.json(&serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "test"}],
"stream": true
}))
.send()
.await?;
let text = resp.text().await?;
assert!(text.contains("event: message_start"));
// Stream ends abruptly — no message_stop event emitted
assert!(!text.contains("event: message_stop"));
Ok(())
}
}Bearer Token Authentication
mod bearer_auth {
use llmposter::{Fixture, ServerBuilder};
async fn run() -> Result<(), Box<dyn std::error::Error>> {
// with_bearer_token_uses implicitly enables auth — no separate
// .with_auth(true) call needed.
let server = ServerBuilder::new()
.with_bearer_token_uses("sk-test-token", 3)
.fixture(Fixture::new().respond_with_content("authorized"))
.build()
.await?;
let client = reqwest::Client::new();
// Without bearer token: 401
let resp = client
.post(format!("{}/v1/messages", server.url()))
.json(&serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "test"}]
}))
.send()
.await?;
assert_eq!(resp.status(), 401);
// With token: 200
let resp = client
.post(format!("{}/v1/messages", server.url()))
.bearer_auth("sk-test-token")
.json(&serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "test"}]
}))
.send()
.await?;
assert_eq!(resp.status(), 200);
// /health is NEVER auth-protected
let health = client.get(format!("{}/health", server.url())).send().await?;
assert_eq!(health.status(), 200);
Ok(())
}
}Stateful Scenarios (Retry Behavior)
mod scenario_retry {
use llmposter::{Fixture, ServerBuilder};
async fn run() -> Result<(), Box<dyn std::error::Error>> {
// First call: 429. After first failure, scenario advances to "retried";
// second call matches the success fixture. The success fixture has
// higher priority so it wins over the (still-matching) failure fixture
// once the scenario state advances.
let server = ServerBuilder::new()
.fixture(
Fixture::new()
.with_scenario("retry-flow", None, Some("retried"))
.with_error(429, "Rate limit; please retry"),
)
.fixture(
Fixture::new()
.with_scenario("retry-flow", Some("retried"), None)
.respond_with_content("success after retry")
.with_priority(10),
)
.build()
.await?;
let client = reqwest::Client::new();
let req = || {
client
.post(format!("{}/v1/messages", server.url()))
.json(&serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "ping"}]
}))
.send()
};
let first = req().await?;
assert_eq!(first.status(), 429);
let second = req().await?;
assert_eq!(second.status(), 200);
let body: serde_json::Value = second.json().await?;
assert_eq!(body["content"][0]["text"], "success after retry");
Ok(())
}
}YAML Fixtures
mod yaml_fixtures {
use llmposter::ServerBuilder;
use std::path::Path;
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let server = ServerBuilder::new()
.load_yaml(Path::new("fixtures/anthropic.yaml"))?
.build()
.await?;
println!("Mock server at {} with fixtures loaded", server.url());
Ok(())
}
}YAML fixture format:
# fixtures/anthropic.yaml
fixtures:
- match:
user_message: "hello"
response:
content: "Hi from the mock!"
- match:
model: "claude-sonnet"
user_message: "weather"
response:
tool_calls:
- name: get_weather
arguments:
location: London
unit: celsius
- match:
user_message: "fail"
error:
status: 429
message: "Rate limit exceeded"
- match:
user_message: "slow"
response:
content: "delayed"
streaming:
latency: 50
chunk_size: 5
failure:
latency_ms: 500
- priority: 10
catch_all: true
response:
content: "fallback response"The top-level fixtures: key is required — a bare list will fail to load.
Request Capture and Assertion Helpers
mod request_capture {
use llmposter::{Fixture, ServerBuilder};
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let server = ServerBuilder::new()
.fixture(Fixture::new().respond_with_content("captured"))
.capture_capacity(100)
.build()
.await?;
let client = reqwest::Client::new();
client
.post(format!("{}/v1/messages", server.url()))
.json(&serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "ping the mock"}]
}))
.send()
.await?;
assert_eq!(server.request_count(), 1);
assert_eq!(server.matched_count(), 1);
server.assert_matched("ping the mock");
server.assert_not_matched("does-not-appear");
let captured = server.get_requests();
assert_eq!(captured[0].method, "POST");
assert_eq!(captured[0].path, "/v1/messages");
assert!(captured[0].was_matched());
Ok(())
}
}Model Matching
mod model_matching {
use llmposter::{Fixture, ServerBuilder};
async fn run() -> Result<(), Box<dyn std::error::Error>> {
let server = ServerBuilder::new()
.fixture(
Fixture::new()
.match_model("claude-sonnet")
.respond_with_content("sonnet response"),
)
.build()
.await?;
let client = reqwest::Client::new();
// "claude-sonnet" is a substring of "claude-sonnet-4-6" — matches
let resp = client
.post(format!("{}/v1/messages", server.url()))
.json(&serde_json::json!({
"model": "claude-sonnet-4-6",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "test"}]
}))
.send()
.await?;
assert_eq!(resp.status(), 200);
// "claude-sonnet" is NOT a substring of "claude-haiku-3" — 404
let resp = client
.post(format!("{}/v1/messages", server.url()))
.json(&serde_json::json!({
"model": "claude-haiku-3",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "test"}]
}))
.send()
.await?;
assert_eq!(resp.status(), 404);
Ok(())
}
}Endpoint Reference
No provider prefix in routes — clients use real API paths, just swap the base URL.
| Provider | Endpoint | Notes |
|---|---|---|
| Anthropic | POST /v1/messages |
Requires max_tokens in request body |
| OpenAI | POST /v1/chat/completions |
|
| OpenAI legacy | POST /v1/completions |
|
| Gemini (non-streaming) | POST /v1beta/models/{model}:generateContent |
Model name in URL path |
| Gemini (streaming) | POST /v1beta/models/{model}:streamGenerateContent |
Distinct path; use ?alt=sse for true SSE transport |
| Responses | POST /v1/responses |
OpenAI Responses API; input field optional |
| Embeddings | POST /v1/embeddings |
Default vector: 1536-dim, FNV-1a seeded, L2-normalized |
| Moderations | POST /v1/moderations |
Static flagged: false response, never auth-protected |
| Models list | GET /v1/models |
Auto-derived from fixtures or ServerBuilder::models() |
| Health | GET /health |
Returns {"status": "ok"}. Never auth-protected. |
| Status echo | GET /code/{status} |
Returns specified HTTP status (100–599). Never auth-protected. |
Response Shapes
Anthropic non-streaming:
body.type:"message"body.role:"assistant"body.id:"msg-llmposter-{N}"(hyphens, not underscores — NOTmsg_...;Nis a 1-indexed monotonic counter shared across providers in a process)body.content[N].type:"text"or"tool_use"body.content[N].id(tool_use):"toolu_llmposter_{N}"(1-indexed)body.content[N].input(tool_use): the JSON object fromToolCall.argumentsbody.stop_reason:"end_turn"(text) |"tool_use"(tool calls) | custombody.usage.input_tokens/output_tokens:u64(approximate,bytes/4heuristic)
Anthropic streaming (SSE, Content-Type: text/event-stream):
Events in order: message_start, content_block_start, content_block_delta (repeated), content_block_stop, message_delta, message_stop
Error responses (all providers): {"error": {"message": "..."}} — nested envelope, not a top-level message.
/code/{status}: Returns the specified HTTP status code. /code/204, /code/304, and /code/205 return empty bodies.
Corrupt body (failure.corrupt_body: true): literal string "overloaded" with Content-Type: text/plain and HTTP 200.
Behavioral Semantics
- Matching order: first-match-wins with priority override. Non-catch-all fixtures are sorted by descending priority (higher wins), then file/declaration order breaks ties. Catch-all fixtures (
catch_all: true) are always checked after all non-catch-all fixtures, regardless of priority. Default priority is0. - Match fields stack conjunctively: a fixture with both
modelandtool_schemarequires every condition to match. - Substring matching:
match_user_messageandmatch_modeluse substring/contains matching."hello"matches"hello world". NoExactvariant exists — useStringMatch::regex("^exact$")for exact matching. - Prompt redaction in no-match errors: no-match error responses redact prompt content to avoid leaking sensitive content in logs and error messages.
- Response IDs: always
msg-llmposter-{N}(hyphens, monotonic counter) for Anthropic — not a UUID. Counter is shared with OpenAI (chatcmpl-llmposter-{N}) and Responses (resp-llmposter-{N}). - Tool-call IDs: deterministic
toolu_llmposter_{N}(1-indexed, sequential), not random UUIDs. - Token counts:
bytes/4heuristic — not a real tokenizer. Never assert exact values, only> 0. Token totals usesaturating_addacross all format builders (v0.4.8) — no panic on extreme counts. - Anthropic tool input field:
ToolCall.arguments(Rust) maps tocontent[].input(JSON), notcontent[].arguments. - Anthropic
stop_reason: defaults to"end_turn"for text,"tool_use"for tool calls. Not"stop"(that is OpenAI'sfinish_reason). max_tokensrequired for Anthropic — missingmax_tokensreturns a 400 validation error.- Non-boolean
streamfield rejected: requests withstreamset to a non-boolean (e.g.,"yes") return an error. - Auth scope: LLM routes only.
/health,/code/{N},/v1/models,/v1/embeddings,/v1/moderations, and/uiare never auth-protected. corrupt_body: always returns literal"overloaded"astext/plainwith HTTP 200. Configured content is ignored. On streaming requests, emits a malformed SSE frame.chunk_sizeignored for tool-call streaming across all four providers. Only affects text-content streaming.- Gemini streaming endpoints: non-streaming at
/v1beta/models/{model}:generateContent; streaming at/v1beta/models/{model}:streamGenerateContent. Non-SSE streaming buffers all chunks in memory and returns a single JSON array — use?alt=ssefor true SSE transport failure simulation. - Hot-reload: via
--watchflag (requireswatchfeature) orkill -HUP <pid>(SIGHUP). Fixtures are swapped atomically with priority re-sorting at load time. Invalid YAML keeps prior fixtures serving — the server is never taken down by a bad reload. - Load-time validation: invalid JSONPath, duplicate response headers (case-insensitive), and
latency_jitter_mswithoutlatency_msare all rejected when fixtures are loaded — not at request time. - OpenAI first streaming chunk: omits
contentfield entirely viaskip_serializing_if, not"content": null. All major SDKs treat absent and null identically. - Captured request status under chaos: capture runs before chaos logic. A chaos-injected 500 shows as 200 in the capture log; verify chaos failures via the HTTP response, not the capture log.
truncate_after_frames/disconnect_after_mson non-streaming: warning emitted, fields ignored.disconnect_after_ms: requires streaming withlatency > 0. Withlatency = 0, frames may complete before disconnect timer fires.- Content templating: uses minijinja via
content_templatefield (templatingfeature). /v1/embeddings: default vector is 1536-dim, L2-normalized, FNV-1a seeded by input. Deterministic per input. Override viarespond_with_embedding(Vec<f64>)./v1/moderations: static OpenAI-compatible response withflagged: false. Always returned, never computed from input.- Tool-call argument serialization fails: falls back to
"{}"(v0.4.8). Previous versions used"".
Feature Flags
| Feature | Default | Description |
|---|---|---|
oauth |
on | OAuthConfig, with_oauth*, oauth_url, approve_device_code |
watch |
on | Hot-reload via file watcher |
jsonpath |
on | body_jsonpath match field |
ui |
off | Debug UI at /ui |
templating |
off | minijinja content_template rendering |
Disable defaults: llmposter = { version = "0.4", default-features = false, features = ["jsonpath"] }
CLI Usage
# Start mock server with YAML fixtures (default port: 2112)
llmposter --fixtures fixtures/
# Validate fixtures without starting server
llmposter --fixtures fixtures/ --validate
# Custom port and bind address
llmposter --fixtures fixtures/ --port 8080 --bind 0.0.0.0
# Verbose logging (404 responses include match failure detail)
llmposter --fixtures fixtures/ --verbose
# Diagnostic 404s — show nearest-match fixture with per-field pass/fail
llmposter --fixtures fixtures/ --diagnostics
# Set capture capacity (CLI default: 1000; 0 disables capture)
llmposter --fixtures fixtures/ --capture-capacity 5000
# Enable debug UI (requires ui feature)
llmposter --fixtures fixtures/ --ui
# Enable hot-reload (requires watch feature)
llmposter --fixtures fixtures/ --watchPitfalls
- Streaming config field name: when constructing
StreamingConfigdirectly, the field islatency(notlatency_ms). The builder methodwith_streaming(latency, chunk_size)also useslatencyas its parameter name.StreamingConfig { latency_ms: ... }will not compile. chunk_sizeand tool calls:chunk_sizeis silently ignored for tool-call streaming across all four providers. It only affects text-content streaming.- Gemini disconnect simulation: non-SSE Gemini streaming is buffered.
disconnect_after_msproduces a shorter 200 OK array, not a transport failure. Use?alt=ssefor real disconnect simulation. - Priority vs file order: fixtures are sorted by descending priority. A
priority: 10fixture at the bottom of the file wins overpriority: 0at the top. File order is the tiebreaker within the same priority level. - Capture log under chaos:
CapturedRequest.status_codeshows pre-chaos value (e.g., 200 even if chaos injects 500). Verify chaos failures via the HTTP response, not the capture log. - OpenAI first streaming chunk:
contentfield is absent, notnull. Assertcontent.is_none()or check for absent/null — strict JSON equality fails. - Token count accuracy:
bytes/4heuristic. Never assert exact token counts; assert> 0only. - JSONPath with
default-features = false:body_jsonpathrequires thejsonpathfeature. Re-enable explicitly if you disabled defaults. - Jitter without latency:
latency_jitter_msrequireslatency_msto be set. Rejected at fixture load time, not runtime. - Duplicate response headers: case-insensitive duplicate detection rejects fixtures at load time. Do not set
Content-Typein custom headers — the handler sets it automatically. - Streaming-only fields on non-streaming:
truncate_after_framesanddisconnect_after_msare ignored with a warning on non-streaming requests. - CLI vs library capture defaults: CLI defaults to 1000 captured requests with FIFO trimming. Library default is unbounded. Set
capture_capacity(0)to disable capture entirely (get_requests()returns empty). - Provider exclusivity at the route level: a
for_provider(Provider::OpenAI)fixture on a/v1/messages(Anthropic) request returns 404 — there is no cross-provider fallback. - Response exclusivity:
FixtureResponse.contentandFixtureResponse.tool_callsare mutually exclusive. Setting both is invalid; split into separate fixtures. - Tool-call arguments shape:
ToolCall.argumentsisserde_json::Value, not a stringified JSONString. Useserde_json::json!({"x": 1}), NOTr#"{"x":1}"#.to_string(). - Auth coverage: a bearer token does NOT block
GET /health,GET /code/{N},GET /v1/models,POST /v1/embeddings,POST /v1/moderations, orGET /ui. Auth only protects LLM routes. - Scenario state: a retry-success fixture without
required_statewill be shadowed by the failure fixture forever, causing an infinite 429 loop. Setrequired_state: Some("retried")(or whatever the failure fixture'sset_stateis) on the success fixture. - Hidden legacy helper:
llmposter::fixture::match_fixtureis#[doc(hidden)]and soft-deprecated since v0.4.6 — it does not honor v0.4.6+ match fields (priority,catch_all,headers,system_prompt,temperature,metadata,tool_schema,body_jsonpath). Drive the server throughServerBuilderfor full match semantics.
Migration
v0.4.7 → v0.4.8
- Tool-call argument serialization fallback changed from
""to"{}"when arguments fail to serialize. Tests that asserted on the empty-string fallback must update to"{}". - Token total overflow protection: token totals now use
saturating_addacross all format builders — extreme token counts no longer panic. No API change. - Docs example fix (marked
[BREAKING]in CHANGELOG): the scenario builder example indocs/scenarios.mdpreviously usedrespond_with_tool_calls(vec![])(empty vec). If your code was modeled on it, provide at least oneToolCallor switch torespond_with_content(...).
v0.4.6 → v0.4.7
- CLI capture capacity default changed from unbounded to 1000 with FIFO trimming. Pass
--capture-capacity <N>for a higher value or0to disable. Library users are unaffected (still unbounded by default). ServerBuilder::ui(true)and--uiCLI flag added (opt-in).
v0.4.5 → v0.4.6
- Fixture matching changed from file-order to priority-based sorting. Fixtures with explicit
priorityvalues now match before lower-priority fixtures regardless of file position. Default priority is0— existing fixtures without priority are unaffected relative to each other. - New match fields (
headers,system_prompt,temperature,metadata,tool_schema,body_jsonpath) added — all additive and optional. jsonpathfeature is on by default. Disable withdefault-features = falseif not needed.- New
Fixturestruct fields (priority,catch_all) andFixtureMatchfields are settable via direct struct construction or the dedicated builder methods (with_priority,as_catch_all,match_header,match_system_prompt,match_temperature,match_temperature_range,match_metadata,match_tool_schema,match_body_jsonpath).
General Upgrade
All 0.4.x releases are additive at the Rust API level (the only [BREAKING] 0.4.8 entry is a docs example removal). YAML fixture format is backward-compatible — old fixtures work unchanged with new versions. Pin to "0.4" semver range.