feat(mock-provider): deterministic OpenAI-compatible endpoint for local and CI use

Adds src/mock_provider/, a standalone ASGI app that lets Honcho run with no
model provider, no API key, and no spend. It answers /v1/chat/completions and
/v1/embeddings with obviously-synthetic content derived from the request, so
the same request always produces the same response.

It runs as its own service from the standard Honcho image with a different
entrypoint, the way api and deriver already differ, so there is no second image
to build or keep in digest-sync. The app imports nothing from src.config or
src.db, so it boots even when the rest of the stack is misconfigured.

The chat endpoint generates from the JSON Schema it is sent rather than
answering with prose. That matters because a prose answer does not fail loudly:
repair_response_model_json swallows the parse error and returns an empty
PromptRepresentation, which reads as "the deriver found nothing" rather than
"the mock is wrong". Generation resolves $ref/$defs indirection, caps recursion
for reasoning-tree schemas, and covers json_object mode by recovering the
schema Honcho injects into the prompt. Embeddings are hash-derived, so
identical input yields an identical vector.

Tests drive the production OpenAIBackend and _EmbeddingClient against the app
over ASGI, including the strict json_schema transform that
chat.completions.parse() applies. Verified end to end against a real stack:
messages in, conclusions and 1536-dim embeddings written to pgvector, with no
calls to any real provider.

Mock embeddings carry no semantic similarity, so recall against this provider
must use lexical search. CONTRIBUTING notes that, and the load_dotenv(override=
True) behaviour that lets a stale repo .env win over exported environment
variables.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eugene Eisenstein 2026-08-28 16:15:26 -04:00 committed by GitHub
parent 2ad56a4d71
commit b1594089aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1391 additions and 0 deletions

View File

@ -219,6 +219,40 @@ uv run python -m src.deriver # background worker
Everything Python goes through `uv run`. Redis is optional for local development; without it
caching is simply disabled.
### Running without a model provider
`src/mock_provider/` is a deterministic, OpenAI-compatible endpoint, so you can run the full
stack with no provider account, no API key, and no spend. It answers `/v1/chat/completions`
and `/v1/embeddings` with obviously-synthetic content derived from the request, and the same
request always produces the same response. Run it from the standard image or the repo:
```bash
uv run fastapi run --host 0.0.0.0 --port 8106 src/mock_provider/main.py
```
Then point Honcho at it. All three variables are required:
```bash
export LLM_OPENAI_API_KEY=sandbox # gates client construction, not just auth
export LLM_OPENAI_BASE_URL=http://localhost:8106/v1
export EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL=http://localhost:8106/v1
```
Embeddings resolve through a separate client that reads the base URL only from the per-module
override, so without the third variable your embedding calls go to `api.openai.com` for real.
Do not set any per-module credential override (`..._OVERRIDES__API_KEY` / `API_KEY_ENV`) —
that makes the module ignore the global base URL.
Two things to know:
- **A repo `.env` beats your exported environment.** `src/config.py` calls
`load_dotenv(override=True)` at import, so a stale `.env` silently wins over the variables
above. Set `PYTHON_DOTENV_DISABLED=1` (and `HONCHO_CONFIG_TOML_DISABLED=1` for a local
`config.toml`) when you need the environment to be the only input.
- **Mock embeddings are hash-derived and carry no semantic similarity.** Two paraphrases are as
far apart as two unrelated strings. Recall against this provider must use lexical/full-text
search; anything asserting on vector ranking needs a real embedding provider.
## Making the change
### Branches and commits

View File

@ -0,0 +1 @@
"""Deterministic OpenAI-compatible provider for local and CI use."""

212
src/mock_provider/chat.py Normal file
View File

@ -0,0 +1,212 @@
"""OpenAI-compatible ``/chat/completions``, answered without inference."""
from __future__ import annotations
import hashlib
import json
import re
from collections.abc import AsyncIterator
from typing import Any
from fastapi import APIRouter, Request
from fastapi.responses import StreamingResponse
from src.mock_provider.coerce import as_dict, as_list, as_str
from src.mock_provider.schema_gen import generate
router = APIRouter(tags=["mock-provider"])
# Honcho's json_object mode injects the schema into the prompt text rather than
# into response_format (see _apply_json_object_mode in the OpenAI backend), so
# the only machine-readable copy of the schema is inside a message.
_SCHEMA_HINT = re.compile(r"schema:\s*(\{)", re.IGNORECASE)
def _completion_id(payload: dict[str, Any]) -> str:
"""Stable id, so a replayed request is byte-identical."""
digest = hashlib.sha256(
json.dumps(payload, sort_keys=True, default=str).encode()
).hexdigest()
return f"chatcmpl-mock-{digest[:24]}"
def _extract_balanced_json(text: str, start: int) -> dict[str, Any] | None:
"""Read one balanced ``{...}`` beginning at ``start`` and parse it.
A plain regex cannot do this — a JSON Schema contains nested objects, and
braces inside string literals must not count toward the depth.
"""
depth = 0
in_string = False
escaped = False
for index in range(start, len(text)):
char = text[index]
if in_string:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
in_string = False
continue
if char == '"':
in_string = True
elif char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
try:
parsed = json.loads(text[start : index + 1])
except json.JSONDecodeError:
return None
return as_dict(parsed)
return None
def _schema_from_messages(messages: list[Any]) -> dict[str, Any] | None:
"""Recover an injected schema from the prompt, for json_object mode."""
for message in reversed(messages):
message_dict = as_dict(message)
if message_dict is None:
continue
content = as_str(message_dict.get("content"))
if content is None:
continue
for match in _SCHEMA_HINT.finditer(content):
candidate = _extract_balanced_json(content, match.start(1))
if candidate and ("properties" in candidate or "$defs" in candidate):
return candidate
return None
def _response_content(payload: dict[str, Any]) -> str:
"""The assistant message body: schema-conforming JSON, or prose."""
response_format = as_dict(payload.get("response_format"))
messages = as_list(payload.get("messages")) or []
if response_format is not None:
kind = as_str(response_format.get("type"))
if kind == "json_schema":
wrapper = as_dict(response_format.get("json_schema"))
if wrapper is not None:
schema = as_dict(wrapper.get("schema"))
if schema is not None:
return json.dumps(generate(schema))
# A json_schema request whose schema we cannot read must not fall
# through to prose — that is the silent-empty failure this mock
# exists to avoid. An empty object at least parses.
return "{}"
if kind == "json_object":
schema = _schema_from_messages(messages)
return json.dumps(generate(schema)) if schema else "{}"
return (
"[mock] This is a synthetic response from Honcho's mock provider. "
"No model was called."
)
def _usage(payload: dict[str, Any], content: str) -> dict[str, int]:
"""Rough token accounting, so cost telemetry has plausible numbers."""
messages = as_list(payload.get("messages")) or []
prompt_chars = 0
for message in messages:
message_dict = as_dict(message)
text = as_str(message_dict.get("content")) if message_dict else None
if text is not None:
prompt_chars += len(text)
prompt_tokens = max(1, prompt_chars // 4)
completion_tokens = max(1, len(content) // 4)
return {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
}
def _created() -> int:
# Fixed rather than time-based: a mock that changes its output between
# identical calls defeats the point.
return 1577836800 # 2020-01-01T00:00:00Z
async def _stream(
completion_id: str, model: str, content: str, usage: dict[str, int]
) -> AsyncIterator[bytes]:
def chunk(payload: dict[str, Any]) -> bytes:
return f"data: {json.dumps(payload)}\n\n".encode()
base = {
"id": completion_id,
"object": "chat.completion.chunk",
"created": _created(),
"model": model,
}
yield chunk(
{
**base,
"choices": [
{
"index": 0,
"delta": {"role": "assistant", "content": ""},
"finish_reason": None,
}
],
}
)
yield chunk(
{
**base,
"choices": [
{"index": 0, "delta": {"content": content}, "finish_reason": None}
],
}
)
yield chunk(
{
**base,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
}
)
# The backend requests stream_options.include_usage and ends the stream on
# the usage chunk, so it must come last and must carry choices: [].
yield chunk({**base, "choices": [], "usage": usage})
yield b"data: [DONE]\n\n"
@router.post("/chat/completions")
async def chat_completions(request: Request) -> Any:
payload = as_dict(await request.json()) or {}
model = as_str(payload.get("model")) or "mock-model"
content = _response_content(payload)
usage = _usage(payload, content)
completion_id = _completion_id(payload)
if payload.get("stream"):
return StreamingResponse(
_stream(completion_id, model, content, usage),
media_type="text/event-stream",
)
return {
"id": completion_id,
"object": "chat.completion",
"created": _created(),
"model": model,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": content,
"refusal": None,
"tool_calls": None,
},
"logprobs": None,
"finish_reason": "stop",
}
],
"usage": usage,
}

View File

@ -0,0 +1,34 @@
"""Typed narrowing for values decoded from JSON.
``isinstance(value, dict)`` on an ``Any`` narrows to ``dict[Unknown, Unknown]``,
which spreads unknown types through everything downstream. These helpers narrow
and pin the element types in one step.
"""
from __future__ import annotations
from typing import Any, cast
def as_dict(value: object) -> dict[str, Any] | None:
"""The value as a JSON object, or None if it is not one."""
return cast("dict[str, Any]", value) if isinstance(value, dict) else None
def as_list(value: object) -> list[Any] | None:
"""The value as a JSON array, or None if it is not one."""
return cast("list[Any]", value) if isinstance(value, list) else None
def as_str(value: object) -> str | None:
"""The value as a JSON string, or None if it is not one."""
return value if isinstance(value, str) else None
def as_int(value: object) -> int | None:
"""The value as a JSON integer, or None if it is not one.
``bool`` is excluded: it is an ``int`` subclass, and a JSON ``true`` reaching
a size or dimension field is a malformed request, not the number one.
"""
return value if isinstance(value, int) and not isinstance(value, bool) else None

View File

@ -0,0 +1,103 @@
"""OpenAI-compatible ``/embeddings``, answered from a content hash."""
from __future__ import annotations
import base64
import hashlib
import struct
from typing import Any
from fastapi import APIRouter, Request
from src.mock_provider.coerce import as_dict, as_int, as_list, as_str
router = APIRouter(tags=["mock-provider"])
# Honcho's default. EmbeddingClient._validate_embedding_dimensions raises when a
# vector comes back at the wrong width, and validate_embedding_schema refuses to
# boot when the width disagrees with the pgvector column, so the request's own
# `dimensions` is honoured whenever it is present.
DEFAULT_DIMENSIONS = 1536
def content_to_embedding(content: str, dimensions: int) -> list[float]:
"""A deterministic vector for ``content``.
Identical input yields an identical vector, and different inputs differ —
which is what deduplication logic needs. It carries no semantic similarity:
two paraphrases are as far apart as two unrelated strings. Anything
asserting on ranking quality must not use this provider.
Mirrors ``_content_to_embedding`` in tests/conftest.py.
"""
digest = hashlib.sha256(content.encode()).digest()
return [(digest[i % len(digest)] / 255.0) * 2 - 1 for i in range(dimensions)]
def _encode_base64(vector: list[float]) -> str:
"""Little-endian float32, which is what the OpenAI SDK decodes."""
return base64.b64encode(struct.pack(f"<{len(vector)}f", *vector)).decode()
def _normalize_input(raw: object) -> list[str]:
"""Accept every input shape the OpenAI embeddings API allows.
Token-array inputs (list[int] / list[list[int]]) are rendered back to a
stable string rather than rejected — the vector only has to be deterministic.
"""
if raw is None:
return []
if (text := as_str(raw)) is not None:
return [text]
items = as_list(raw)
if items is None:
return [str(raw)]
# A flat list of ints is one tokenized input, not many single-token ones.
if items and all(as_int(item) is not None for item in items):
return [",".join(str(item) for item in items)]
texts: list[str] = []
for item in items:
if (item_text := as_str(item)) is not None:
texts.append(item_text)
elif (parts := as_list(item)) is not None:
texts.append(",".join(str(part) for part in parts))
else:
texts.append(str(item))
return texts
@router.post("/embeddings")
async def embeddings(request: Request) -> Any:
payload = as_dict(await request.json()) or {}
texts = _normalize_input(payload.get("input"))
requested = as_int(payload.get("dimensions"))
dimensions = requested if requested and requested > 0 else DEFAULT_DIMENSIONS
# The SDK omits encoding_format only when it wants base64 (it sets the
# parameter explicitly for float), so absent means base64.
encoding_format = as_str(payload.get("encoding_format")) or "base64"
data: list[dict[str, Any]] = []
for index, text in enumerate(texts):
vector = content_to_embedding(text, dimensions)
data.append(
{
"object": "embedding",
"index": index,
"embedding": (
vector if encoding_format == "float" else _encode_base64(vector)
),
}
)
prompt_tokens = max(1, sum(len(text) for text in texts) // 4)
return {
"object": "list",
"data": data,
"model": as_str(payload.get("model")) or "mock-embedding",
"usage": {"prompt_tokens": prompt_tokens, "total_tokens": prompt_tokens},
}

59
src/mock_provider/main.py Normal file
View File

@ -0,0 +1,59 @@
"""A deterministic, OpenAI-compatible provider for local and CI use.
Lets Honcho run with no model provider, no API key, and no spend. It answers
``/v1/chat/completions`` and ``/v1/embeddings`` with obviously-synthetic content
derived from the request, so the same request always produces the same response.
Runs as its own service from the standard Honcho image:
fastapi run --host 0.0.0.0 src/mock_provider/main.py
Point Honcho at it with three variables — all three are required:
LLM_OPENAI_API_KEY=sandbox # gates client construction, not just auth
LLM_OPENAI_BASE_URL=http://mock-provider:8000/v1
EMBEDDING_MODEL_CONFIG__OVERRIDES__BASE_URL=http://mock-provider:8000/v1
Embeddings resolve through a separate client that reads the base URL only from
the per-module override, so without the third variable embedding calls go to
api.openai.com for real. Do not set any per-module credential override
(``..._OVERRIDES__API_KEY`` / ``API_KEY_ENV``) — that makes the module ignore the
global base URL.
Embeddings are hash-derived and carry no semantic similarity. Recall assertions
against this provider must use lexical/full-text search, not vector ranking.
"""
from __future__ import annotations
from typing import Any
from fastapi import FastAPI
from src.mock_provider import chat, embeddings
app = FastAPI(
title="Honcho Mock Provider",
description="Deterministic OpenAI-compatible endpoint for local and CI use.",
version="1.0.0",
)
# Mounted at both prefixes so the base URL works with or without /v1.
for _router in (chat.router, embeddings.router):
app.include_router(_router, prefix="/v1")
app.include_router(_router)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok", "provider": "mock"}
@app.get("/{path:path}")
async def catch_all(path: str) -> dict[str, Any]:
"""Answer any other GET, so a bare ``/`` works as a container healthcheck.
Deliberately GET-only: an unimplemented POST returns 405 rather than a
plausible-looking 200, so a missing endpoint fails loudly.
"""
return {"object": "mock", "path": path, "detail": "mock provider placeholder"}

View File

@ -0,0 +1,285 @@
"""Generate a conforming instance from a JSON Schema.
The deriver is a structured-output caller: it sends a schema and parses the
reply back into a Pydantic model. A mock that answers with prose does not fail
loudly — ``repair_response_model_json`` swallows the error and hands back an
empty ``PromptRepresentation``, which reads as "the deriver found nothing"
rather than "the mock is wrong". So generation is driven by the schema that was
actually sent, ``$ref`` indirection and all.
Values are derived from a hash of the property path, so the same schema always
produces the same instance and two different fields never collide.
"""
from __future__ import annotations
import hashlib
from typing import Any
from src.mock_provider.coerce import as_dict, as_int, as_list, as_str
# Depth cap for self-referential schemas. Reasoning-tree models nest premises
# inside conclusions, so a $ref cycle is normal input, not a malformed schema.
MAX_DEPTH = 6
_WORDS = (
"synthetic",
"placeholder",
"mock",
"sample",
"fixture",
"stub",
"generated",
"example",
"inert",
"dummy",
)
def _seed(path: str) -> int:
return int.from_bytes(hashlib.sha256(path.encode()).digest()[:8], "big")
def _phrase(path: str, words: int = 6) -> str:
"""An obviously-synthetic sentence, stable for a given path."""
seed = _seed(path)
picked = [_WORDS[(seed >> (i * 5)) % len(_WORDS)] for i in range(words)]
return f"[mock] {' '.join(picked)}"
def _resolve(schema: dict[str, Any], root: dict[str, Any]) -> dict[str, Any]:
"""Follow a local ``$ref`` chain to the schema it points at.
Only local refs are supported: the mock never fetches over the network, and
Pydantic's ``model_json_schema()`` only ever emits ``#/$defs/...``.
"""
seen: set[str] = set()
current = schema
while "$ref" in current:
ref = as_str(current["$ref"])
if ref is None or not ref.startswith("#/") or ref in seen:
return {}
seen.add(ref)
target: dict[str, Any] | None = root
for part in ref[2:].split("/"):
if target is None or part not in target:
return {}
target = as_dict(target[part])
if target is None:
return {}
current = target
return current
def _merge_all_of(schema: dict[str, Any], root: dict[str, Any]) -> dict[str, Any]:
"""Flatten ``allOf`` into the parent so one pass can read properties off it."""
branches = as_list(schema.get("allOf"))
if branches is None:
return schema
merged: dict[str, Any] = {k: v for k, v in schema.items() if k != "allOf"}
for branch in branches:
resolved_branch = as_dict(branch)
if resolved_branch is None:
continue
resolved = _resolve(resolved_branch, root)
for key, value in resolved.items():
if key == "properties":
properties = as_dict(value)
if properties is not None:
existing = as_dict(merged.get("properties")) or {}
merged["properties"] = {**properties, **existing}
continue
if key == "required":
required = as_list(value)
if required is not None:
previous = as_list(merged.get("required")) or []
merged["required"] = list({*previous, *required})
continue
merged.setdefault(key, value)
return merged
def _infer_type(schema: dict[str, Any]) -> str:
"""Best-effort type when the schema omits an explicit ``type``."""
declared = schema.get("type")
if (name := as_str(declared)) is not None:
return name
if (names := as_list(declared)) is not None:
# Nullable unions arrive as ["string", "null"]; prefer the real type.
for candidate in names:
if (candidate_name := as_str(candidate)) and candidate_name != "null":
return candidate_name
return "null"
if "properties" in schema:
return "object"
if "items" in schema:
return "array"
return "string"
def generate(schema: dict[str, Any], root: dict[str, Any] | None = None) -> Any:
"""Build a value satisfying ``schema``.
``root`` carries the document that ``$ref`` resolves against; it defaults to
``schema`` itself, which is the shape Pydantic emits.
"""
return _generate(schema, root if root is not None else schema, "$", 0)
def _generate(
schema: dict[str, Any], root: dict[str, Any], path: str, depth: int
) -> Any:
resolved = _merge_all_of(_resolve(schema, root), root)
if "const" in resolved:
return resolved["const"]
enum = as_list(resolved.get("enum"))
if enum:
return enum[_seed(path) % len(enum)]
if depth >= MAX_DEPTH and "default" in resolved:
return resolved["default"]
for key in ("anyOf", "oneOf"):
branches = as_list(resolved.get(key))
if branches:
return _generate(_pick_branch(branches, root, depth), root, path, depth)
kind = _infer_type(resolved)
if kind == "object":
return _generate_object(resolved, root, path, depth)
if kind == "array":
return _generate_array(resolved, root, path, depth)
if kind == "integer":
return _bounded_int(resolved, path)
if kind == "number":
return float(_bounded_int(resolved, path))
if kind == "boolean":
return _seed(path) % 2 == 0
if kind == "null":
return None
return _generate_string(resolved, path)
def _pick_branch(
branches: list[Any], root: dict[str, Any], depth: int
) -> dict[str, Any]:
"""Choose a union member, preferring a non-null one.
Past the depth cap the order flips: a nullable recursive field terminates on
``null`` instead of nesting another level.
"""
resolved: list[dict[str, Any]] = []
for branch in branches:
branch_dict = as_dict(branch)
if branch_dict is not None:
resolved.append(_resolve(branch_dict, root))
if not resolved:
return {}
if depth >= MAX_DEPTH:
nulls = [b for b in resolved if _infer_type(b) == "null"]
if nulls:
return nulls[0]
non_null = [b for b in resolved if _infer_type(b) != "null"]
return non_null[0] if non_null else resolved[0]
def _generate_object(
schema: dict[str, Any], root: dict[str, Any], path: str, depth: int
) -> dict[str, Any]:
properties = as_dict(schema.get("properties"))
if properties is None:
return {}
# OpenAI structured outputs run in strict mode, where every property is
# required. Emitting the full property set satisfies both strict and loose
# schemas, so `required` is only consulted to decide what to drop once the
# depth cap has been hit.
declared_required = as_list(schema.get("required"))
required: set[str] = (
{name for name in (as_str(item) for item in declared_required) if name}
if declared_required is not None
else set(properties)
)
result: dict[str, Any] = {}
for name, subschema in properties.items():
if depth >= MAX_DEPTH and name not in required:
continue
child = as_dict(subschema)
if child is None:
continue
result[name] = _generate(child, root, f"{path}.{name}", depth + 1)
return result
def _generate_array(
schema: dict[str, Any], root: dict[str, Any], path: str, depth: int
) -> list[Any]:
items = as_dict(schema.get("items"))
min_items = as_int(schema.get("minItems"))
max_items = as_int(schema.get("maxItems"))
count = 2
if min_items is not None:
count = max(count, min_items)
if max_items is not None:
count = min(count, max_items)
if depth >= MAX_DEPTH:
count = min_items or 0
if items is None or count <= 0:
return []
return [_generate(items, root, f"{path}[{i}]", depth + 1) for i in range(count)]
def _generate_string(schema: dict[str, Any], path: str) -> str:
fmt = as_str(schema.get("format"))
if fmt == "date-time":
return "2020-01-01T00:00:00Z"
if fmt == "date":
return "2020-01-01"
if fmt == "uuid":
stem = hashlib.sha256(path.encode()).hexdigest()[:8]
return f"{stem}-0000-4000-8000-000000000000"
if fmt in ("uri", "url"):
return "https://mock.invalid/placeholder"
if fmt == "email":
return "placeholder@mock.invalid"
value = _phrase(path)
min_length = as_int(schema.get("minLength"))
max_length = as_int(schema.get("maxLength"))
if min_length is not None and len(value) < min_length:
value = value.ljust(min_length, "x")
if max_length is not None and len(value) > max_length:
value = value[:max_length]
return value
def _bounded_int(schema: dict[str, Any], path: str) -> int:
low = as_int(schema.get("minimum"))
if (
low is None
and (exclusive := as_int(schema.get("exclusiveMinimum"))) is not None
):
low = exclusive + 1
high = as_int(schema.get("maximum"))
if (
high is None
and (exclusive := as_int(schema.get("exclusiveMaximum"))) is not None
):
high = exclusive - 1
if low is not None and high is not None:
span = high - low
return low + (_seed(path) % (span + 1) if span > 0 else 0)
if low is not None:
return low + (_seed(path) % 8)
if high is not None:
return high - (_seed(path) % 8)
return _seed(path) % 100

View File

@ -91,6 +91,9 @@ _RUNTIME_MOCK_TEST_BLOCKLIST_PREFIXES = (
# Pure JWT scope tests — operate on src.security directly, no DB needed.
"tests/test_security.py",
"tests/test_generate_jwt_script.py",
# The mock provider is a standalone ASGI app with no database or LLM of its
# own; the runtime mocks would patch the very seams it exists to replace.
"tests/mock_provider/",
)
_LIVE_LLM_MARKER = "live_llm"

View File

@ -0,0 +1,221 @@
"""Drive Honcho's real provider clients against the mock over ASGI.
The unit tests assert the mock's own output. These assert the hop that actually
matters: ``OpenAIBackend`` and ``EmbeddingClient`` — the production classes,
unpatched — talking to the mock through the genuine OpenAI SDK, including the
``strict: true`` json_schema transform that ``chat.completions.parse()`` applies
on the way out and the Pydantic validation it applies on the way back.
"""
from __future__ import annotations
from typing import Any
import httpx
import pytest
from openai import AsyncOpenAI
from src.config import EmbeddingModelConfig
from src.embedding_client import _EmbeddingClient # pyright: ignore[reportPrivateUsage]
from src.llm.backends.openai import OpenAIBackend
from src.mock_provider.embeddings import content_to_embedding
from src.mock_provider.main import app
from src.utils.representation import PromptRepresentation
MESSAGES: list[dict[str, Any]] = [
{"role": "user", "content": "I switched the service from pip to uv last week."}
]
@pytest.fixture
def openai_client() -> AsyncOpenAI:
return AsyncOpenAI(
api_key="sandbox",
base_url="http://mock-provider.invalid/v1",
http_client=httpx.AsyncClient(transport=httpx.ASGITransport(app=app)),
)
@pytest.mark.asyncio
async def test_backend_parses_the_deriver_response_model(
openai_client: AsyncOpenAI,
) -> None:
"""The production path: parse() with a Pydantic response_format."""
backend = OpenAIBackend(openai_client)
result = await backend.complete(
model="mock-model",
messages=MESSAGES,
max_tokens=512,
response_format=PromptRepresentation,
)
assert isinstance(result.content, PromptRepresentation)
# An empty explicit list is what a prose-answering mock silently produces,
# so it is the specific thing worth asserting against.
assert result.content.explicit
assert result.output_tokens > 0
@pytest.mark.asyncio
async def test_backend_json_object_mode_recovers_the_schema(
openai_client: AsyncOpenAI,
) -> None:
"""json_object mode carries the schema in the prompt, not response_format."""
backend = OpenAIBackend(openai_client)
result = await backend.complete(
model="mock-model",
messages=MESSAGES,
max_tokens=512,
response_format=PromptRepresentation,
extra_params={"structured_output_mode": "json_object"},
)
assert isinstance(result.content, PromptRepresentation)
assert result.content.explicit
@pytest.mark.asyncio
async def test_backend_with_tools_uses_json_schema_and_still_parses(
openai_client: AsyncOpenAI,
) -> None:
"""Non-strict tools force create() + explicit json_schema instead of parse()."""
backend = OpenAIBackend(openai_client)
result = await backend.complete(
model="mock-model",
messages=MESSAGES,
max_tokens=512,
response_format=PromptRepresentation,
tools=[
{
"type": "function",
"function": {
"name": "search_memory",
"description": "Search memory",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
},
},
}
],
)
assert isinstance(result.content, PromptRepresentation)
assert result.content.explicit
@pytest.mark.asyncio
async def test_backend_plain_completion(openai_client: AsyncOpenAI) -> None:
backend = OpenAIBackend(openai_client)
result = await backend.complete(
model="mock-model", messages=MESSAGES, max_tokens=128
)
assert isinstance(result.content, str)
assert "[mock]" in result.content
assert result.finish_reason == "stop"
@pytest.mark.asyncio
async def test_backend_stream_yields_content_then_a_usage_terminator(
openai_client: AsyncOpenAI,
) -> None:
backend = OpenAIBackend(openai_client)
chunks = [
chunk
async for chunk in backend.stream(
model="mock-model", messages=MESSAGES, max_tokens=128
)
]
assert "[mock]" in "".join(chunk.content or "" for chunk in chunks)
terminator = chunks[-1]
assert terminator.is_done
assert terminator.finish_reason == "stop"
# None here means the stream ended without a usage chunk, which is the
# failure mode when stream_options.include_usage goes unanswered.
assert terminator.output_tokens is not None
assert terminator.output_tokens > 0
def _embedding_client(dimensions: int, encoding_format: str) -> _EmbeddingClient:
# The public EmbeddingClient is a settings-driven singleton wrapper; the
# transport behaviour under test lives on the implementation it wraps.
return _EmbeddingClient(
EmbeddingModelConfig(
model="text-embedding-3-small",
transport="openai",
api_key="sandbox",
base_url="http://mock-provider.invalid/v1",
),
vector_dimensions=dimensions,
max_input_tokens=8192,
max_tokens_per_request=300000,
send_dimensions=True,
encoding_format=encoding_format, # pyright: ignore[reportArgumentType]
)
@pytest.fixture(autouse=True)
def _route_embedding_client_over_asgi( # pyright: ignore[reportUnusedFunction]
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Give the embedding client's AsyncOpenAI an ASGI transport.
EmbeddingClient builds its own client internally, so the transport has to be
injected at construction rather than passed in.
"""
original = AsyncOpenAI.__init__
def patched(self: AsyncOpenAI, *args: Any, **kwargs: Any) -> None:
kwargs.setdefault(
"http_client",
httpx.AsyncClient(transport=httpx.ASGITransport(app=app)),
)
original(self, *args, **kwargs)
monkeypatch.setattr(AsyncOpenAI, "__init__", patched)
@pytest.mark.asyncio
@pytest.mark.parametrize("encoding_format", ["float", "base64"])
async def test_embedding_client_round_trip(encoding_format: str) -> None:
"""Covers both wire encodings; base64 is what the SDK uses by default."""
client = _embedding_client(1536, encoding_format)
vector = await client.embed("I switched the service from pip to uv.")
# _validate_embedding_dimensions raises on a width mismatch, so reaching
# here already proves the width is right; assert the values too.
assert len(vector) == 1536
assert vector == pytest.approx( # pyright: ignore[reportUnknownMemberType]
content_to_embedding("I switched the service from pip to uv.", 1536),
abs=1e-6,
)
@pytest.mark.asyncio
async def test_embedding_client_honours_a_non_default_dimension() -> None:
"""send_dimensions=True forwards `dimensions`; the mock must obey it."""
client = _embedding_client(256, "float")
assert len(await client.embed("hello")) == 256
@pytest.mark.asyncio
async def test_embedding_client_batches() -> None:
"""_validate_embedding_count rejects a mismatched count."""
client = _embedding_client(1536, "float")
texts = [f"observation number {index}" for index in range(12)]
vectors = await client.simple_batch_embed(texts)
assert len(vectors) == len(texts)
assert all(len(vector) == 1536 for vector in vectors)
assert len({tuple(vector) for vector in vectors}) == len(texts)

View File

@ -0,0 +1,439 @@
"""Contract tests for the mock provider.
The failure this guards against is silent: when the mock answers a structured
request with something the deriver cannot parse, ``repair_response_model_json``
falls back to an empty ``PromptRepresentation`` and the run looks like "the
deriver found nothing" rather than "the mock is broken". So the assertions here
are about parseability against real Honcho models, not about response shape.
"""
from __future__ import annotations
import base64
import hashlib
import json
import struct
from collections.abc import Callable
from typing import Any
import pytest
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
from src.mock_provider.embeddings import content_to_embedding
from src.mock_provider.main import app
from src.mock_provider.schema_gen import generate
from src.utils.representation import PromptRepresentation
# A $ref/$defs schema, which is what Pydantic emits for any nested model and the
# indirection a naive generator silently drops.
PROBE_SCHEMA: dict[str, Any] = {
"$defs": {
"Item": {
"type": "object",
"properties": {
"name": {"type": "string"},
"count": {"type": "integer", "minimum": 1, "maximum": 5},
},
"required": ["name", "count"],
}
},
"type": "object",
"properties": {
"label": {"type": "string"},
"items": {"type": "array", "items": {"$ref": "#/$defs/Item"}},
},
"required": ["label", "items"],
}
class ProbeItem(BaseModel):
name: str
count: int = Field(ge=1, le=5)
class Probe(BaseModel):
label: str
items: list[ProbeItem]
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def _post_chat(client: TestClient, **payload: Any) -> dict[str, Any]:
payload.setdefault("model", "mock-model")
payload.setdefault("messages", [{"role": "user", "content": "hello"}])
response = client.post("/v1/chat/completions", json=payload)
assert response.status_code == 200, response.text
return response.json()
def _json_schema_format(schema: dict[str, Any], name: str) -> dict[str, Any]:
return {
"type": "json_schema",
"json_schema": {"name": name, "schema": schema, "strict": True},
}
# --- structured output ------------------------------------------------------
def test_json_schema_request_round_trips_into_its_pydantic_model(
client: TestClient,
) -> None:
body = _post_chat(
client, response_format=_json_schema_format(PROBE_SCHEMA, "Probe")
)
content = body["choices"][0]["message"]["content"]
probe = Probe.model_validate_json(content)
assert probe.label
assert probe.items, "$ref array must not come back empty"
assert all(1 <= item.count <= 5 for item in probe.items)
def test_deriver_response_model_round_trips() -> None:
"""The real model the deriver parses, not a stand-in."""
schema = PromptRepresentation.model_json_schema()
content = json.dumps(generate(schema))
representation = PromptRepresentation.model_validate_json(content)
assert (
representation.explicit
), "an empty explicit list is exactly the silent failure this mock avoids"
def test_json_schema_response_is_never_prose(client: TestClient) -> None:
body = _post_chat(
client, response_format=_json_schema_format(PROBE_SCHEMA, "Probe")
)
json.loads(body["choices"][0]["message"]["content"])
def test_unreadable_json_schema_still_returns_parseable_json(
client: TestClient,
) -> None:
body = _post_chat(
client,
response_format={"type": "json_schema", "json_schema": {"name": "Broken"}},
)
assert json.loads(body["choices"][0]["message"]["content"]) == {}
def test_json_object_mode_recovers_the_schema_from_the_prompt(
client: TestClient,
) -> None:
"""json_object mode puts the schema in the prompt, not in response_format."""
body = _post_chat(
client,
messages=[
{"role": "user", "content": "Extract facts."},
{
"role": "user",
"content": "Respond with valid JSON matching this schema:\n"
+ json.dumps(PROBE_SCHEMA),
},
],
response_format={"type": "json_object"},
)
Probe.model_validate_json(body["choices"][0]["message"]["content"])
def test_json_object_mode_without_a_schema_returns_an_empty_object(
client: TestClient,
) -> None:
body = _post_chat(client, response_format={"type": "json_object"})
assert json.loads(body["choices"][0]["message"]["content"]) == {}
def test_plain_request_returns_prose(client: TestClient) -> None:
body = _post_chat(client)
content = body["choices"][0]["message"]["content"]
assert "[mock]" in content
with pytest.raises(json.JSONDecodeError):
json.loads(content)
def test_tools_request_does_not_emit_tool_calls(client: TestClient) -> None:
"""The tool loop must terminate; a mock that calls tools would spin."""
body = _post_chat(
client,
tools=[
{
"type": "function",
"function": {"name": "search_memory", "parameters": {}},
}
],
)
assert body["choices"][0]["message"]["tool_calls"] is None
assert body["choices"][0]["finish_reason"] == "stop"
def test_identical_requests_are_byte_identical(client: TestClient) -> None:
payload: dict[str, Any] = {
"model": "mock-model",
"messages": [{"role": "user", "content": "determinism"}],
"response_format": _json_schema_format(PROBE_SCHEMA, "Probe"),
}
first = client.post("/v1/chat/completions", json=payload).json()
second = client.post("/v1/chat/completions", json=payload).json()
assert first == second
def test_usage_is_reported(client: TestClient) -> None:
body = _post_chat(client)
usage = body["usage"]
assert usage["total_tokens"] == usage["prompt_tokens"] + usage["completion_tokens"]
assert usage["completion_tokens"] > 0
# --- schema generation edge cases -------------------------------------------
def test_recursive_schema_terminates() -> None:
"""Reasoning trees nest premises inside conclusions, so cycles are normal."""
schema: dict[str, Any] = {
"$defs": {
"Node": {
"type": "object",
"properties": {
"value": {"type": "string"},
"child": {"anyOf": [{"$ref": "#/$defs/Node"}, {"type": "null"}]},
},
"required": ["value", "child"],
}
},
"$ref": "#/$defs/Node",
}
node: dict[str, Any] | None = generate(schema)
depth = 0
while node is not None and node.get("child") is not None:
node = node["child"]
depth += 1
assert depth < 50, "recursive schema did not terminate"
_SCALAR_CASES: list[tuple[str, dict[str, Any], Callable[[Any], bool]]] = [
("enum", {"type": "string", "enum": ["a", "b"]}, lambda v: v in ("a", "b")),
("const", {"const": 7}, lambda v: v == 7),
("boolean", {"type": "boolean"}, lambda v: isinstance(v, bool)),
("null", {"type": "null"}, lambda v: v is None),
("number", {"type": "number"}, lambda v: isinstance(v, float)),
("nullable-union", {"type": ["string", "null"]}, lambda v: isinstance(v, str)),
("pinned-int", {"type": "integer", "minimum": 3, "maximum": 3}, lambda v: v == 3),
(
"exclusive-bounds",
{"type": "integer", "exclusiveMinimum": 1, "exclusiveMaximum": 3},
lambda v: v == 2,
),
(
"date-time",
{"type": "string", "format": "date-time"},
lambda v: str(v).endswith("Z"),
),
("min-length", {"type": "string", "minLength": 400}, lambda v: len(v) >= 400),
("max-length", {"type": "string", "maxLength": 4}, lambda v: len(v) == 4),
(
"min-items",
{"type": "array", "items": {"type": "string"}, "minItems": 3},
lambda v: len(v) >= 3,
),
(
"max-items",
{"type": "array", "items": {"type": "string"}, "maxItems": 1},
lambda v: len(v) == 1,
),
]
@pytest.mark.parametrize(
("schema", "check"),
[(schema, check) for _, schema, check in _SCALAR_CASES],
ids=[name for name, _, _ in _SCALAR_CASES],
)
def test_scalar_schema_forms(
schema: dict[str, Any], check: Callable[[Any], bool]
) -> None:
assert check(generate(schema))
def test_all_of_is_flattened() -> None:
schema: dict[str, Any] = {
"allOf": [
{
"type": "object",
"properties": {"a": {"type": "string"}},
"required": ["a"],
},
{
"type": "object",
"properties": {"b": {"type": "integer"}},
"required": ["b"],
},
]
}
result = generate(schema)
assert isinstance(result["a"], str)
assert isinstance(result["b"], int)
def test_generation_is_stable_across_calls() -> None:
assert generate(PROBE_SCHEMA) == generate(PROBE_SCHEMA)
def test_sibling_fields_of_the_same_type_differ() -> None:
"""Path-seeded, so a schema of identical fields is not all one value."""
schema: dict[str, Any] = {
"type": "object",
"properties": {
"first": {"type": "string"},
"second": {"type": "string"},
},
"required": ["first", "second"],
}
result = generate(schema)
assert result["first"] != result["second"]
# --- embeddings -------------------------------------------------------------
def test_embeddings_default_to_1536_and_are_stable(client: TestClient) -> None:
payload = {
"model": "text-embedding-3-small",
"input": "hello",
"encoding_format": "float",
}
first = client.post("/v1/embeddings", json=payload)
assert first.status_code == 200, first.text
vector = first.json()["data"][0]["embedding"]
assert len(vector) == 1536
assert all(-1.0 <= value <= 1.0 for value in vector)
assert client.post("/v1/embeddings", json=payload).json() == first.json()
def test_embeddings_honour_the_requested_dimension(client: TestClient) -> None:
"""A width mismatch raises in EmbeddingClient and blocks startup."""
response = client.post(
"/v1/embeddings",
json={"input": "hello", "dimensions": 256, "encoding_format": "float"},
)
assert len(response.json()["data"][0]["embedding"]) == 256
def test_different_inputs_give_different_vectors(client: TestClient) -> None:
response = client.post(
"/v1/embeddings",
json={"input": ["alpha", "beta"], "encoding_format": "float"},
)
data = response.json()["data"]
assert len(data) == 2
assert [item["index"] for item in data] == [0, 1]
assert data[0]["embedding"] != data[1]["embedding"]
def test_batch_returns_one_embedding_per_input(client: TestClient) -> None:
"""EmbeddingClient._validate_embedding_count rejects any other count."""
texts = [f"text-{index}" for index in range(17)]
response = client.post(
"/v1/embeddings", json={"input": texts, "encoding_format": "float"}
)
assert len(response.json()["data"]) == len(texts)
def test_base64_is_the_default_encoding_and_decodes_to_the_float_vector(
client: TestClient,
) -> None:
"""The SDK omits encoding_format precisely when it wants base64."""
response = client.post("/v1/embeddings", json={"input": "hello"})
encoded = response.json()["data"][0]["embedding"]
assert isinstance(encoded, str)
raw = base64.b64decode(encoded)
decoded = list(struct.unpack(f"<{len(raw) // 4}f", raw))
assert len(decoded) == 1536
expected = content_to_embedding("hello", 1536)
assert decoded == pytest.approx(expected, abs=1e-6) # pyright: ignore[reportUnknownMemberType]
def test_embedding_matches_the_test_suite_helper() -> None:
"""Kept in step with _content_to_embedding in tests/conftest.py.
Both must derive the same vector from the same text, so a suite that mocks
the embedding client in-process and one that talks to this provider over
HTTP agree on what a given string embeds to.
"""
digest = hashlib.sha256(b"hello").digest()
expected = [(digest[i % len(digest)] / 255.0) * 2 - 1 for i in range(8)]
assert content_to_embedding("hello", 8) == pytest.approx(expected) # pyright: ignore[reportUnknownMemberType]
# --- routing ----------------------------------------------------------------
def test_routes_are_mounted_with_and_without_the_v1_prefix(
client: TestClient,
) -> None:
for path in ("/v1/chat/completions", "/chat/completions"):
response = client.post(
path, json={"model": "m", "messages": [{"role": "user", "content": "x"}]}
)
assert response.status_code == 200, path
def test_unimplemented_post_returns_405_not_a_plausible_200(
client: TestClient,
) -> None:
"""A catch-all POST would make a missing endpoint look like it worked."""
assert client.post("/v1/completions", json={}).status_code == 405
def test_health_and_catch_all_get(client: TestClient) -> None:
assert client.get("/health").json()["status"] == "ok"
assert client.get("/").status_code == 200
# --- streaming --------------------------------------------------------------
def test_stream_emits_content_then_a_final_usage_chunk(client: TestClient) -> None:
"""The backend ends the stream on the usage chunk, so it must come last."""
with client.stream(
"POST",
"/v1/chat/completions",
json={
"model": "mock-model",
"messages": [{"role": "user", "content": "stream please"}],
"stream": True,
"stream_options": {"include_usage": True},
},
) as response:
assert response.status_code == 200
lines = [
line[len("data: ") :]
for line in response.iter_lines()
if line.startswith("data: ")
]
assert lines[-1] == "[DONE]"
chunks = [json.loads(line) for line in lines[:-1]]
content = "".join(
chunk["choices"][0]["delta"].get("content", "")
for chunk in chunks
if chunk["choices"]
)
assert "[mock]" in content
assert any(
chunk["choices"] and chunk["choices"][0]["finish_reason"] == "stop"
for chunk in chunks
)
usage_chunk = chunks[-1]
assert usage_chunk["usage"]["completion_tokens"] > 0
assert usage_chunk["choices"] == []