Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions rest/openai/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# REST example: OpenAI-compatible API mock

A drop-in mock of the [OpenAI API](https://platform.openai.com/docs/api-reference)
— enough of the common endpoints for any "OpenAI compatible" AI harness to run
against it: model listing, chat completions (plain, streaming and tool calling),
legacy completions and embeddings.

Point your client's base URL at `http://localhost:8080/v1`, use any API key, and
the mock stands in for the real API. It's a plain `rest` plugin mock — every
response lives in an externalised file under [`responses/`](responses), and the
chat endpoint routes to a different file based on the request body.

## Endpoints

| Method & path | Returns |
| --- | --- |
| `GET /v1/models` | The list of available models |
| `GET /v1/models/{model}` | A single model record (echoes the id) |
| `POST /v1/chat/completions` | A chat completion — see the routing below |
| `POST /v1/completions` | A legacy text completion |
| `POST /v1/embeddings` | An embedding vector |

### Chat completion routing

The one `POST /v1/chat/completions` endpoint serves three response files,
selected by matching on the request body. The conditions are **mutually
exclusive** and checked in this order:

| When the request… | …the mock replies with | File |
| --- | --- | --- |
| sets `"stream": true` | Server-Sent Events (`text/event-stream`), one `chat.completion.chunk` per `data:` line, ending with `[DONE]` | [`chat-completion-stream.txt`](responses/chat-completion-stream.txt) |
| is **not** streaming and mentions `weather` anywhere in the body | an assistant **tool call** to `get_weather` (`finish_reason: tool_calls`) | [`chat-completion-tool-call.json`](responses/chat-completion-tool-call.json) |
| anything else | a plain assistant message (`finish_reason: stop`) | [`chat-completion.json`](responses/chat-completion.json) |

Streaming takes precedence: a request that both streams and mentions the weather
gets the stream. The requested `model` is captured from the request and
templated back into every response, so the client sees the model it asked for.

> [!NOTE]
> The mock ignores the `Authorization` header, so any API key works. The tool
> call trigger is a plain substring match for `weather` on the whole request
> body — so a request whose *tool definitions* mention the weather triggers it
> too, not only the user's message. The trigger and the fixed reply text are
> deliberately simple — the point is coherent, correctly-shaped responses, not a
> real model. Edit the files in [`responses/`](responses) to reshape any reply.

> [!IMPORTANT]
> When several resources share a path (as the three chat resources do), keep
> their conditions mutually exclusive. If two can match the same request,
> Imposter logs *"more than one resource matched"* and picks one arbitrarily.
> That's why the tool and plain resources also test `$.stream != "true"` — many
> clients stream by default, so without it a streamed weather request would
> match both the streaming and tool resources. Note `value: "true"` is quoted:
> unquoted, YAML reads it as a boolean and the body matcher never equals it.

## Run

```bash
imposter up
```

> [!TIP]
> Imposter not installed?
> ```sh
> brew install imposter-project/imposter/imposter
> ```

## Try it

### With curl

```bash
# List models
curl http://localhost:8080/v1/models

# Plain chat completion
curl http://localhost:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Say hello"}]}'

# Tool calling (mention the weather)
curl http://localhost:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"gpt-4o","messages":[{"role":"user","content":"What is the weather in London?"}],
"tools":[{"type":"function","function":{"name":"get_weather"}}]}'

# Streaming (Server-Sent Events)
curl -N http://localhost:8080/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"gpt-4o","stream":true,"messages":[{"role":"user","content":"hi"}]}'

# Embeddings
curl http://localhost:8080/v1/embeddings \
-H 'Content-Type: application/json' \
-d '{"model":"text-embedding-3-small","input":"hello world"}'
```

### With the OpenAI SDK

Any OpenAI client works — just override the base URL. For example, in Python:

```python
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8080/v1", api_key="sk-anything")
print(client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Say hello"}],
).choices[0].message.content)
```

### Verify it end to end

The [`harness/`](harness) directory has a small script that drives the mock with
the **real `openai` SDK** and checks every endpoint — handy as a smoke test or in
CI. See [`harness/README.md`](harness/README.md).

## A note on streaming

Streaming replies are complete, correctly-framed SSE: the OpenAI SDKs parse them
and reassemble the message exactly as they would from the real API (the harness
proves this). The whole event stream is sent at once rather than dribbled out
token-by-token over time — fine for wiring up and testing a client, but not a
substitute for real time-to-first-token behaviour.
2 changes: 2 additions & 0 deletions rest/openai/harness/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.venv/
__pycache__/
61 changes: 61 additions & 0 deletions rest/openai/harness/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Verification harness

A tiny harness that proves the mock is genuinely _OpenAI compatible_ by driving
it with the **official `openai` Python SDK** — the same client a real AI
application would use. If your harness can talk to OpenAI, and this script
passes, the mock will stand in for the real API.

It checks:

- `models.list()`
- `chat.completions.create(...)` — plain assistant reply
- `chat.completions.create(..., tools=[...])` — a tool call (`finish_reason: tool_calls`)
- `chat.completions.create(..., stream=True)` — Server-Sent Events, reassembled
- `completions.create(...)` — legacy text completion
- `embeddings.create(...)`

## Run it

First start the mock from the parent directory:

```bash
cd ..
imposter up
```

Then, in this directory, install the SDK and run the checks:

```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

python verify.py
```

You should see every check marked `PASS`:

```
Verifying OpenAI-compatible mock at http://localhost:8080/v1

models.list
[PASS] returns a non-empty model list — gpt-4o, gpt-4o-mini, gpt-3.5-turbo, text-embedding-3-small
...
PASS: all checks passed — the mock is OpenAI-compatible
```

The script exits `0` when everything passes and `1` if any check fails, so it
works in CI too.

## Options

Point it at a mock on a different host or port:

```bash
python verify.py --base-url http://localhost:3000/v1
# or
OPENAI_BASE_URL=http://localhost:3000/v1 python verify.py
```

`OPENAI_API_KEY` is read if set, but the mock accepts any key — you don't need a
real one.
1 change: 1 addition & 0 deletions rest/openai/harness/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
openai>=1.0
155 changes: 155 additions & 0 deletions rest/openai/harness/verify.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Verify the Imposter OpenAI-compatible mock using the real OpenAI SDK.

This exercises the mock exactly as a downstream AI harness would: it points the
official `openai` client at the mock's base URL and checks that models, chat,
tool calling, streaming and embeddings all round-trip with the shapes the SDK
expects.

Usage:
python verify.py # against http://localhost:8080/v1
python verify.py --base-url http://localhost:3000/v1
OPENAI_BASE_URL=... python verify.py

Exit code is 0 if every check passes, 1 otherwise, so it drops into CI.
"""
from __future__ import annotations

import argparse
import os
import sys

from openai import OpenAI

# The mock ignores the key, but the SDK insists on a non-empty one.
API_KEY = os.environ.get("OPENAI_API_KEY", "sk-imposter-not-a-real-key")

PASS = "\033[32mPASS\033[0m"
FAIL = "\033[31mFAIL\033[0m"


class Checker:
"""Runs named checks, records outcomes and prints a running log."""

def __init__(self) -> None:
self.failures = 0

def check(self, name: str, ok: bool, detail: str = "") -> None:
marker = PASS if ok else FAIL
line = f" [{marker}] {name}"
if detail:
line += f" — {detail}"
print(line)
if not ok:
self.failures += 1


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--base-url",
default=os.environ.get("OPENAI_BASE_URL", "http://localhost:8080/v1"),
help="Base URL of the mock (default: http://localhost:8080/v1)",
)
args = parser.parse_args()

print(f"Verifying OpenAI-compatible mock at {args.base_url}\n")
client = OpenAI(base_url=args.base_url, api_key=API_KEY)
c = Checker()

# --- Models -----------------------------------------------------------
print("models.list")
model_ids = [m.id for m in client.models.list().data]
c.check("returns a non-empty model list", bool(model_ids), ", ".join(model_ids))

# --- Plain chat completion -------------------------------------------
print("\nchat.completions.create")
chat = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Say hello"}],
)
choice = chat.choices[0]
c.check("echoes the requested model", chat.model == "gpt-4o-mini", chat.model)
c.check("finish_reason is 'stop'", choice.finish_reason == "stop")
c.check("assistant returned content", bool(choice.message.content),
(choice.message.content or "")[:60] + "…")

# --- Tool calling -----------------------------------------------------
print("\nchat.completions.create (tool calling)")
tool_chat = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "What's the weather in London?"}],
tools=[{
"type": "function",
"function": {
"name": "get_weather",
"parameters": {
"type": "object",
"properties": {"location": {"type": "string"}},
},
},
}],
)
tchoice = tool_chat.choices[0]
c.check("finish_reason is 'tool_calls'", tchoice.finish_reason == "tool_calls")
tool_calls = tchoice.message.tool_calls or []
c.check("assistant requested a tool call", len(tool_calls) == 1)
if tool_calls:
fn = tool_calls[0].function
c.check("called get_weather with JSON arguments",
fn.name == "get_weather" and fn.arguments.strip().startswith("{"),
f"{fn.name}({fn.arguments})")

# --- Streaming --------------------------------------------------------
print("\nchat.completions.create (stream=True)")
stream = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "hi"}],
stream=True,
)
chunks = 0
assembled = ""
for chunk in stream:
chunks += 1
delta = chunk.choices[0].delta.content
if delta:
assembled += delta
c.check("received multiple stream chunks", chunks > 1, f"{chunks} chunks")
c.check("assembled a non-empty message", bool(assembled), repr(assembled))

# --- Legacy completions ----------------------------------------------
print("\ncompletions.create (legacy)")
completion = client.completions.create(
model="gpt-3.5-turbo-instruct",
prompt="Once upon a time",
)
c.check("returned completion text", bool(completion.choices[0].text.strip()))

# --- Embeddings -------------------------------------------------------
print("\nembeddings.create")
emb = client.embeddings.create(
model="text-embedding-3-small",
input="hello world",
)
vector = emb.data[0].embedding
c.check("returned a numeric embedding vector",
len(vector) > 0 and all(isinstance(x, float) for x in vector),
f"{len(vector)} dimensions")

# --- Summary ----------------------------------------------------------
print()
if c.failures:
print(f"{FAIL}: {c.failures} check(s) failed")
return 1
print(f"{PASS}: all checks passed — the mock is OpenAI-compatible")
return 0


if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc: # noqa: BLE001 - surface any SDK/transport error clearly
print(f"\n\033[31mERROR\033[0m: {exc}", file=sys.stderr)
print("Is the mock running? Start it with `imposter up` in the parent "
"directory, then re-run.", file=sys.stderr)
sys.exit(2)
Loading