A deterministic OpenAI API emulator for testing and CI/CD pipelines. Provides scripted responses that make official OpenAI client libraries "just work" without actual API calls.
- Full SDK Compatibility: Works with official OpenAI Python and JavaScript SDKs
- Simple API: Just send text, get text back - no complex matching rules
- Deterministic Responses: Script exact responses for reproducible testing
- Streaming Support: Full SSE streaming for chat completions and responses
- Session Isolation: Token-based session management for concurrent tests
- Pattern Matching: Optional regex patterns for dynamic responses
- Kubernetes Ready: Health checks, Docker support, and K8s manifests included
# Build and run
make run
# Or directly with Go
go run cmd/openai-emulator/main.go# Build and run container
make docker-run
# Or manually
docker build -t openai-emulator .
docker run -p 8080:8080 openai-emulator# Run all tests (Go unit tests + conformance)
make test
# Run only conformance tests
make conformance
# Run tests against Docker container
make docker-testGET /v1/models- List modelsGET /v1/models/{id}- Get model detailsPOST /v1/responses- Text completions (streaming/non-streaming)POST /v1/chat/completions- Chat completions (streaming/non-streaming)
POST /_emulator/script- Load test scriptsPOST /_emulator/reset- Reset session stateGET /_emulator/state- Debug state (requires DEBUG=true)
GET /healthz- Liveness probeGET /readyz- Readiness probe
from openai import OpenAI
client = OpenAI(
api_key="test-token",
base_url="http://localhost:8080/v1"
)
# Use normally
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}]
)import OpenAI from 'openai';
const openai = new OpenAI({
apiKey: 'test-token',
baseURL: 'http://localhost:8080/v1'
});
// Use normally
const response = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: 'Hello' }]
});Load scripts via POST /_emulator/script:
curl -X POST http://localhost:8080/_emulator/script \
-H "Authorization: Bearer test-token" \
-H "Content-Type: application/json" \
-d '{"reset": true, "responses": "Hello from the emulator!"}'curl -X POST http://localhost:8080/_emulator/script \
-H "Authorization: Bearer test-token" \
-H "Content-Type: application/json" \
-d '{
"reset": true,
"responses": [
"First response",
"Second response",
"Third response"
]
}'curl -X POST http://localhost:8080/_emulator/script \
-H "Authorization: Bearer test-token" \
-H "Content-Type: application/json" \
-d '{
"reset": true,
"responses": {
".*hello.*": "Hi there!",
".*help.*": "How can I help you?",
".*weather.*": "It's sunny today!"
}
}'rules takes the same fields without the shorthand:
curl -X POST http://localhost:8080/_emulator/script \
-H "Authorization: Bearer test-token" \
-H "Content-Type: application/json" \
-d '{
"reset": true,
"rules": [
{"pattern": ".*hello.*", "response": "Hi there!", "times": 1},
{"pattern": "", "response": "Anything else."}
]
}'An empty pattern always matches, so the rule above answers every request that
falls through. A rule with no times is unlimited, except a tool-call rule,
which fires once. The sequential array form of responses is unchanged: each
entry still fires once, in order.
A rule can answer with a tool call instead of text, so an agent under test actually executes something:
curl -X POST http://localhost:8080/_emulator/script \
-H "Authorization: Bearer test-token" \
-H "Content-Type: application/json" \
-d '{
"reset": true,
"responses": [
{"pattern": ".*list the files.*", "tool_calls": [
{"id": "call_1", "type": "function",
"function": {"name": "shell", "arguments": "{\"command\":\"ls -1\"}"}}
]},
{"pattern": "", "response": "all done", "times": -1}
]
}'The reply carries finish_reason: "tool_calls", and the client is expected to
run the function and send the result back as a role: "tool" message.
Two things to know:
- A tool-call rule defaults to
"times": 1. The client answers with the result and the last user message is unchanged, so an unlimited rule would match again and the conversation would never end. Put a catch-all after it, as above, to say what happens once the call has been made. function.argumentsis a JSON document encoded as a string, which is the OpenAI wire format and what SDKs expect to parse.- Tool calls are chat completions only.
/v1/completionsand/v1/responseshave no place to put them, so a tool-call rule aimed at those endpoints answers with empty text.
# Apply manifests
kubectl apply -f k8s/
# Or use with your own image
kubectl set image deployment/openai-emulator openai-emulator=your-registry/openai-emulator:tagPORT- Server port (default: 8080)DEBUG- Enable debug endpoints (default: false)EMULATOR_URL- URL for conformance tests (default: http://localhost:8080)
See CLAUDE.md for detailed development documentation.
This emulator prioritizes:
- SDK Compatibility: Real OpenAI SDKs must work without modification
- Simplicity: Send text, get text back - no complex configuration
- Deterministic Behavior: Scripted responses for reproducible tests
- Streaming Fidelity: Accurate SSE streaming implementation
- Automatic Error Handling: Invalid models return proper OpenAI errors
Apache 2.0