From 17e4c28ae3e81190d061decfee775a7a93ef3a77 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Sat, 25 Jul 2026 20:48:58 +0100 Subject: [PATCH 1/2] feat: add OpenAI-compatible API mock with SDK verification harness Add a `rest/openai` example: a drop-in mock of the common OpenAI API endpoints for any "OpenAI compatible" AI harness to run against. It's a plain rest plugin mock with externalised response files. The one `POST /v1/chat/completions` endpoint routes to three response files by matching the request body: streaming (Server-Sent Events) when `stream: true`, a `get_weather` tool call when the body mentions the weather, and a plain assistant message otherwise. The requested model is captured and templated back into each response. Also covers `GET /v1/models`, `GET /v1/models/{model}`, legacy `POST /v1/completions` and `POST /v1/embeddings`. A `harness/` subdir drives the mock with the real `openai` Python SDK and asserts every endpoint round-trips, so users can verify the mock (and use it in CI). Verified against openai-python 2.48.0. Claude-Session: https://claude.ai/code/session_01NGFQaVnKN4wv322agMownn --- rest/openai/README.md | 111 +++++++++++++ rest/openai/harness/.gitignore | 2 + rest/openai/harness/README.md | 61 +++++++ rest/openai/harness/requirements.txt | 1 + rest/openai/harness/verify.py | 155 ++++++++++++++++++ rest/openai/openai-config.yaml | 93 +++++++++++ .../responses/chat-completion-stream.txt | 12 ++ .../responses/chat-completion-tool-call.json | 33 ++++ rest/openai/responses/chat-completion.json | 23 +++ rest/openai/responses/completion.json | 19 +++ rest/openai/responses/embeddings.json | 20 +++ rest/openai/responses/model.json | 6 + rest/openai/responses/models.json | 29 ++++ 13 files changed, 565 insertions(+) create mode 100644 rest/openai/README.md create mode 100644 rest/openai/harness/.gitignore create mode 100644 rest/openai/harness/README.md create mode 100644 rest/openai/harness/requirements.txt create mode 100644 rest/openai/harness/verify.py create mode 100644 rest/openai/openai-config.yaml create mode 100644 rest/openai/responses/chat-completion-stream.txt create mode 100644 rest/openai/responses/chat-completion-tool-call.json create mode 100644 rest/openai/responses/chat-completion.json create mode 100644 rest/openai/responses/completion.json create mode 100644 rest/openai/responses/embeddings.json create mode 100644 rest/openai/responses/model.json create mode 100644 rest/openai/responses/models.json diff --git a/rest/openai/README.md b/rest/openai/README.md new file mode 100644 index 0000000..fb514ca --- /dev/null +++ b/rest/openai/README.md @@ -0,0 +1,111 @@ +# 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: + +| 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) | +| mentions `weather` 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) | + +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 (`weather`) 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. + +## 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. diff --git a/rest/openai/harness/.gitignore b/rest/openai/harness/.gitignore new file mode 100644 index 0000000..a230a78 --- /dev/null +++ b/rest/openai/harness/.gitignore @@ -0,0 +1,2 @@ +.venv/ +__pycache__/ diff --git a/rest/openai/harness/README.md b/rest/openai/harness/README.md new file mode 100644 index 0000000..a65979c --- /dev/null +++ b/rest/openai/harness/README.md @@ -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. diff --git a/rest/openai/harness/requirements.txt b/rest/openai/harness/requirements.txt new file mode 100644 index 0000000..eb1435f --- /dev/null +++ b/rest/openai/harness/requirements.txt @@ -0,0 +1 @@ +openai>=1.0 diff --git a/rest/openai/harness/verify.py b/rest/openai/harness/verify.py new file mode 100644 index 0000000..5d8ea7a --- /dev/null +++ b/rest/openai/harness/verify.py @@ -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) diff --git a/rest/openai/openai-config.yaml b/rest/openai/openai-config.yaml new file mode 100644 index 0000000..e812011 --- /dev/null +++ b/rest/openai/openai-config.yaml @@ -0,0 +1,93 @@ +plugin: rest + +# An OpenAI-compatible mock: the endpoints an "OpenAI compatible" AI harness +# expects, served from externalised response files. Point your client's base URL +# at http://localhost:8080/v1 and drop in any API key. +# +# Chat completions route to a different response file depending on the request +# body, so one endpoint covers plain chat, streaming and tool calling: +# * stream: true -> Server-Sent Events (text/event-stream) +# * ...mentions "weather" -> an assistant tool call (finish_reason: tool_calls) +# * otherwise -> a plain assistant message +# +# The requested model is echoed back into each response via a captured store. + +resources: + # --- List models ------------------------------------------------------- + - method: GET + path: /v1/models + response: + file: responses/models.json + + - method: GET + path: /v1/models/{model} + response: + file: responses/model.json + template: true + + # --- Chat completions -------------------------------------------------- + + # Streaming: the client asked for stream: true. Reply with Server-Sent + # Events, one chat.completion.chunk per `data:` line, ending with [DONE]. + - method: POST + path: /v1/chat/completions + requestBody: + jsonPath: $.stream + value: true + capture: + model: + jsonPath: $.model + store: request + response: + file: responses/chat-completion-stream.txt + template: true + headers: + Content-Type: text/event-stream + + # Tool calling: the latest message mentions the weather, so the assistant + # responds with a call to the get_weather function instead of prose. + - method: POST + path: /v1/chat/completions + requestBody: + operator: Contains + value: weather + capture: + model: + jsonPath: $.model + store: request + response: + file: responses/chat-completion-tool-call.json + template: true + + # Plain chat completion (the default). + - method: POST + path: /v1/chat/completions + capture: + model: + jsonPath: $.model + store: request + response: + file: responses/chat-completion.json + template: true + + # --- Legacy text completions ------------------------------------------- + - method: POST + path: /v1/completions + capture: + model: + jsonPath: $.model + store: request + response: + file: responses/completion.json + template: true + + # --- Embeddings -------------------------------------------------------- + - method: POST + path: /v1/embeddings + capture: + model: + jsonPath: $.model + store: request + response: + file: responses/embeddings.json + template: true diff --git a/rest/openai/responses/chat-completion-stream.txt b/rest/openai/responses/chat-completion-stream.txt new file mode 100644 index 0000000..ab58643 --- /dev/null +++ b/rest/openai/responses/chat-completion-stream.txt @@ -0,0 +1,12 @@ +data: {"id":"chatcmpl-imposter00000000000000000003","object":"chat.completion.chunk","created":1700000000,"model":"${stores.request.model:-gpt-4o}","system_fingerprint":"fp_imposter","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}]} + +data: {"id":"chatcmpl-imposter00000000000000000003","object":"chat.completion.chunk","created":1700000000,"model":"${stores.request.model:-gpt-4o}","system_fingerprint":"fp_imposter","choices":[{"index":0,"delta":{"content":"Hello"},"logprobs":null,"finish_reason":null}]} + +data: {"id":"chatcmpl-imposter00000000000000000003","object":"chat.completion.chunk","created":1700000000,"model":"${stores.request.model:-gpt-4o}","system_fingerprint":"fp_imposter","choices":[{"index":0,"delta":{"content":" from"},"logprobs":null,"finish_reason":null}]} + +data: {"id":"chatcmpl-imposter00000000000000000003","object":"chat.completion.chunk","created":1700000000,"model":"${stores.request.model:-gpt-4o}","system_fingerprint":"fp_imposter","choices":[{"index":0,"delta":{"content":" Imposter!"},"logprobs":null,"finish_reason":null}]} + +data: {"id":"chatcmpl-imposter00000000000000000003","object":"chat.completion.chunk","created":1700000000,"model":"${stores.request.model:-gpt-4o}","system_fingerprint":"fp_imposter","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}]} + +data: [DONE] + diff --git a/rest/openai/responses/chat-completion-tool-call.json b/rest/openai/responses/chat-completion-tool-call.json new file mode 100644 index 0000000..f976f62 --- /dev/null +++ b/rest/openai/responses/chat-completion-tool-call.json @@ -0,0 +1,33 @@ +{ + "id": "chatcmpl-imposter00000000000000000002", + "object": "chat.completion", + "created": 1700000000, + "model": "${stores.request.model:-gpt-4o}", + "system_fingerprint": "fp_imposter", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_imposter0000000000000001", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\": \"London, UK\", \"unit\": \"celsius\"}" + } + } + ] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 62, + "completion_tokens": 18, + "total_tokens": 80 + } +} diff --git a/rest/openai/responses/chat-completion.json b/rest/openai/responses/chat-completion.json new file mode 100644 index 0000000..927143b --- /dev/null +++ b/rest/openai/responses/chat-completion.json @@ -0,0 +1,23 @@ +{ + "id": "chatcmpl-imposter00000000000000000001", + "object": "chat.completion", + "created": 1700000000, + "model": "${stores.request.model:-gpt-4o}", + "system_fingerprint": "fp_imposter", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello from Imposter! This is a mock OpenAI-compatible chat completion. Point your AI harness at http://localhost:8080/v1 and this response stands in for the real API." + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 19, + "completion_tokens": 34, + "total_tokens": 53 + } +} diff --git a/rest/openai/responses/completion.json b/rest/openai/responses/completion.json new file mode 100644 index 0000000..1f3dbfa --- /dev/null +++ b/rest/openai/responses/completion.json @@ -0,0 +1,19 @@ +{ + "id": "cmpl-imposter00000000000000000001", + "object": "text_completion", + "created": 1700000000, + "model": "${stores.request.model:-gpt-3.5-turbo-instruct}", + "choices": [ + { + "text": "\n\nThis is a mock text completion served by Imposter.", + "index": 0, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 5, + "completion_tokens": 12, + "total_tokens": 17 + } +} diff --git a/rest/openai/responses/embeddings.json b/rest/openai/responses/embeddings.json new file mode 100644 index 0000000..dfe26b7 --- /dev/null +++ b/rest/openai/responses/embeddings.json @@ -0,0 +1,20 @@ +{ + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": [ + 0.0023064255, -0.009327292, 0.015797347, -0.007778034, + 0.004549444, 0.021252235, -0.013026721, 0.017145356, + -0.019018242, 0.006035530, 0.011951274, -0.002890319, + 0.008325662, -0.014567234, 0.001204657, 0.020018242 + ] + } + ], + "model": "${stores.request.model:-text-embedding-3-small}", + "usage": { + "prompt_tokens": 8, + "total_tokens": 8 + } +} diff --git a/rest/openai/responses/model.json b/rest/openai/responses/model.json new file mode 100644 index 0000000..86efd13 --- /dev/null +++ b/rest/openai/responses/model.json @@ -0,0 +1,6 @@ +{ + "id": "${context.request.pathParams.model}", + "object": "model", + "created": 1715367049, + "owned_by": "imposter" +} diff --git a/rest/openai/responses/models.json b/rest/openai/responses/models.json new file mode 100644 index 0000000..78cc3a4 --- /dev/null +++ b/rest/openai/responses/models.json @@ -0,0 +1,29 @@ +{ + "object": "list", + "data": [ + { + "id": "gpt-4o", + "object": "model", + "created": 1715367049, + "owned_by": "imposter" + }, + { + "id": "gpt-4o-mini", + "object": "model", + "created": 1721172741, + "owned_by": "imposter" + }, + { + "id": "gpt-3.5-turbo", + "object": "model", + "created": 1677610602, + "owned_by": "imposter" + }, + { + "id": "text-embedding-3-small", + "object": "model", + "created": 1705948997, + "owned_by": "imposter" + } + ] +} From b5776d7d5894f581e4255594b33971e7c038cd4a Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Sat, 25 Jul 2026 21:24:04 +0100 Subject: [PATCH 2/2] fix: make chat-completion resources mutually exclusive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three POST /v1/chat/completions resources shared a path but their body conditions overlapped, so a single request could match more than one. Clients that stream by default hit this: a "weather" message is both `stream: true` and contains "weather", matching the streaming and tool-call resources equally. Imposter then warns ("more than one resource matched") and picks one arbitrarily — returning the stream instead of the tool call. Make the conditions disjoint: streaming matches `$.stream == "true"`; the tool and plain resources additionally require `$.stream != "true"`, and plain requires the body not contain "weather". Verified on both the native and JVM engines: all six stream/weather combinations route to exactly one resource with no match warnings, and the SDK harness still passes. Also quote `value: "true"` — unquoted it is a YAML boolean, which the string body matcher never equals (a cross-engine footgun). Claude-Session: https://claude.ai/code/session_01NGFQaVnKN4wv322agMownn --- rest/openai/README.md | 27 ++++++++++++++++++++------- rest/openai/openai-config.yaml | 31 +++++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/rest/openai/README.md b/rest/openai/README.md index fb514ca..c163c3b 100644 --- a/rest/openai/README.md +++ b/rest/openai/README.md @@ -23,22 +23,35 @@ chat endpoint routes to a different file based on the request body. ### Chat completion routing The one `POST /v1/chat/completions` endpoint serves three response files, -selected by matching on the request body: +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) | -| mentions `weather` 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) | +| 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) | -The requested `model` is captured from the request and templated back into every -response, so the client sees the model it asked for. +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 (`weather`) 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. +> 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 diff --git a/rest/openai/openai-config.yaml b/rest/openai/openai-config.yaml index e812011..3f9f1a2 100644 --- a/rest/openai/openai-config.yaml +++ b/rest/openai/openai-config.yaml @@ -27,13 +27,21 @@ resources: # --- Chat completions -------------------------------------------------- + # The three chat resources below share one path, so their body conditions + # are kept mutually exclusive — exactly one matches any request. Otherwise + # Imposter reports "more than one resource matched" and picks one at random. + # Note: `value: "true"` is quoted so it stays the *string* true, not a YAML + # boolean, which the body matcher would never equal. + # Streaming: the client asked for stream: true. Reply with Server-Sent # Events, one chat.completion.chunk per `data:` line, ending with [DONE]. + # Streaming wins over tool calling when both would apply. - method: POST path: /v1/chat/completions requestBody: jsonPath: $.stream - value: true + operator: EqualTo + value: "true" capture: model: jsonPath: $.model @@ -44,13 +52,17 @@ resources: headers: Content-Type: text/event-stream - # Tool calling: the latest message mentions the weather, so the assistant - # responds with a call to the get_weather function instead of prose. + # Tool calling: the latest message mentions the weather (and the client is + # not streaming), so the assistant calls the get_weather function. - method: POST path: /v1/chat/completions requestBody: - operator: Contains - value: weather + allOf: + - operator: Contains + value: weather + - jsonPath: $.stream + operator: NotEqualTo + value: "true" capture: model: jsonPath: $.model @@ -59,9 +71,16 @@ resources: file: responses/chat-completion-tool-call.json template: true - # Plain chat completion (the default). + # Plain chat completion: anything that is neither streamed nor about weather. - method: POST path: /v1/chat/completions + requestBody: + allOf: + - operator: NotContains + value: weather + - jsonPath: $.stream + operator: NotEqualTo + value: "true" capture: model: jsonPath: $.model