feat: transparent anthropic api incl. native anthropic api backend

This commit is contained in:
Alpha Nerd 2026-07-06 10:46:18 +02:00
parent c9e495876b
commit 1d012a92ef
Signed by: alpha-nerd
SSH key fingerprint: SHA256:QkkAgVoYi9TQ0UKPkiKSfnerZy2h4qhi3SVPXJmBN+M
11 changed files with 1431 additions and 20 deletions

View file

@ -167,6 +167,47 @@ multi-worker/replica deployment polling works via the shared DB, but `cancel` on
running task in the worker that started it (other workers just mark the stored row cancelled). A
background task interrupted by a server restart is reconciled to `failed` on the next startup.
## Anthropic Messages API
NOMYO Router also exposes the Anthropic **Messages API**:
```
POST /v1/messages # create a message (stream or non-stream)
POST /v1/messages/count_tokens # count input tokens for a request
```
It works transparently across **all** backends. For Ollama / llama-server / llama-swap the router
translates Messages ⇄ Chat Completions in both directions (request, response, and streaming typed
SSE events — `message_start``content_block_*``message_delta``message_stop`), so clients get
a consistent `/v1/messages` surface regardless of backend. The API is stateless — there is no store,
background mode, or conversation persistence.
### Native Anthropic upstream
Configure real Anthropic endpoints under the `anthropic_endpoints` config key (base URL **without**
a `/v1` suffix). Requests routed to a model advertised by such an endpoint are **forwarded verbatim**
over the Anthropic wire format — the router injects the endpoint's `api_keys` entry as the `x-api-key`
header and pins `anthropic-version`, passing through the client's `anthropic-beta`. Their advertised
models are treated as always-loaded, like external OpenAI endpoints.
```yaml
anthropic_endpoints:
- https://api.anthropic.com
api_keys:
"https://api.anthropic.com": "${ANTHROPIC_API_KEY}"
```
### Thinking
An inbound `thinking` block is mapped to the backend's `reasoning_effort` (budget → `low`/`medium`/
`high`); a backend that streams `reasoning_content` is surfaced back as Anthropic `thinking` content
blocks / `thinking_delta` events. On native endpoints, `thinking` passes through untouched.
### Caching
Set `nomyo: {"cache": true}` on the request body to consult the router's semantic LLM cache; a hit is
reflected via `usage.cache_read_input_tokens` (input tokens served from cache rather than re-processed).
## Semantic LLM Cache
NOMYO Router includes an optional semantic cache that serves repeated or semantically similar LLM requests from cache — no endpoint round-trip, no token cost, response in <10 ms.

329
api/messages.py Normal file
View file

@ -0,0 +1,329 @@
"""Anthropic **Messages API** routes (``/v1/messages`` and ``/v1/messages/count_tokens``).
The router speaks Chat Completions to its local backends, so this layer:
* **native** (configured ``anthropic_endpoints``): forwards the Anthropic request
verbatim over httpx with ``x-api-key`` / ``anthropic-version`` headers and streams
the upstream SSE straight back.
* **translated** (Ollama / llama-server / llama-swap): converts the request to chat,
reuses the resilient ``create_chat_with_retries`` ladder, and re-emits the result as
Anthropic typed SSE events (``requests/anthropic.py``).
The Messages API is stateless, so unlike ``/v1/responses`` there is no store,
background mode, or DB. An optional ``nomyo.cache`` extension field reflects hits back
through the router's semantic LLM cache (a hit is reported via ``usage.cache_read_input_tokens``).
"""
import codecs
import httpx
import orjson
from fastapi import APIRouter, HTTPException, Request
from starlette.responses import JSONResponse, StreamingResponse
from cache import get_llm_cache
from config import get_config
from context_window import _count_message_tokens
from fingerprint import _conversation_fingerprint
from state import app_state, token_queue, default_headers
from backends.normalize import is_anthropic_endpoint
from backends.probe import ANTHROPIC_VERSION
from backends.sessions import _make_openai_client
from routing import choose_endpoint, decrement_usage
from api.openai import create_chat_with_retries
from requests.anthropic import (
ChatToMessagesStream,
anthropic_messages_to_chat,
anthropic_to_chat_send_params,
build_message_object,
chat_message_to_content_blocks,
finish_reason_to_stop_reason,
message_object_to_sse,
new_message_id,
usage_chat_to_anthropic,
)
router = APIRouter()
CACHE_ROUTE = "anthropic_messages"
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _anthropic_http_client(endpoint: str) -> httpx.AsyncClient:
"""Return the warmed httpx client for a native Anthropic endpoint.
Startup pre-creates one per configured endpoint; fall back to an on-demand
client (cached in app_state) for tests that skip the lifespan startup.
"""
client = app_state["httpx_clients"].get(endpoint)
if client is None:
client = httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=15.0))
app_state["httpx_clients"][endpoint] = client
return client
def _native_headers(request: Request, api_key: str) -> dict:
"""Build outbound headers for a native Anthropic forward.
Injects the router's stored key as ``x-api-key`` and pins ``anthropic-version``,
passing through the client's ``anthropic-beta`` / ``anthropic-version`` when present.
"""
headers = {
"content-type": "application/json",
"x-api-key": api_key,
"anthropic-version": request.headers.get("anthropic-version", ANTHROPIC_VERSION),
}
beta = request.headers.get("anthropic-beta")
if beta:
headers["anthropic-beta"] = beta
return headers
async def _track(endpoint, tracking_model, prompt_tok, comp_tok):
if prompt_tok or comp_tok:
await token_queue.put((endpoint, tracking_model, prompt_tok, comp_tok))
def _serve_cache_hit(cached: bytes, message_id: str, stream: bool):
"""Serve a stored message object as a cache hit (input tokens → cache_read)."""
obj = orjson.loads(cached)
obj["id"] = message_id
u = obj.get("usage") or {}
read = u.get("input_tokens", 0) or 0
obj["usage"] = {
**u,
"input_tokens": 0,
"cache_read_input_tokens": read,
"cache_creation_input_tokens": 0,
}
if stream:
async def _served():
yield message_object_to_sse(obj)
return StreamingResponse(_served(), media_type="text/event-stream")
return JSONResponse(content=obj)
# ---------------------------------------------------------------------------
# POST /v1/messages
# ---------------------------------------------------------------------------
@router.post("/v1/messages")
async def anthropic_messages_proxy(request: Request):
config = get_config()
raw_body = await request.body()
try:
payload = orjson.loads(raw_body.decode("utf-8"))
except orjson.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}") from e
model = payload.get("model")
messages = payload.get("messages")
system = payload.get("system")
stream = bool(payload.get("stream"))
_cache_enabled = payload.get("nomyo", {}).get("cache", False)
if not model:
raise HTTPException(status_code=400, detail="Missing required field 'model'")
if messages is None:
raise HTTPException(status_code=400, detail="Missing required field 'messages'")
if payload.get("max_tokens") is None:
raise HTTPException(status_code=400, detail="Missing required field 'max_tokens'")
if ":latest" in model:
model = model.split(":latest")[0]
chat_messages = anthropic_messages_to_chat(system, messages)
message_id = new_message_id()
# Cache lookup (foreground) — before endpoint selection, keyed on the chat form.
_cache = get_llm_cache()
if _cache is not None and _cache_enabled:
cached = await _cache.get_chat(CACHE_ROUTE, model, chat_messages)
if cached is not None:
return _serve_cache_hit(cached, message_id, stream)
async def _cache_store(obj):
if _cache is None or not _cache_enabled or not obj.get("content"):
return
try:
await _cache.set_chat(CACHE_ROUTE, model, chat_messages, orjson.dumps(obj))
except Exception as _ce:
print(f"[cache] set_chat ({CACHE_ROUTE}) failed: {_ce}")
# Endpoint selection reserves a slot — released exactly once per branch.
_affinity_key = _conversation_fingerprint(model, chat_messages, None)
endpoint, tracking_model = await choose_endpoint(model, affinity_key=_affinity_key)
try:
native = is_anthropic_endpoint(endpoint)
if not native:
oclient = _make_openai_client(endpoint, default_headers=default_headers,
api_key=config.api_keys.get(endpoint, "no-key"))
send_params = anthropic_to_chat_send_params(payload, chat_messages, model)
except BaseException:
await decrement_usage(endpoint, tracking_model)
raise
# ---- native passthrough -----------------------------------------------
if native:
return await _handle_native(
request, payload, endpoint, tracking_model, stream,
api_key=config.api_keys.get(endpoint, "no-key"), cache_store=_cache_store)
# ---- translated streaming ---------------------------------------------
if stream:
try:
source = await create_chat_with_retries(
oclient, {**send_params, "stream": True,
"stream_options": {"include_usage": True}},
endpoint, model, tracking_model)
except BaseException:
await decrement_usage(endpoint, tracking_model)
raise
translator = ChatToMessagesStream(message_id, model)
async def _stream():
try:
async for sse in translator.events(source):
yield sse
prompt = (translator.usage or {}).get("prompt_tokens", 0)
comp = (translator.usage or {}).get("completion_tokens", 0)
await _track(endpoint, tracking_model, prompt, comp)
obj = build_message_object(
message_id=message_id, model=model,
content_blocks=translator.content_blocks,
stop_reason=translator.stop_reason,
usage=usage_chat_to_anthropic(translator.usage))
await _cache_store(obj)
finally:
await decrement_usage(endpoint, tracking_model)
return StreamingResponse(_stream(), media_type="text/event-stream")
# ---- translated non-streaming -----------------------------------------
try:
result = await create_chat_with_retries(
oclient, {**send_params, "stream": False}, endpoint, model, tracking_model)
message = result.choices[0].message.model_dump() if result.choices else {}
usage = result.usage.model_dump() if result.usage is not None else None
content_blocks = chat_message_to_content_blocks(message)
finish_reason = getattr(result.choices[0], "finish_reason", None) if result.choices else None
has_tool_use = any(b.get("type") == "tool_use" for b in content_blocks)
stop_reason = finish_reason_to_stop_reason(finish_reason, has_tool_use=has_tool_use)
await _track(endpoint, tracking_model,
(usage or {}).get("prompt_tokens", 0),
(usage or {}).get("completion_tokens", 0))
finally:
await decrement_usage(endpoint, tracking_model)
obj = build_message_object(
message_id=message_id, model=model, content_blocks=content_blocks,
stop_reason=stop_reason, usage=usage_chat_to_anthropic(usage))
await _cache_store(obj)
return JSONResponse(content=obj)
async def _handle_native(request, payload, endpoint, tracking_model, stream,
*, api_key, cache_store):
"""Forward an Anthropic request verbatim to a native upstream."""
client = _anthropic_http_client(endpoint)
headers = _native_headers(request, api_key)
forward = {k: v for k, v in payload.items() if k != "nomyo"}
url = f"{endpoint.rstrip('/')}/v1/messages"
if not stream:
forward["stream"] = False
try:
resp = await client.post(url, headers=headers, json=forward)
except BaseException:
await decrement_usage(endpoint, tracking_model)
raise
try:
data = resp.json()
except Exception:
data = {"type": "error", "error": {"message": resp.text[:500]}}
if resp.status_code == 200 and isinstance(data, dict):
u = data.get("usage") or {}
await _track(endpoint, tracking_model,
u.get("input_tokens", 0) or 0, u.get("output_tokens", 0) or 0)
await cache_store(data)
await decrement_usage(endpoint, tracking_model)
return JSONResponse(content=data, status_code=resp.status_code)
forward["stream"] = True
async def _proxy_stream():
decoder = codecs.getincrementaldecoder("utf-8")()
buffer = ""
input_tok = 0
output_tok = 0
try:
async with client.stream("POST", url, headers=headers, json=forward) as resp:
async for raw in resp.aiter_bytes():
if not raw:
continue
yield raw
# Parse a light copy to capture usage for token tracking.
buffer += decoder.decode(raw)
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
line = line.strip()
if not line.startswith("data:"):
continue
payload_str = line[len("data:"):].strip()
if not payload_str or payload_str == "[DONE]":
continue
try:
evt = orjson.loads(payload_str)
except orjson.JSONDecodeError:
continue
if evt.get("type") == "message_start":
u = (evt.get("message") or {}).get("usage") or {}
input_tok = u.get("input_tokens", 0) or input_tok
elif evt.get("type") == "message_delta":
u = evt.get("usage") or {}
output_tok = u.get("output_tokens", 0) or output_tok
finally:
await _track(endpoint, tracking_model, input_tok, output_tok)
await decrement_usage(endpoint, tracking_model)
return StreamingResponse(_proxy_stream(), media_type="text/event-stream")
# ---------------------------------------------------------------------------
# POST /v1/messages/count_tokens
# ---------------------------------------------------------------------------
@router.post("/v1/messages/count_tokens")
async def anthropic_count_tokens(request: Request):
config = get_config()
try:
payload = orjson.loads((await request.body()).decode("utf-8"))
except orjson.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid JSON: {e}") from e
model = payload.get("model")
messages = payload.get("messages")
if not model:
raise HTTPException(status_code=400, detail="Missing required field 'model'")
if messages is None:
raise HTTPException(status_code=400, detail="Missing required field 'messages'")
if ":latest" in model:
model = model.split(":latest")[0]
chat_messages = anthropic_messages_to_chat(payload.get("system"), messages)
# No slot reservation — this is a metadata call, not a completion.
endpoint, _tracking = await choose_endpoint(model, reserve=False)
if is_anthropic_endpoint(endpoint):
client = _anthropic_http_client(endpoint)
headers = _native_headers(request, config.api_keys.get(endpoint, "no-key"))
forward = {k: v for k, v in payload.items() if k != "nomyo"}
try:
resp = await client.post(
f"{endpoint.rstrip('/')}/v1/messages/count_tokens",
headers=headers, json=forward)
return JSONResponse(content=resp.json(), status_code=resp.status_code)
except Exception as e:
raise HTTPException(status_code=502, detail=f"count_tokens upstream failed: {e}") from e
return JSONResponse(content={"input_tokens": _count_message_tokens(chat_messages)})

View file

@ -665,10 +665,16 @@ async def openai_models_proxy(request: Request):
fetch.endpoint_details(ep, "/models", "data", config.api_keys.get(ep), skip_error_cache=True, timeout=8)
for ep in all_llama_endpoints
]
# 4. Query native Anthropic endpoints via /v1/models (auth headers picked by endpoint type)
anthropic_tasks = [
fetch.endpoint_details(ep, "/v1/models", "data", config.api_keys.get(ep), skip_error_cache=True, timeout=8)
for ep in config.anthropic_endpoints
]
ollama_models = await asyncio.gather(*ollama_tasks) if ollama_tasks else []
ext_openai_models = await asyncio.gather(*ext_openai_tasks) if ext_openai_tasks else []
llama_models = await asyncio.gather(*llama_tasks) if llama_tasks else []
anthropic_models = await asyncio.gather(*anthropic_tasks) if anthropic_tasks else []
models = {'data': []}
@ -702,6 +708,16 @@ async def openai_models_proxy(request: Request):
model['name'] = model['id']
models['data'].append(model)
# Add native Anthropic models (if any)
if anthropic_models:
for modellist in anthropic_models:
for model in modellist:
if not "id" in model.keys():
model['id'] = model.get('name', model.get('id', ''))
else:
model['name'] = model['id']
models['data'].append(model)
# 2. Return a JSONResponse with a deduplicated list of unique models for inference
return JSONResponse(
content={"data": dedupe_on_keys(models['data'], ['name'])},

View file

@ -70,6 +70,16 @@ def llama_endpoints(cfg) -> list:
return list(dict.fromkeys([*cfg.llama_server_endpoints, *cfg.llama_swap_endpoints]))
def is_anthropic_endpoint(endpoint: str) -> bool:
"""True if the endpoint is a configured native Anthropic Messages-API backend.
These speak the Anthropic wire format (``x-api-key`` / ``anthropic-version``
headers, ``/v1/messages``), not OpenAI Chat Completions, so requests routed to
them are forwarded verbatim rather than translated.
"""
return endpoint in get_config().anthropic_endpoints
def is_ext_openai_endpoint(endpoint: str) -> bool:
"""
Determine if an endpoint is an external OpenAI-compatible endpoint (not Ollama, llama-server or llama-swap).
@ -117,8 +127,8 @@ def get_tracking_model(endpoint: str, model: str) -> str:
This ensures consistent model naming across all routes for usage tracking.
"""
# External OpenAI endpoints are not shown in PS, keep as-is
if is_ext_openai_endpoint(endpoint):
# External OpenAI / native Anthropic endpoints are not shown in PS, keep as-is
if is_ext_openai_endpoint(endpoint) or is_anthropic_endpoint(endpoint):
return model
# llama-server / llama-swap endpoints use normalized names in PS

View file

@ -46,7 +46,32 @@ from backends.health import (
_format_connection_issue,
_is_llama_model_loaded,
)
from backends.normalize import is_ext_openai_endpoint, is_openai_compatible, is_llama_server, is_llama_swap
from backends.normalize import (
is_ext_openai_endpoint,
is_openai_compatible,
is_llama_server,
is_llama_swap,
is_anthropic_endpoint,
)
# Anthropic Messages API version pinned on every native probe/proxy request.
ANTHROPIC_VERSION = "2023-06-01"
def _auth_headers(endpoint: str, api_key: Optional[str]) -> dict:
"""Build outbound auth headers for a probe, honoring the backend's scheme.
Native Anthropic endpoints authenticate with ``x-api-key`` + a pinned
``anthropic-version`` header; everything else uses OpenAI-style Bearer auth.
"""
headers = {"Referer": default_headers.get("HTTP-Referer", "https://nomyo.ai")}
if is_anthropic_endpoint(endpoint):
if api_key is not None:
headers["x-api-key"] = api_key
headers["anthropic-version"] = ANTHROPIC_VERSION
elif api_key is not None:
headers["Authorization"] = "Bearer " + api_key
return headers
class fetch:
@ -56,12 +81,13 @@ class fetch:
This is called by available_models() after checking caches and in-flight requests.
"""
cfg = get_config()
headers = {"Referer": default_headers.get("HTTP-Referer", "https://nomyo.ai")}
if api_key is not None:
headers["Authorization"] = "Bearer " + api_key
headers = _auth_headers(endpoint, api_key)
ep_base = endpoint.rstrip("/")
if is_llama_server(endpoint) and "/v1" not in endpoint:
if is_anthropic_endpoint(endpoint):
endpoint_url = f"{ep_base}/v1/models"
key = "data"
elif is_llama_server(endpoint) and "/v1" not in endpoint:
endpoint_url = f"{ep_base}/v1/models"
key = "data"
elif "/v1" in endpoint or is_llama_server(endpoint):
@ -320,11 +346,12 @@ class fetch:
Uses request coalescing to prevent cache stampede and stale-while-revalidate
to serve requests immediately even when cache is stale (refreshing in background).
"""
if is_ext_openai_endpoint(endpoint):
# External OpenAI-compatible backends (vLLM, OpenAI, Groq, …) keep
# every advertised model permanently resident — there is no
# /api/ps-style "loaded" subset to probe. Report the advertised set
# as the loaded set so choose_endpoint's "loaded & free" preference
if is_ext_openai_endpoint(endpoint) or is_anthropic_endpoint(endpoint):
# External OpenAI-compatible backends (vLLM, OpenAI, Groq, …) and
# native Anthropic endpoints keep every advertised model permanently
# resident — there is no /api/ps-style "loaded" subset to probe.
# Report the advertised set as the loaded set so choose_endpoint's
# "loaded & free" preference
# treats them on par with Ollama/llama-server backends that have the
# model hot, instead of relegating them to the free-slot fallback and
# never using them when an Ollama box advertises the same model.
@ -396,9 +423,7 @@ class fetch:
if _is_fresh(_available_error_cache[endpoint], 300):
return []
headers = {"Referer": default_headers.get("HTTP-Referer", "https://nomyo.ai")}
if api_key is not None:
headers["Authorization"] = "Bearer " + api_key
headers = _auth_headers(endpoint, api_key)
request_url = f"{endpoint.rstrip('/')}/{route.lstrip('/')}"
client: aiohttp.ClientSession = get_probe_session(endpoint)
@ -434,9 +459,7 @@ async def _raw_probe(
(unlike `fetch.endpoint_details`, which returns [] on either).
Returns `(ok, payload_or_error_message)`.
"""
headers = {"Referer": default_headers.get("HTTP-Referer", "https://nomyo.ai")}
if api_key is not None:
headers["Authorization"] = "Bearer " + api_key
headers = _auth_headers(ep, api_key)
url = f"{ep.rstrip('/')}/{route.lstrip('/')}"
req_kwargs = {}
if timeout is not None:
@ -459,6 +482,14 @@ async def _endpoint_health(ep: str, *, timeout: Optional[float] = None) -> dict:
path (issue #83) is reported as `error` rather than `ok`.
OpenAI-compatible endpoints use a single `/models` probe.
"""
if is_anthropic_endpoint(ep):
ok, payload = await _raw_probe(
ep, "/v1/models", get_config().api_keys.get(ep), timeout=timeout,
)
if ok:
return {"status": "ok", "version": "latest"}
return {"status": "error", "detail": str(payload)}
if is_openai_compatible(ep):
ok, payload = await _raw_probe(
ep, "/models", get_config().api_keys.get(ep), timeout=timeout,

View file

@ -27,6 +27,12 @@ class Config(BaseSettings):
# workers). Same surface as llama_server_endpoints, but loaded models are read from
# /running (not /v1/models status) and unload uses POST /api/models/unload/:model_id.
llama_swap_endpoints: List[str] = Field(default_factory=list)
# List of native Anthropic Messages-API endpoints (e.g. https://api.anthropic.com).
# Configure the base URL WITHOUT a /v1 suffix; the router appends /v1/models and
# /v1/messages itself. Requests routed here are forwarded verbatim (no Messages⇄Chat
# translation); the endpoint's api_keys entry is sent as the x-api-key header. Their
# advertised models are treated as always-loaded, like external OpenAI endpoints.
anthropic_endpoints: List[str] = Field(default_factory=list)
# Max concurrent connections per endpointmodel pair, see OLLAMA_NUM_PARALLEL
max_concurrent_connections: int = 1
# Per-endpoint overrides: {endpoint_url: {max_concurrent_connections: N}}

View file

@ -16,6 +16,14 @@ llama_server_endpoints:
llama_swap_endpoints:
- http://192.168.0.52:8890/v1
# Native Anthropic Messages-API endpoints (optional). Configure the base URL WITHOUT a
# /v1 suffix; the router appends /v1/models and /v1/messages itself. Requests routed to a
# model advertised here are forwarded verbatim (no Messages⇄Chat translation), with the
# matching api_keys entry sent as the x-api-key header. Advertised models are treated as
# always-loaded, like external OpenAI endpoints.
# anthropic_endpoints:
# - https://api.anthropic.com
# Maximum concurrent connections *per endpointmodel pair* (equals to OLLAMA_NUM_PARALLEL)
# This is the global default; individual endpoints can override it via endpoint_config below.
max_concurrent_connections: 2

519
requests/anthropic.py Normal file
View file

@ -0,0 +1,519 @@
"""Translation between the Anthropic **Messages API** and **Chat Completions**.
The router speaks Chat Completions to its local backends (Ollama, llama-server,
llama-swap). To expose ``/v1/messages`` transparently on top of that, this module
converts in both directions:
* request: Anthropic ``system`` / ``messages`` / ``tools`` chat ``messages`` / ``tools``
* response: chat ``choices[0].message`` Anthropic ``content`` blocks
* stream: chat completion deltas Anthropic typed SSE events
Pure functions / a stream-translator class no I/O, mirroring ``requests/responses.py``.
The native passthrough path (configured ``anthropic_endpoints``) does not use this
module; it forwards the Anthropic wire format straight through.
Reasoning: an inbound ``thinking`` block maps to the backend's ``reasoning_effort``;
a backend that streams ``reasoning_content`` is surfaced back as Anthropic
``thinking`` content blocks / ``thinking_delta`` events.
"""
import secrets
import orjson
# ---------------------------------------------------------------------------
# Request direction: Anthropic Messages → Chat Completions
# ---------------------------------------------------------------------------
def _system_to_text(system):
"""Flatten Anthropic ``system`` (string or text blocks) to a plain string."""
if system is None:
return None
if isinstance(system, str):
return system
if isinstance(system, list):
parts = []
for b in system:
if isinstance(b, dict) and b.get("type") == "text":
parts.append(b.get("text", ""))
elif isinstance(b, str):
parts.append(b)
return "\n\n".join(p for p in parts if p) or None
return None
def _image_block_to_chat(block):
"""Convert an Anthropic ``image`` block to an OpenAI ``image_url`` part (or None)."""
src = block.get("source") or {}
stype = src.get("type")
if stype == "base64":
media = src.get("media_type", "image/png")
data = src.get("data", "")
return {"type": "image_url", "image_url": {"url": f"data:{media};base64,{data}"}}
if stype == "url" and src.get("url"):
return {"type": "image_url", "image_url": {"url": src["url"]}}
return None
def _tool_result_content_to_str(content):
"""Flatten an Anthropic ``tool_result`` content (string or blocks) to a string."""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for b in content:
if isinstance(b, dict):
if b.get("type") == "text":
parts.append(b.get("text", ""))
elif b.get("type") == "image":
parts.append("[image]")
else:
parts.append(orjson.dumps(b).decode("utf-8"))
else:
parts.append(str(b))
return "\n".join(parts)
return orjson.dumps(content).decode("utf-8")
def _user_content_to_chat(content):
"""Split an Anthropic user ``content`` into ``(chat_content, tool_messages)``.
``tool_result`` blocks become standalone OpenAI ``role:"tool"`` messages; the
remaining text/image parts collapse to a chat ``content`` (string or list).
"""
tool_messages = []
if content is None or isinstance(content, str):
return content, tool_messages
parts = []
for b in content if isinstance(content, list) else []:
if not isinstance(b, dict):
parts.append({"type": "text", "text": str(b)})
continue
btype = b.get("type")
if btype == "text":
parts.append({"type": "text", "text": b.get("text", "")})
elif btype == "image":
img = _image_block_to_chat(b)
if img:
parts.append(img)
elif btype == "tool_result":
tool_messages.append({
"role": "tool",
"tool_call_id": b.get("tool_use_id"),
"content": _tool_result_content_to_str(b.get("content")),
})
# document / other blocks: no chat equivalent → skip
if not parts:
chat_content = None
elif len(parts) == 1 and parts[0].get("type") == "text":
chat_content = parts[0]["text"]
else:
chat_content = parts
return chat_content, tool_messages
def _assistant_content_to_chat(content):
"""Convert an Anthropic assistant ``content`` to ``(text_or_parts, tool_calls)``."""
if content is None or isinstance(content, str):
return content, None
text_parts = []
tool_calls = []
for b in content if isinstance(content, list) else []:
if not isinstance(b, dict):
continue
btype = b.get("type")
if btype == "text":
text_parts.append(b.get("text", ""))
elif btype == "tool_use":
tool_calls.append({
"id": b.get("id"),
"type": "function",
"function": {
"name": b.get("name"),
"arguments": orjson.dumps(b.get("input") or {}).decode("utf-8"),
},
})
# thinking blocks in history have no chat equivalent → drop
text = "".join(text_parts) if text_parts else None
return text, (tool_calls or None)
def anthropic_messages_to_chat(system, messages):
"""Build a Chat Completions ``messages`` list from Anthropic ``system`` + ``messages``."""
chat = []
sys_text = _system_to_text(system)
if sys_text:
chat.append({"role": "system", "content": sys_text})
for m in messages or []:
role = m.get("role")
content = m.get("content")
if role == "user":
chat_content, tool_messages = _user_content_to_chat(content)
# Anthropic packs tool results into a user turn; OpenAI wants each as a
# separate tool message *before* any trailing user text.
chat.extend(tool_messages)
if chat_content is not None and chat_content != []:
chat.append({"role": "user", "content": chat_content})
elif role == "assistant":
text, tool_calls = _assistant_content_to_chat(content)
msg = {"role": "assistant", "content": text}
if tool_calls:
msg["tool_calls"] = tool_calls
chat.append(msg)
else:
chat.append({"role": role or "user", "content": content})
return chat
def tools_anthropic_to_chat(tools):
"""Map Anthropic tool definitions → Chat Completions function tools."""
if not tools:
return None
out = []
for t in tools:
if not isinstance(t, dict):
continue
# Server tools (web_search, …) carry a ``type`` but no ``input_schema`` —
# local backends can't run them, so skip.
if "input_schema" not in t and t.get("type") not in (None, "custom"):
continue
fn = {"name": t.get("name")}
if t.get("description"):
fn["description"] = t["description"]
fn["parameters"] = t.get("input_schema") or {"type": "object", "properties": {}}
out.append({"type": "function", "function": fn})
return out or None
def tool_choice_anthropic_to_chat(tool_choice):
"""Map Anthropic ``tool_choice`` → Chat Completions ``tool_choice``."""
if not isinstance(tool_choice, dict):
return None
ttype = tool_choice.get("type")
if ttype == "auto":
return "auto"
if ttype == "any":
return "required"
if ttype == "none":
return "none"
if ttype == "tool" and tool_choice.get("name"):
return {"type": "function", "function": {"name": tool_choice["name"]}}
return None
def _thinking_to_reasoning_effort(thinking):
"""Map an Anthropic ``thinking`` config to an OpenAI ``reasoning_effort`` level."""
if not isinstance(thinking, dict):
return None
if thinking.get("type") == "disabled":
return None
budget = thinking.get("budget_tokens")
if not isinstance(budget, int):
return "medium" # adaptive / enabled without an explicit budget
if budget < 2048:
return "low"
if budget < 8192:
return "medium"
return "high"
def anthropic_to_chat_send_params(payload, chat_messages, model):
"""Assemble the Chat Completions request body from an Anthropic payload."""
send = {"messages": chat_messages, "model": model}
if payload.get("max_tokens") is not None:
send["max_tokens"] = payload["max_tokens"]
opt = {
"temperature": payload.get("temperature"),
"top_p": payload.get("top_p"),
"stop": payload.get("stop_sequences"),
"tools": tools_anthropic_to_chat(payload.get("tools")),
"tool_choice": tool_choice_anthropic_to_chat(payload.get("tool_choice")),
"reasoning_effort": _thinking_to_reasoning_effort(payload.get("thinking")),
}
send.update({k: v for k, v in opt.items() if v is not None})
return send
# ---------------------------------------------------------------------------
# Response direction: Chat Completions → Anthropic Messages
# ---------------------------------------------------------------------------
def new_message_id():
return f"msg_{secrets.token_hex(24)}"
_STOP_REASON_MAP = {
"stop": "end_turn",
"length": "max_tokens",
"tool_calls": "tool_use",
"function_call": "tool_use",
"content_filter": "end_turn",
}
def finish_reason_to_stop_reason(finish_reason, has_tool_use=False):
if has_tool_use:
return "tool_use"
return _STOP_REASON_MAP.get(finish_reason, "end_turn")
def usage_chat_to_anthropic(usage, cache_read_tokens=0, cache_creation_tokens=0):
"""Map chat usage → Anthropic usage, folding in nomyo-cache attribution."""
prompt = (usage or {}).get("prompt_tokens") or 0
completion = (usage or {}).get("completion_tokens") or 0
return {
"input_tokens": prompt,
"output_tokens": completion,
"cache_creation_input_tokens": cache_creation_tokens,
"cache_read_input_tokens": cache_read_tokens,
}
def chat_message_to_content_blocks(message):
"""Convert an assistant chat message (dict) into Anthropic content blocks.
Ordering follows the Anthropic convention: thinking text tool_use.
"""
blocks = []
reasoning = message.get("reasoning_content") or message.get("reasoning")
if reasoning:
blocks.append({"type": "thinking", "thinking": reasoning})
content = message.get("content")
if content:
blocks.append({"type": "text", "text": content})
for tc in message.get("tool_calls") or []:
fn = tc.get("function", {})
try:
args = orjson.loads(fn.get("arguments") or "{}")
except (orjson.JSONDecodeError, TypeError):
args = {}
blocks.append({
"type": "tool_use",
"id": tc.get("id") or f"toolu_{secrets.token_hex(12)}",
"name": fn.get("name"),
"input": args,
})
return blocks
def build_message_object(*, message_id, model, content_blocks, stop_reason,
usage, stop_sequence=None):
"""Assemble a full ``type:"message"`` body for a non-streaming reply."""
return {
"id": message_id,
"type": "message",
"role": "assistant",
"model": model,
"content": content_blocks or [],
"stop_reason": stop_reason,
"stop_sequence": stop_sequence,
"usage": usage,
}
# ---------------------------------------------------------------------------
# Cache-hit replay: a finished message object → Anthropic SSE stream
# ---------------------------------------------------------------------------
def _sse(etype, payload):
body = {"type": etype, **payload}
return f"event: {etype}\ndata: {orjson.dumps(body).decode('utf-8')}\n\n".encode("utf-8")
def message_object_to_sse(msg):
"""Render a *finished* message object as a valid Anthropic SSE event stream.
Used to serve cache hits to streaming clients without a backend call.
"""
out = []
start = {**msg, "content": [], "stop_reason": None, "stop_sequence": None,
"usage": {**(msg.get("usage") or {}), "output_tokens": 0}}
out.append(_sse("message_start", {"message": start}))
for i, block in enumerate(msg.get("content") or []):
btype = block.get("type")
if btype == "text":
out.append(_sse("content_block_start", {
"index": i, "content_block": {"type": "text", "text": ""}}))
out.append(_sse("content_block_delta", {
"index": i, "delta": {"type": "text_delta", "text": block.get("text", "")}}))
elif btype == "thinking":
out.append(_sse("content_block_start", {
"index": i, "content_block": {"type": "thinking", "thinking": ""}}))
out.append(_sse("content_block_delta", {
"index": i,
"delta": {"type": "thinking_delta", "thinking": block.get("thinking", "")}}))
elif btype == "tool_use":
out.append(_sse("content_block_start", {
"index": i,
"content_block": {"type": "tool_use", "id": block.get("id"),
"name": block.get("name"), "input": {}}}))
out.append(_sse("content_block_delta", {
"index": i,
"delta": {"type": "input_json_delta",
"partial_json": orjson.dumps(block.get("input") or {}).decode("utf-8")}}))
out.append(_sse("content_block_stop", {"index": i}))
out.append(_sse("message_delta", {
"delta": {"stop_reason": msg.get("stop_reason"),
"stop_sequence": msg.get("stop_sequence")},
"usage": {"output_tokens": (msg.get("usage") or {}).get("output_tokens", 0)}}))
out.append(_sse("message_stop", {}))
return b"".join(out)
# ---------------------------------------------------------------------------
# Streaming direction: Chat Completions deltas → Anthropic typed SSE events
# ---------------------------------------------------------------------------
class ChatToMessagesStream:
"""Translate a Chat Completions streaming generator into Anthropic events.
Usage::
translator = ChatToMessagesStream(message_id, model)
async for sse_bytes in translator.events(chat_async_gen):
yield sse_bytes
# translator.content_blocks / usage / stop_reason now populated for storage
Emits ``message_start`` (``content_block_start`` ``*_delta``*
``content_block_stop``)* ``message_delta`` (stop_reason + usage)
``message_stop``. A single monotonically increasing ``index`` is assigned across
thinking / text / tool_use blocks.
"""
def __init__(self, message_id, model, cache_read_tokens=0, cache_creation_tokens=0):
self.message_id = message_id
self.model = model
self.cache_read_tokens = cache_read_tokens
self.cache_creation_tokens = cache_creation_tokens
self.usage = None
self.stop_reason = "end_turn"
self.content_blocks = []
def _open_block(self, index, content_block):
return _sse("content_block_start", {"index": index, "content_block": content_block})
def _delta(self, index, delta):
return _sse("content_block_delta", {"index": index, "delta": delta})
def _stop(self, index):
return _sse("content_block_stop", {"index": index})
async def events(self, async_gen):
yield _sse("message_start", {"message": {
"id": self.message_id, "type": "message", "role": "assistant",
"model": self.model, "content": [], "stop_reason": None,
"stop_sequence": None,
"usage": {"input_tokens": 0, "output_tokens": 0,
"cache_creation_input_tokens": self.cache_creation_tokens,
"cache_read_input_tokens": self.cache_read_tokens},
}})
next_index = 0
# open block state: ("thinking"|"text", index) or None
open_kind = None
open_index = None
thinking_text = []
text_parts = []
finish_reason = None
# tool call state: chat tool-call index -> {index, id, name, args}
tc_state = {}
def close_open():
nonlocal open_kind, open_index
if open_kind is not None:
ev = self._stop(open_index)
open_kind, open_index = None, None
return ev
return None
async for chunk in async_gen:
usage = getattr(chunk, "usage", None)
if usage is not None:
self.usage = {
"prompt_tokens": getattr(usage, "prompt_tokens", 0) or 0,
"completion_tokens": getattr(usage, "completion_tokens", 0) or 0,
}
choices = getattr(chunk, "choices", None)
if not choices:
continue
choice = choices[0]
if getattr(choice, "finish_reason", None):
finish_reason = choice.finish_reason
delta = choice.delta
reasoning = getattr(delta, "reasoning_content", None) or getattr(delta, "reasoning", None)
if reasoning:
if open_kind != "thinking":
ev = close_open()
if ev:
yield ev
open_kind, open_index = "thinking", next_index
next_index += 1
yield self._open_block(open_index, {"type": "thinking", "thinking": ""})
thinking_text.append(reasoning)
yield self._delta(open_index, {"type": "thinking_delta", "thinking": reasoning})
content_piece = getattr(delta, "content", None)
if content_piece:
if open_kind != "text":
ev = close_open()
if ev:
yield ev
open_kind, open_index = "text", next_index
next_index += 1
yield self._open_block(open_index, {"type": "text", "text": ""})
text_parts.append(content_piece)
yield self._delta(open_index, {"type": "text_delta", "text": content_piece})
for tc in getattr(delta, "tool_calls", None) or []:
idx = tc.index
fn = getattr(tc, "function", None)
if idx not in tc_state:
ev = close_open()
if ev:
yield ev
block_index = next_index
next_index += 1
state = {
"index": block_index,
"id": getattr(tc, "id", None) or f"toolu_{secrets.token_hex(12)}",
"name": (fn.name if fn else None),
"args": "",
}
tc_state[idx] = state
open_kind, open_index = "tool", block_index
yield self._open_block(block_index, {
"type": "tool_use", "id": state["id"], "name": state["name"], "input": {}})
else:
state = tc_state[idx]
if getattr(tc, "id", None):
state["id"] = tc.id
if fn and fn.name:
state["name"] = fn.name
if fn and fn.arguments:
state["args"] += fn.arguments
yield self._delta(state["index"], {
"type": "input_json_delta", "partial_json": fn.arguments})
ev = close_open()
if ev:
yield ev
# Assemble final content blocks (thinking → text → tool_use) for storage.
if thinking_text:
self.content_blocks.append({"type": "thinking", "thinking": "".join(thinking_text)})
if text_parts:
self.content_blocks.append({"type": "text", "text": "".join(text_parts)})
for idx in sorted(tc_state.keys()):
state = tc_state[idx]
try:
parsed = orjson.loads(state["args"]) if state["args"] else {}
except (orjson.JSONDecodeError, TypeError):
parsed = {}
self.content_blocks.append({
"type": "tool_use", "id": state["id"], "name": state["name"], "input": parsed})
self.stop_reason = finish_reason_to_stop_reason(finish_reason, has_tool_use=bool(tc_state))
out_tokens = (self.usage or {}).get("completion_tokens", 0)
yield _sse("message_delta", {
"delta": {"stop_reason": self.stop_reason, "stop_sequence": None},
"usage": {"output_tokens": out_tokens}})
yield _sse("message_stop", {})

View file

@ -293,6 +293,8 @@ from api.openai import router as openai_router
app.include_router(openai_router)
from api.responses import router as responses_router
app.include_router(responses_router)
from api.messages import router as messages_router
app.include_router(messages_router)
from api.ollama import router as ollama_router
app.include_router(ollama_router)
@ -375,6 +377,13 @@ async def startup_event() -> None:
if is_ext_openai_endpoint(ep):
app_state["httpx_clients"][ep] = httpx.AsyncClient(timeout=30.0)
# Native Anthropic Messages-API endpoints are forwarded over httpx. Use a
# long read timeout so streamed completions aren't cut short. Closed on
# shutdown by the shared httpx_clients cleanup.
for ep in config.anthropic_endpoints:
app_state["httpx_clients"][ep] = httpx.AsyncClient(
timeout=httpx.Timeout(300.0, connect=15.0))
# Create per-endpoint Unix socket sessions for .sock endpoints
for ep in llama_endpoints(config):
if _is_unix_socket_endpoint(ep):

View file

@ -33,6 +33,7 @@ from backends.normalize import (
is_ext_openai_endpoint,
is_openai_compatible,
is_llama_server,
is_anthropic_endpoint,
llama_endpoints,
get_tracking_model,
)
@ -97,7 +98,8 @@ async def choose_endpoint(model: str, reserve: bool = True,
# 1⃣ Gather advertisedmodel sets for all endpoints concurrently
# Include config.endpoints plus any llama-server / llama-swap endpoints
llama_eps_extra = [ep for ep in llama_endpoints(config) if ep not in config.endpoints]
all_endpoints = config.endpoints + llama_eps_extra
anthropic_eps_extra = [ep for ep in config.anthropic_endpoints if ep not in config.endpoints]
all_endpoints = config.endpoints + llama_eps_extra + anthropic_eps_extra
# Build the probe tasks in the SAME order as ``all_endpoints`` so the
# gathered results stay aligned for the ``zip(all_endpoints, advertised_sets)``
@ -107,7 +109,7 @@ async def choose_endpoint(model: str, reserve: bool = True,
# before an Ollama one would inherit the Ollama model set and get a request
# for a model it cannot serve, 404ing. See issue #128.)
def _advertised_task(ep: str):
if is_openai_compatible(ep):
if is_openai_compatible(ep) or is_anthropic_endpoint(ep):
return fetch.available_models(ep, config.api_keys.get(ep))
return fetch.available_models(ep)

440
test/test_messages.py Normal file
View file

@ -0,0 +1,440 @@
"""Tests for the Anthropic Messages API support (api/messages.py + requests/anthropic.py).
Covers the pure translation layer, the translated (Ollama-style) and native
(configured-anthropic-endpoint) backend paths, thinkingreasoning mapping,
streaming event shape, token counting, and the nomyo-cache reflection.
"""
from contextlib import ExitStack, contextmanager
from types import SimpleNamespace as NS
from unittest.mock import AsyncMock, MagicMock, patch
import orjson
import pytest
import router
from api import messages as api_messages
from requests import anthropic as at
# ──────────────────────────────────────────────────────────────────────────────
# Pure translation unit tests (no app / no I/O)
# ──────────────────────────────────────────────────────────────────────────────
class TestRequestTranslation:
def test_system_string(self):
chat = at.anthropic_messages_to_chat("be brief", [{"role": "user", "content": "hi"}])
assert chat[0] == {"role": "system", "content": "be brief"}
assert chat[1] == {"role": "user", "content": "hi"}
def test_system_blocks_join(self):
chat = at.anthropic_messages_to_chat(
[{"type": "text", "text": "a"}, {"type": "text", "text": "b"}],
[{"role": "user", "content": "hi"}])
assert chat[0] == {"role": "system", "content": "a\n\nb"}
def test_image_base64_to_data_url(self):
chat = at.anthropic_messages_to_chat(None, [{
"role": "user", "content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "AAA"}},
{"type": "text", "text": "what?"},
]}])
parts = chat[0]["content"]
assert parts[0] == {"type": "image_url", "image_url": {"url": "data:image/png;base64,AAA"}}
assert parts[1] == {"type": "text", "text": "what?"}
def test_tool_use_and_result_roundtrip(self):
chat = at.anthropic_messages_to_chat(None, [
{"role": "assistant", "content": [
{"type": "text", "text": "calling"},
{"type": "tool_use", "id": "toolu_1", "name": "get", "input": {"x": 1}}]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "toolu_1", "content": "42"}]},
])
assert chat[0]["role"] == "assistant"
assert chat[0]["content"] == "calling"
assert chat[0]["tool_calls"][0]["id"] == "toolu_1"
assert chat[0]["tool_calls"][0]["function"]["arguments"] == '{"x":1}'
assert chat[1] == {"role": "tool", "tool_call_id": "toolu_1", "content": "42"}
def test_tool_result_blocks_flattened(self):
chat = at.anthropic_messages_to_chat(None, [{
"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "t1",
"content": [{"type": "text", "text": "line"}]}]}])
assert chat[0] == {"role": "tool", "tool_call_id": "t1", "content": "line"}
def test_tools_and_choice(self):
sp = at.anthropic_to_chat_send_params(
{"max_tokens": 10, "tools": [{"name": "get", "description": "d",
"input_schema": {"type": "object"}}],
"tool_choice": {"type": "tool", "name": "get"}},
[], "m")
assert sp["tools"] == [{"type": "function", "function": {
"name": "get", "description": "d", "parameters": {"type": "object"}}}]
assert sp["tool_choice"] == {"type": "function", "function": {"name": "get"}}
def test_tool_choice_variants(self):
assert at.tool_choice_anthropic_to_chat({"type": "auto"}) == "auto"
assert at.tool_choice_anthropic_to_chat({"type": "any"}) == "required"
assert at.tool_choice_anthropic_to_chat({"type": "none"}) == "none"
def test_server_tools_dropped(self):
# web_search has a type but no input_schema → not runnable locally
assert at.tools_anthropic_to_chat([{"type": "web_search_20260209", "name": "web_search"}]) is None
def test_stop_sequences_and_sampling(self):
sp = at.anthropic_to_chat_send_params(
{"max_tokens": 5, "stop_sequences": ["X"], "temperature": 0.2, "top_p": 0.9}, [], "m")
assert sp["stop"] == ["X"]
assert sp["temperature"] == 0.2
assert sp["top_p"] == 0.9
def test_thinking_maps_to_reasoning_effort(self):
assert at.anthropic_to_chat_send_params(
{"max_tokens": 5, "thinking": {"type": "enabled", "budget_tokens": 1000}}, [], "m"
)["reasoning_effort"] == "low"
assert at.anthropic_to_chat_send_params(
{"max_tokens": 5, "thinking": {"type": "enabled", "budget_tokens": 4096}}, [], "m"
)["reasoning_effort"] == "medium"
assert at.anthropic_to_chat_send_params(
{"max_tokens": 5, "thinking": {"type": "enabled", "budget_tokens": 20000}}, [], "m"
)["reasoning_effort"] == "high"
assert "reasoning_effort" not in at.anthropic_to_chat_send_params(
{"max_tokens": 5, "thinking": {"type": "disabled"}}, [], "m")
class TestResponseTranslation:
def test_content_blocks_order(self):
blocks = at.chat_message_to_content_blocks({
"role": "assistant", "reasoning_content": "hmm", "content": "answer",
"tool_calls": [{"id": "c1", "function": {"name": "f", "arguments": '{"a":1}'}}]})
assert [b["type"] for b in blocks] == ["thinking", "text", "tool_use"]
assert blocks[0]["thinking"] == "hmm"
assert blocks[1]["text"] == "answer"
assert blocks[2] == {"type": "tool_use", "id": "c1", "name": "f", "input": {"a": 1}}
def test_stop_reason_mapping(self):
assert at.finish_reason_to_stop_reason("stop") == "end_turn"
assert at.finish_reason_to_stop_reason("length") == "max_tokens"
assert at.finish_reason_to_stop_reason("tool_calls") == "tool_use"
assert at.finish_reason_to_stop_reason("stop", has_tool_use=True) == "tool_use"
def test_usage_mapping(self):
u = at.usage_chat_to_anthropic({"prompt_tokens": 7, "completion_tokens": 3},
cache_read_tokens=5)
assert u == {"input_tokens": 7, "output_tokens": 3,
"cache_creation_input_tokens": 0, "cache_read_input_tokens": 5}
# ──────────────────────────────────────────────────────────────────────────────
# Streaming translator (pure)
# ──────────────────────────────────────────────────────────────────────────────
def _chunk(content=None, tool_calls=None, reasoning=None, finish_reason=None):
delta = NS(content=content, tool_calls=tool_calls)
if reasoning is not None:
delta.reasoning_content = reasoning
return NS(choices=[NS(delta=delta, finish_reason=finish_reason)], usage=None)
def _usage_chunk(p, c):
return NS(choices=[], usage=NS(prompt_tokens=p, completion_tokens=c))
async def _collect(translator, gen):
frames = []
async for sse in translator.events(gen):
frames.append(sse.decode())
return _parse_sse("".join(frames))
def _parse_sse(text):
out = []
for frame in text.strip().split("\n\n"):
if not frame.strip():
continue
etype = data = None
for line in frame.splitlines():
if line.startswith("event: "):
etype = line[len("event: "):]
elif line.startswith("data: "):
data = orjson.loads(line[len("data: "):])
out.append((etype, data))
return out
class TestStreamTranslator:
async def test_text_stream(self):
async def gen():
yield _chunk(content="Hel")
yield _chunk(content="lo", finish_reason="stop")
yield _usage_chunk(3, 5)
tr = at.ChatToMessagesStream("msg_1", "m")
events = await _collect(tr, gen())
types = [e[0] for e in events]
assert types[0] == "message_start"
assert types[-1] == "message_stop"
assert "content_block_start" in types and "content_block_stop" in types
text = "".join(d["delta"]["text"] for t, d in events
if t == "content_block_delta" and d["delta"]["type"] == "text_delta")
assert text == "Hello"
md = [d for t, d in events if t == "message_delta"][0]
assert md["delta"]["stop_reason"] == "end_turn"
assert md["usage"]["output_tokens"] == 5
assert tr.content_blocks == [{"type": "text", "text": "Hello"}]
async def test_thinking_then_text(self):
async def gen():
yield _chunk(reasoning="think ")
yield _chunk(reasoning="more")
yield _chunk(content="answer", finish_reason="stop")
tr = at.ChatToMessagesStream("msg_1", "m")
events = await _collect(tr, gen())
# thinking block (index 0) then text block (index 1)
starts = [(d["index"], d["content_block"]["type"])
for t, d in events if t == "content_block_start"]
assert starts == [(0, "thinking"), (1, "text")]
think = "".join(d["delta"]["thinking"] for t, d in events
if t == "content_block_delta" and d["delta"]["type"] == "thinking_delta")
assert think == "think more"
assert tr.content_blocks[0] == {"type": "thinking", "thinking": "think more"}
async def test_tool_call_stream(self):
tc0 = NS(index=0, id="call_1", function=NS(name="lookup", arguments='{"q":'))
tc1 = NS(index=0, id=None, function=NS(name=None, arguments='"hi"}'))
async def gen():
yield _chunk(tool_calls=[tc0])
yield _chunk(tool_calls=[tc1], finish_reason="tool_calls")
yield _usage_chunk(4, 2)
tr = at.ChatToMessagesStream("msg_1", "m")
events = await _collect(tr, gen())
assert any(t == "content_block_start" and d["content_block"]["type"] == "tool_use"
for t, d in events)
partial = "".join(d["delta"]["partial_json"] for t, d in events
if t == "content_block_delta" and d["delta"]["type"] == "input_json_delta")
assert partial == '{"q":"hi"}'
md = [d for t, d in events if t == "message_delta"][0]
assert md["delta"]["stop_reason"] == "tool_use"
assert tr.content_blocks[-1] == {"type": "tool_use", "id": "call_1",
"name": "lookup", "input": {"q": "hi"}}
# ──────────────────────────────────────────────────────────────────────────────
# Cache-hit replay
# ──────────────────────────────────────────────────────────────────────────────
class TestCacheReplay:
def test_message_object_to_sse_roundtrip(self):
msg = at.build_message_object(
message_id="msg_1", model="m",
content_blocks=[{"type": "text", "text": "hi"}],
stop_reason="end_turn",
usage=at.usage_chat_to_anthropic({"prompt_tokens": 2, "completion_tokens": 1}))
events = _parse_sse(at.message_object_to_sse(msg).decode())
types = [e[0] for e in events]
assert types[0] == "message_start"
assert types[-1] == "message_stop"
text = "".join(d["delta"]["text"] for t, d in events
if t == "content_block_delta" and d["delta"]["type"] == "text_delta")
assert text == "hi"
# ──────────────────────────────────────────────────────────────────────────────
# Route-level tests
# ──────────────────────────────────────────────────────────────────────────────
@contextmanager
def _enter(*cms):
with ExitStack() as stack:
for cm in cms:
stack.enter_context(cm)
yield
def _fake_completion(content="hello", usage=(3, 5), reasoning=None, tool_calls=None,
finish_reason="stop"):
md = {"role": "assistant", "content": content}
if reasoning is not None:
md["reasoning_content"] = reasoning
if tool_calls is not None:
md["tool_calls"] = tool_calls
msg = MagicMock()
msg.model_dump.return_value = md
usage_obj = MagicMock()
usage_obj.model_dump.return_value = {
"prompt_tokens": usage[0], "completion_tokens": usage[1], "total_tokens": sum(usage)}
return NS(choices=[NS(message=msg, finish_reason=finish_reason)], usage=usage_obj)
def _patch_backend(native=False, endpoint="http://ollama:11434", cache=None):
return (
patch.object(api_messages, "choose_endpoint",
AsyncMock(return_value=(endpoint, "test-model:latest"))),
patch.object(api_messages, "decrement_usage", AsyncMock()),
patch.object(api_messages, "is_anthropic_endpoint", return_value=native),
patch.object(api_messages, "_make_openai_client", return_value=MagicMock()),
patch.object(api_messages, "get_llm_cache", return_value=cache),
)
class TestTranslatedRoute:
async def test_nonstream(self, client):
with _enter(*_patch_backend(native=False),
patch.object(api_messages, "create_chat_with_retries",
AsyncMock(return_value=_fake_completion("hello world")))):
resp = await client.post("/v1/messages",
json={"model": "test-model", "max_tokens": 100,
"messages": [{"role": "user", "content": "hi"}]})
assert resp.status_code == 200
body = resp.json()
assert body["type"] == "message"
assert body["role"] == "assistant"
assert body["content"] == [{"type": "text", "text": "hello world"}]
assert body["stop_reason"] == "end_turn"
assert body["usage"]["input_tokens"] == 3 and body["usage"]["output_tokens"] == 5
assert body["id"].startswith("msg_")
async def test_missing_max_tokens_400(self, client):
with _enter(*_patch_backend(native=False)):
resp = await client.post("/v1/messages",
json={"model": "m", "messages": [{"role": "user", "content": "hi"}]})
assert resp.status_code == 400
async def test_nonstream_tool_use(self, client):
tc = [{"id": "c1", "function": {"name": "get", "arguments": '{"a":1}'}}]
with _enter(*_patch_backend(native=False),
patch.object(api_messages, "create_chat_with_retries",
AsyncMock(return_value=_fake_completion(
content=None, tool_calls=tc, finish_reason="tool_calls")))):
resp = await client.post("/v1/messages",
json={"model": "m", "max_tokens": 50,
"messages": [{"role": "user", "content": "call it"}]})
body = resp.json()
assert body["stop_reason"] == "tool_use"
assert body["content"][0] == {"type": "tool_use", "id": "c1", "name": "get", "input": {"a": 1}}
async def test_stream_event_sequence(self, client):
async def _text():
yield _chunk(content="Hi", finish_reason="stop")
yield _usage_chunk(3, 2)
with _enter(*_patch_backend(native=False),
patch.object(api_messages, "create_chat_with_retries",
AsyncMock(return_value=_text()))):
resp = await client.post("/v1/messages",
json={"model": "m", "max_tokens": 50, "stream": True,
"messages": [{"role": "user", "content": "hi"}]})
assert resp.headers["content-type"].startswith("text/event-stream")
events = _parse_sse(resp.content.decode())
types = [e[0] for e in events]
assert types[0] == "message_start" and types[-1] == "message_stop"
text = "".join(d["delta"]["text"] for t, d in events
if t == "content_block_delta" and d["delta"]["type"] == "text_delta")
assert text == "Hi"
async def test_thinking_passed_as_reasoning_effort(self, client):
captured = {}
async def _spy(oclient, send_params, endpoint, model, tracking_model):
captured.update(send_params)
return _fake_completion("ok")
with _enter(*_patch_backend(native=False),
patch.object(api_messages, "create_chat_with_retries", _spy)):
await client.post("/v1/messages",
json={"model": "m", "max_tokens": 50,
"thinking": {"type": "enabled", "budget_tokens": 20000},
"messages": [{"role": "user", "content": "hi"}]})
assert captured["reasoning_effort"] == "high"
class TestCacheRoute:
async def test_hit_reports_cache_read(self, client):
stored = at.build_message_object(
message_id="msg_old", model="m",
content_blocks=[{"type": "text", "text": "cached"}], stop_reason="end_turn",
usage=at.usage_chat_to_anthropic({"prompt_tokens": 9, "completion_tokens": 4}))
fake_cache = MagicMock()
fake_cache.get_chat = AsyncMock(return_value=orjson.dumps(stored))
with _enter(*_patch_backend(native=False, cache=fake_cache)):
resp = await client.post("/v1/messages",
json={"model": "m", "max_tokens": 50, "nomyo": {"cache": True},
"messages": [{"role": "user", "content": "hi"}]})
body = resp.json()
assert body["content"] == [{"type": "text", "text": "cached"}]
assert body["usage"]["cache_read_input_tokens"] == 9
assert body["usage"]["input_tokens"] == 0
assert body["id"].startswith("msg_") and body["id"] != "msg_old"
class TestNativeRoute:
def _fake_client(self, *, post_return=None, stream_frames=None, status=200):
client = MagicMock()
if post_return is not None:
resp = MagicMock()
resp.status_code = status
resp.json.return_value = post_return
client.post = AsyncMock(return_value=resp)
if stream_frames is not None:
class _Stream:
async def __aenter__(self_):
return self_
async def __aexit__(self_, *a):
return False
async def aiter_bytes(self_):
for f in stream_frames:
yield f
client.stream = MagicMock(return_value=_Stream())
return client
async def test_nonstream_passthrough(self, client):
upstream = {"id": "msg_upstream", "type": "message", "role": "assistant",
"content": [{"type": "text", "text": "native hi"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 2, "output_tokens": 3}}
fake = self._fake_client(post_return=upstream)
with _enter(*_patch_backend(native=True, endpoint="https://api.anthropic.com"),
patch.object(api_messages, "_anthropic_http_client", return_value=fake)):
resp = await client.post("/v1/messages",
json={"model": "claude-x", "max_tokens": 50,
"messages": [{"role": "user", "content": "hi"}]})
assert resp.status_code == 200
assert resp.json()["content"][0]["text"] == "native hi"
# request forwarded verbatim with stream disabled, nomyo stripped
sent = fake.post.call_args.kwargs["json"]
assert sent["stream"] is False and "nomyo" not in sent
headers = fake.post.call_args.kwargs["headers"]
assert headers["x-api-key"] and headers["anthropic-version"]
async def test_stream_passthrough(self, client):
frames = [
b'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":5}}}\n\n',
b'event: message_delta\ndata: {"type":"message_delta","usage":{"output_tokens":7}}\n\n',
b'event: message_stop\ndata: {"type":"message_stop"}\n\n',
]
fake = self._fake_client(stream_frames=frames)
tracked = []
with _enter(*_patch_backend(native=True, endpoint="https://api.anthropic.com"),
patch.object(api_messages, "_anthropic_http_client", return_value=fake),
patch.object(api_messages, "_track",
AsyncMock(side_effect=lambda *a: tracked.append(a)))):
resp = await client.post("/v1/messages",
json={"model": "claude-x", "max_tokens": 50, "stream": True,
"messages": [{"role": "user", "content": "hi"}]})
body = resp.content.decode()
assert "message_start" in body and "message_stop" in body
# usage parsed out of the proxied stream for token tracking
assert tracked and tracked[0][2] == 5 and tracked[0][3] == 7
class TestCountTokens:
async def test_local_estimate(self, client):
with _enter(patch.object(api_messages, "choose_endpoint",
AsyncMock(return_value=("http://ollama:11434", "m"))),
patch.object(api_messages, "is_anthropic_endpoint", return_value=False)):
resp = await client.post("/v1/messages/count_tokens",
json={"model": "m",
"messages": [{"role": "user", "content": "count me"}]})
assert resp.status_code == 200
assert isinstance(resp.json()["input_tokens"], int)
assert resp.json()["input_tokens"] > 0