@wendellresearch-ai/benchwarmers 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1584 @@
1
+ """Other APIs' shapes, translated to and from the OpenAI chat shape the bench speaks.
2
+
3
+ python scripts/test_formats.py # the round trips, and every route against fake seats
4
+
5
+ The gateway answers clients that speak OpenAI chat completions, OpenAI's
6
+ legacy completions, OpenAI Responses, Anthropic Messages, Ollama and Gemini.
7
+ Each of those is translated here into OpenAI-style messages (images as
8
+ `image_url` parts with data: or http(s) URLs, so the gateway's vision
9
+ filtering sees them), and the panel's answer back into the format's own
10
+ response and streaming events. Pure functions, stdlib only, no I/O: the
11
+ gateway does the HTTP.
12
+
13
+ The same shapes run the other way for the built-in client, which calls an
14
+ endpoint whose `api` is `anthropic` or `gemini` with them (Ollama's and the
15
+ Responses API's outbound halves are not wired; metrics/client.py speaks
16
+ chat completions and ChatGPT's Responses flow itself):
17
+
18
+ inbound, the gateway answering a client outbound, the client calling an API
19
+ <fmt>_ask(body) native request -> Ask <fmt>_request(messages, ...) OpenAI messages -> native request
20
+ <fmt>_reply(text...) an answer -> native reply <fmt>_text(reply) native reply -> its text
21
+ <Fmt>Stream an answer as it comes -> <fmt>_delta(event) one native stream event -> its text
22
+ native stream bytes
23
+ <fmt>_error(status, message) -> native error body
24
+
25
+ An Ask is a dict: `model` (as the client named it), `messages` (OpenAI
26
+ style, tool calls and results included), `stream`, `options` (max_tokens,
27
+ temperature, top_p, stop, when given), the bench's own `with` and `reply`,
28
+ read from any format's body (an SDK's extra_body), `upstream` (the request
29
+ as OpenAI chat completions fields beyond messages: tools, tool_choice,
30
+ parallel_tool_calls, response_format and the options) and `agentic` (it
31
+ carries tools, tool calls or tool results). A panel reads `options` but does
32
+ not pass them on: each seat uses its own call options
33
+ (config/model-options.json). Route mode passes `upstream` on.
34
+
35
+ ROUTE MODE (one member, tools and all)
36
+ --------------------------------------
37
+ `Turn` reads the member's OpenAI chunks; each format's route stream
38
+ (ROUTE) writes them in its own shape and route_reply() the whole answer.
39
+ OpenAI chat completions relays the chunks as they came, tool-call deltas
40
+ exactly. The other formats stream the text as it comes and each tool call
41
+ whole once the turn ends, so a call whose deltas interleave with another's
42
+ still arrives intact. Tool call ids pass through both ways; Gemini and
43
+ Ollama carry none, so they are numbered per request (`_Ids`), and a result
44
+ is matched to the oldest open call of its name.
45
+
46
+ WHAT IS REFUSED, NOT DROPPED
47
+ ----------------------------
48
+ A panel refuses an agentic Ask (the gateway answers 400 with TOOLS): its
49
+ reply is several models' text, not a tool call, and an agent that waits for
50
+ one would hang or loop. Every format refuses what would be lost on the way:
51
+ a PDF or audio part, an uploaded file id, a stored previous response, a
52
+ tool that only its own vendor runs (Anthropic's web search, OpenAI's
53
+ hosted tools, Gemini's Google Search and code execution). A model's own
54
+ reasoning from an earlier turn (Anthropic thinking blocks, Responses
55
+ reasoning items, Gemini thought parts) is dropped: each model reasons for
56
+ itself, and none could read another's signed thinking. Structured-output
57
+ settings (Ollama `format`, Gemini's responseSchema, Responses `text.format`)
58
+ reach route mode as response_format, and are ignored by a panel, which
59
+ answers in markdown.
60
+
61
+ Token counts in replies are estimates (characters / 4 plus 10%), as the
62
+ gateway's size check makes; durations are real.
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import base64
68
+ import binascii
69
+ import datetime as dt
70
+ import hashlib
71
+ import json
72
+ import mimetypes
73
+ import secrets
74
+ import time
75
+ import urllib.parse
76
+ from typing import Callable, NamedTuple
77
+
78
+ TOOLS = ("this request carries tools, tool calls or tool results, and a panel answers with several models'"
79
+ " text, not a tool call. Ask for the collection's route mode instead (bench/<collection>:route, or"
80
+ ' "bench": {"reply": "route"}), which hands the request to one member, tools and all.')
81
+ REPLIES = ("all", "merged", "route") # bench_collections.REPLIES; not imported, so this file stands alone
82
+ LEAD = ("system", "developer") # newer OpenAI SDKs send "developer" where older ones send "system"
83
+ # The Ollama API version whose shapes these follow. Clients check it to
84
+ # decide which fields to send; a lower one would have them hold back.
85
+ OLLAMA_VERSION = "0.12.0"
86
+ # Anthropic requires max_tokens; this is what the outbound request asks for
87
+ # when the caller set none (the seat's own option, in the built-in client).
88
+ ANTHROPIC_MAX_TOKENS = 8192
89
+ ANTHROPIC_ERRORS = {400: "invalid_request_error", 401: "authentication_error", 403: "permission_error",
90
+ 404: "not_found_error", 413: "request_too_large", 429: "rate_limit_error",
91
+ 503: "overloaded_error"}
92
+ GEMINI_STATUS = {400: "INVALID_ARGUMENT", 401: "UNAUTHENTICATED", 403: "PERMISSION_DENIED", 404: "NOT_FOUND",
93
+ 413: "INVALID_ARGUMENT", 415: "INVALID_ARGUMENT", 429: "RESOURCE_EXHAUSTED",
94
+ 503: "UNAVAILABLE"}
95
+
96
+
97
+ class Unsupported(ValueError):
98
+ """A request that cannot be put to a panel without losing part of it:
99
+ answered 400 with the reason, never silently trimmed."""
100
+
101
+
102
+ class UpstreamError(RuntimeError):
103
+ """An error a native API sent in place of an answer (outbound)."""
104
+
105
+
106
+ def estimate(text: str) -> int:
107
+ """Tokens in `text`: characters / 4 plus 10%, which errs high
108
+ (gateway.estimate_tokens says why). At least 1 for any text: a count
109
+ of 0 for "hi" reads as nothing sent."""
110
+ return max(1, int(len(text) / 4 * 1.1)) if text else 0
111
+
112
+
113
+ # ---- the parts every format shares ---------------------------------------
114
+
115
+ def image(url: str) -> dict:
116
+ """An OpenAI image part."""
117
+ return {"type": "image_url", "image_url": {"url": url}}
118
+
119
+
120
+ def text_part(text) -> dict:
121
+ return {"type": "text", "text": str(text or "")}
122
+
123
+
124
+ def data_url(mime: str, data: str) -> str:
125
+ return f"data:{mime};base64,{data}"
126
+
127
+
128
+ def split_data_url(url: str) -> tuple[str, str] | None:
129
+ """("image/png", "<base64>") for a base64 data: URL, else None."""
130
+ if url[:5].lower() != "data:":
131
+ return None
132
+ head, sep, data = url[5:].partition(",")
133
+ mime, _, enc = head.partition(";")
134
+ return (mime or "application/octet-stream", data) if sep and enc.lower() == "base64" else None
135
+
136
+
137
+ def sniff(b64: str) -> str:
138
+ """An image's type from its first bytes, for Ollama, which sends bare
139
+ base64 with no type. PNG when it cannot tell: every model reads one."""
140
+ try:
141
+ head = base64.b64decode(b64[:24] + "=" * (-len(b64[:24]) % 4), validate=False)
142
+ except (binascii.Error, ValueError):
143
+ return "image/png"
144
+ for magic, mime in ((b"\x89PNG", "image/png"), (b"\xff\xd8\xff", "image/jpeg"), (b"GIF8", "image/gif"),
145
+ (b"RIFF", "image/webp"), (b"BM", "image/bmp")):
146
+ if head.startswith(magic):
147
+ return mime
148
+ return "image/png"
149
+
150
+
151
+ def _image_mime(mime: str, where: str) -> str:
152
+ if not str(mime or "").lower().startswith("image/"):
153
+ raise Unsupported(f"{where} is {mime or 'of no type'}; only images can be passed to a panel")
154
+ return mime
155
+
156
+
157
+ def _content(parts: list[dict]) -> str | list[dict]:
158
+ """Text alone as one string, as most clients send it; with an image, the parts."""
159
+ if all(p["type"] == "text" for p in parts):
160
+ return "\n\n".join(p["text"] for p in parts)
161
+ return parts
162
+
163
+
164
+ def parts_of(content) -> list[dict]:
165
+ """OpenAI message content as a list of text and image_url parts."""
166
+ if content is None:
167
+ return []
168
+ if isinstance(content, str):
169
+ return [text_part(content)] if content else []
170
+ out = []
171
+ for p in content if isinstance(content, list) else ():
172
+ if not isinstance(p, dict):
173
+ continue
174
+ if p.get("type") == "image_url":
175
+ u = p.get("image_url")
176
+ out.append(image(u.get("url", "") if isinstance(u, dict) else str(u or "")))
177
+ elif p.get("type") in ("text", "input_text", "output_text"):
178
+ out.append(text_part(p.get("text")))
179
+ return out
180
+
181
+
182
+ def text_of(content) -> str:
183
+ return "\n\n".join(p["text"] for p in parts_of(content) if p["type"] == "text")
184
+
185
+
186
+ def _image_url(part: dict) -> str:
187
+ return part["image_url"]["url"]
188
+
189
+
190
+ def _function(name, description, parameters) -> dict:
191
+ """One OpenAI tool definition."""
192
+ if not isinstance(name, str) or not name:
193
+ raise Unsupported("every tool needs a name")
194
+ return {"type": "function", "function": {"name": name, "description": str(description or ""),
195
+ "parameters": parameters or {"type": "object", "properties": {}}}}
196
+
197
+
198
+ def _call(cid, name, arguments) -> dict:
199
+ """One OpenAI tool call in an assistant message; `arguments` a JSON string or an object."""
200
+ return {"id": str(cid or _new_id("call_")), "type": "function",
201
+ "function": {"name": str(name or ""),
202
+ "arguments": arguments if isinstance(arguments, str) else json.dumps(arguments or {})}}
203
+
204
+
205
+ def _assistant(text, calls: list[dict]) -> dict:
206
+ """An assistant message, with its tool calls when it made any."""
207
+ if not calls:
208
+ return {"role": "assistant", "content": text}
209
+ return {"role": "assistant", "content": text_of(text) or None, "tool_calls": calls}
210
+
211
+
212
+ class _Ids:
213
+ """Tool call ids for formats whose calls carry none (Gemini, Ollama):
214
+ numbered in order through one request, and a result given to the oldest
215
+ open call of its name (or the oldest open call, when it names none). The
216
+ model never sees a past turn's ids from its own reply, so any that match
217
+ within the request will do."""
218
+
219
+ def __init__(self):
220
+ self.n, self.open = 0, []
221
+
222
+ def call(self, name, given=None) -> str:
223
+ cid = str(given or f"call_{self.n}")
224
+ self.n += 1
225
+ self.open.append((name, cid))
226
+ return cid
227
+
228
+ def result(self, name=None, given=None) -> str:
229
+ for i, (n, cid) in enumerate(self.open):
230
+ if (given and cid == given) or (not given and (not name or n == name)):
231
+ del self.open[i]
232
+ return cid
233
+ return str(given or f"call_{self.n}")
234
+
235
+
236
+ def _ask(body: dict, model, messages: list[dict], stream, options: dict, upstream: dict | None = None,
237
+ **extra) -> dict:
238
+ """The Ask every format's request becomes, with the bench's own fields
239
+ checked as POST /v1/chat/completions always checked them. `upstream`:
240
+ the request's OpenAI chat fields beyond messages, for route mode; the
241
+ options when not given."""
242
+ if not isinstance(model, str):
243
+ raise Unsupported('"model" must be a collection name such as bench/core')
244
+ nominated, opts = body.get("with") or [], body.get("bench") or {}
245
+ if not (isinstance(nominated, list) and all(isinstance(a, str) for a in nominated)):
246
+ raise Unsupported('"with" must be a list of model aliases')
247
+ if not isinstance(opts, dict) or opts.get("reply", "all") not in REPLIES:
248
+ raise Unsupported('"bench" must be an object whose "reply" is "all", "merged" or "route"')
249
+ stop = options.get("stop")
250
+ if isinstance(stop, str):
251
+ options = {**options, "stop": [stop]}
252
+ options = {k: v for k, v in options.items() if v is not None}
253
+ upstream = {k: v for k, v in (options if upstream is None else upstream).items() if v is not None}
254
+ agentic = bool(upstream.get("tools")) or any(m.get("role") == "tool" or m.get("tool_calls") for m in messages)
255
+ return {"model": model, "messages": messages, "stream": bool(stream), "options": options,
256
+ "with": nominated, "reply": opts.get("reply") or "", "upstream": upstream, "agentic": agentic, **extra}
257
+
258
+
259
+ def _messages(value, what: str = '"messages"') -> list[dict]:
260
+ if not (isinstance(value, list) and all(isinstance(m, dict) for m in value)):
261
+ raise Unsupported(f"{what} must be a list of message objects")
262
+ return value
263
+
264
+
265
+ def _now() -> int:
266
+ return int(time.time())
267
+
268
+
269
+ def _new_id(prefix: str) -> str:
270
+ return prefix + secrets.token_hex(12)
271
+
272
+
273
+ def sse(data: dict | str, event: str = "") -> bytes:
274
+ """One server-sent event."""
275
+ payload = data if isinstance(data, str) else json.dumps(data, ensure_ascii=False)
276
+ return (f"event: {event}\n" if event else "").encode() + f"data: {payload}\n\n".encode()
277
+
278
+
279
+ class Stream:
280
+ """An answer sent as it comes, in one format's stream shape. Each method
281
+ returns the bytes to write: start() right after the 200, text() for each
282
+ piece, then end() or error(), and keepalive() while waiting, so a client
283
+ or proxy with an idle timeout keeps the connection."""
284
+ content_type = "text/event-stream"
285
+
286
+ def __init__(self, model: str, input_tokens: int = 0, **_):
287
+ self.model, self.input_tokens, self.said = model, input_tokens, []
288
+ self.started, self.turn = time.monotonic(), Turn()
289
+
290
+ def start(self) -> bytes:
291
+ return b""
292
+
293
+ def text(self, text: str) -> bytes:
294
+ raise NotImplementedError
295
+
296
+ def end(self, bench: dict | None = None) -> bytes:
297
+ raise NotImplementedError
298
+
299
+ def error(self, status: int, message: str) -> bytes:
300
+ raise NotImplementedError
301
+
302
+ def keepalive(self) -> bytes:
303
+ return b": keep-alive\n\n" # an SSE comment: every SSE parser skips it
304
+
305
+ def chunk(self, chunk: dict) -> bytes:
306
+ """Route mode: one of the member's OpenAI chunks, its text sent on as
307
+ it comes; the rest (tool calls, usage) is kept for end()."""
308
+ text, _ = self.turn.feed(chunk)
309
+ return self.text(text) if text else b""
310
+
311
+ def output_tokens(self) -> int:
312
+ return estimate("".join(self.said))
313
+
314
+
315
+ # ---- OpenAI chat completions -----------------------------------------------
316
+
317
+ CHAT_OWN = ("model", "messages", "stream", "stream_options", "with", "bench")
318
+
319
+
320
+ def chat_ask(body: dict) -> dict:
321
+ """POST /v1/chat/completions. The messages are already OpenAI's; the
322
+ gateway's own image check reads them as they are, and route mode passes
323
+ every other field on as it came."""
324
+ messages = _messages(body.get("messages"))
325
+ if body.get("functions") or body.get("function_call") or any(
326
+ m.get("role") == "function" or m.get("function_call") for m in messages):
327
+ raise Unsupported('the legacy "functions" fields are not supported; send "tools" and "tool_calls"')
328
+ return _ask(body, body.get("model") or "", messages, body.get("stream"),
329
+ {"max_tokens": body.get("max_completion_tokens") or body.get("max_tokens"),
330
+ "temperature": body.get("temperature"), "top_p": body.get("top_p"), "stop": body.get("stop")},
331
+ upstream={k: v for k, v in body.items() if k not in CHAT_OWN})
332
+
333
+
334
+ def chat_reply(text: str, model: str, bench: dict | None = None, usage=(0, 0), seconds: float = 0, **_) -> dict:
335
+ """A chat.completion, as the gateway has always answered: no usage, the
336
+ bench beside the OpenAI fields (clients ignore keys they do not know)."""
337
+ return {"id": _new_id("chatcmpl-"), "object": "chat.completion", "created": _now(), "model": model,
338
+ "choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": text}}],
339
+ "bench": bench}
340
+
341
+
342
+ def chat_error(status: int, message: str) -> dict:
343
+ return {"error": {"message": message, "type": "bench_gateway", "code": status}}
344
+
345
+
346
+ class ChatStream(Stream):
347
+ """chat.completion.chunk events, then `data: [DONE]`. The bench rides on
348
+ the last chunk, the one with the finish_reason."""
349
+
350
+ def __init__(self, model: str, input_tokens: int = 0, **kw):
351
+ super().__init__(model, input_tokens, **kw)
352
+ self.id, self.created = _new_id("chatcmpl-"), _now()
353
+
354
+ def _chunk(self, delta: dict, finish=None, **extra) -> bytes:
355
+ return sse({"id": self.id, "object": "chat.completion.chunk", "created": self.created, "model": self.model,
356
+ "choices": [{"index": 0, "delta": delta, "finish_reason": finish}], **extra})
357
+
358
+ def start(self) -> bytes:
359
+ return self._chunk({"role": "assistant", "content": ""})
360
+
361
+ def text(self, text: str) -> bytes:
362
+ self.said.append(text)
363
+ return self._chunk({"content": text})
364
+
365
+ def end(self, bench=None) -> bytes:
366
+ return self._chunk({}, "stop", **({"bench": bench} if bench is not None else {})) + b"data: [DONE]\n\n"
367
+
368
+ def error(self, status: int, message: str) -> bytes:
369
+ # The OpenAI SDKs raise on a chunk whose data holds "error".
370
+ return sse(chat_error(status, message)) + b"data: [DONE]\n\n"
371
+
372
+
373
+ # ---- OpenAI legacy completions ---------------------------------------------
374
+
375
+ def completion_ask(body: dict) -> dict:
376
+ """POST /v1/completions: one prompt, as a user message."""
377
+ prompt = body.get("prompt")
378
+ if isinstance(prompt, list) and len(prompt) == 1 and isinstance(prompt[0], str):
379
+ prompt = prompt[0]
380
+ if not isinstance(prompt, str):
381
+ raise Unsupported('"prompt" must be one string (a list of prompts, or of token ids, is not supported)')
382
+ return _ask(body, body.get("model") or "", [{"role": "user", "content": prompt}], body.get("stream"),
383
+ {"max_tokens": body.get("max_tokens"), "temperature": body.get("temperature"),
384
+ "top_p": body.get("top_p"), "stop": body.get("stop")})
385
+
386
+
387
+ def completion_reply(text: str, model: str, bench=None, usage=(0, 0), seconds: float = 0, **_) -> dict:
388
+ return {"id": _new_id("cmpl-"), "object": "text_completion", "created": _now(), "model": model,
389
+ "choices": [{"text": text, "index": 0, "logprobs": None, "finish_reason": "stop"}],
390
+ "usage": {"prompt_tokens": usage[0], "completion_tokens": usage[1], "total_tokens": sum(usage)},
391
+ "bench": bench}
392
+
393
+
394
+ class CompletionStream(ChatStream):
395
+ def _chunk(self, delta: dict, finish=None, **extra) -> bytes:
396
+ return sse({"id": self.id, "object": "text_completion", "created": self.created, "model": self.model,
397
+ "choices": [{"text": delta.get("content", ""), "index": 0, "logprobs": None,
398
+ "finish_reason": finish}], **extra})
399
+
400
+ def start(self) -> bytes:
401
+ return b""
402
+
403
+
404
+ # ---- Anthropic Messages ----------------------------------------------------
405
+
406
+ def _anthropic_blocks(content, where: str) -> str | list[dict]:
407
+ if isinstance(content, str):
408
+ return content
409
+ if not isinstance(content, list):
410
+ raise Unsupported(f"{where}: content must be a string or a list of content blocks")
411
+ parts = []
412
+ for b in content:
413
+ kind = b.get("type") if isinstance(b, dict) else None
414
+ if kind == "text":
415
+ parts.append(text_part(b.get("text")))
416
+ elif kind == "image":
417
+ src = b.get("source") if isinstance(b.get("source"), dict) else {}
418
+ if src.get("type") == "base64":
419
+ data = str(src.get("data") or "")
420
+ parts.append(image(data_url(_image_mime(src.get("media_type") or sniff(data), "an image block"), data)))
421
+ elif src.get("type") == "url":
422
+ parts.append(image(str(src.get("url") or "")))
423
+ else:
424
+ raise Unsupported(f"an image source of type {src.get('type')!r} cannot be passed on;"
425
+ " send it as base64 or a url")
426
+ elif isinstance(kind, str) and kind.endswith(("tool_use", "tool_result")):
427
+ # server_tool_use, web_search_tool_result, mcp_tool_use...: what
428
+ # Anthropic's own servers ran, which no other model can continue.
429
+ raise Unsupported(f"a {kind!r} block comes from a tool Anthropic runs on its own servers, which"
430
+ " no other model can take up; send your own tools' tool_use and tool_result")
431
+ elif kind in ("thinking", "redacted_thinking"):
432
+ continue # a model's own reasoning from an earlier turn
433
+ else:
434
+ raise Unsupported(f"a {kind!r} content block is not supported here; send text and images")
435
+ return _content(parts)
436
+
437
+
438
+ def _anthropic_message(m: dict) -> list[dict]:
439
+ """One Anthropic message as OpenAI messages: an assistant's tool_use
440
+ blocks become its tool_calls; a user's tool_result blocks become tool
441
+ messages, ahead of whatever else it said (OpenAI wants each result right
442
+ after the call). An image in a result goes in that user message after
443
+ them, since a tool message carries text only; is_error marks the result."""
444
+ role, content = m["role"], m.get("content")
445
+ tool = [b for b in content if isinstance(b, dict) and b.get("type") in ("tool_use", "tool_result")] \
446
+ if isinstance(content, list) else []
447
+ if not tool:
448
+ return [{"role": role, "content": _anthropic_blocks(content, role)}]
449
+ rest = [b for b in content if not (isinstance(b, dict) and b.get("type") in ("tool_use", "tool_result"))]
450
+ said = _anthropic_blocks(rest, role) if rest else ""
451
+ wrong = "tool_result" if role == "assistant" else "tool_use"
452
+ if any(b["type"] == wrong for b in tool):
453
+ raise Unsupported(f"a {wrong} block cannot be in a{'n' * (role == 'assistant')} {role} message")
454
+ if role == "assistant":
455
+ return [_assistant(said, [_call(b.get("id"), b.get("name"), b.get("input") or {}) for b in tool])]
456
+ out, pictures = [], []
457
+ for b in tool:
458
+ parts = parts_of(_anthropic_blocks(b.get("content") or "", "a tool_result"))
459
+ pictures += [p for p in parts if p["type"] == "image_url"]
460
+ text = "\n\n".join(p["text"] for p in parts if p["type"] == "text")
461
+ out.append({"role": "tool", "tool_call_id": str(b.get("tool_use_id") or ""),
462
+ "content": ("Error: " if b.get("is_error") else "") + text})
463
+ if (more := parts_of(said) + pictures):
464
+ out.append({"role": "user", "content": _content(more)})
465
+ return out
466
+
467
+
468
+ def _anthropic_tools(body: dict) -> dict:
469
+ """Anthropic's tools and tool_choice as OpenAI's."""
470
+ tools = body.get("tools") or []
471
+ if not isinstance(tools, list):
472
+ raise Unsupported('"tools" must be a list')
473
+ out = []
474
+ for t in tools:
475
+ kind = t.get("type") if isinstance(t, dict) else t
476
+ if not isinstance(t, dict) or kind not in (None, "custom"):
477
+ raise Unsupported(f"the {kind!r} tool is one Anthropic runs on its own servers, which no other"
478
+ " model can; send only your own tools (name, description, input_schema)")
479
+ out.append(_function(t.get("name"), t.get("description"), t.get("input_schema")))
480
+ up = {"tools": out} if out else {}
481
+ choice = body.get("tool_choice")
482
+ if isinstance(choice, dict) and out:
483
+ kind = choice.get("type")
484
+ up["tool_choice"] = ({"type": "function", "function": {"name": choice.get("name")}} if kind == "tool"
485
+ else {"any": "required", "none": "none"}.get(kind, "auto"))
486
+ if choice.get("disable_parallel_tool_use"):
487
+ up["parallel_tool_calls"] = False
488
+ return up
489
+
490
+
491
+ def anthropic_ask(body: dict) -> dict:
492
+ """POST /v1/messages. Parameters with no OpenAI counterpart (top_k,
493
+ thinking, metadata, service_tier) are not passed on."""
494
+ out = []
495
+ if body.get("system"):
496
+ out.append({"role": "system", "content": text_of(_anthropic_blocks(body["system"], "system"))})
497
+ for m in _messages(body.get("messages")):
498
+ if m.get("role") not in ("user", "assistant"):
499
+ raise Unsupported(f'a message\'s "role" must be "user" or "assistant", not {m.get("role")!r}')
500
+ out += _anthropic_message(m)
501
+ options = {"max_tokens": body.get("max_tokens"), "temperature": body.get("temperature"),
502
+ "top_p": body.get("top_p"), "stop": body.get("stop_sequences")}
503
+ return _ask(body, body.get("model") or "", out, body.get("stream"), options,
504
+ {**options, **_anthropic_tools(body)})
505
+
506
+
507
+ def anthropic_reply(text: str, model: str, bench=None, usage=(0, 0), seconds: float = 0, **_) -> dict:
508
+ return {"id": _new_id("msg_"), "type": "message", "role": "assistant", "model": model,
509
+ "content": [{"type": "text", "text": text}], "stop_reason": "end_turn", "stop_sequence": None,
510
+ "usage": {"input_tokens": usage[0], "output_tokens": usage[1]}, "bench": bench}
511
+
512
+
513
+ def anthropic_error(status: int, message: str) -> dict:
514
+ return {"type": "error", "error": {"type": ANTHROPIC_ERRORS.get(status, "api_error"), "message": message}}
515
+
516
+
517
+ class AnthropicStream(Stream):
518
+ """message_start, one text block's deltas, message_delta (the bench
519
+ beside its usage), message_stop; `ping` while waiting, as the API does."""
520
+
521
+ def _head(self) -> bytes:
522
+ message = {"id": _new_id("msg_"), "type": "message", "role": "assistant", "model": self.model,
523
+ "content": [], "stop_reason": None, "stop_sequence": None,
524
+ "usage": {"input_tokens": self.input_tokens, "output_tokens": 0}}
525
+ return sse({"type": "message_start", "message": message}, "message_start")
526
+
527
+ @staticmethod
528
+ def _block(index: int, block: dict) -> bytes:
529
+ return sse({"type": "content_block_start", "index": index, "content_block": block}, "content_block_start")
530
+
531
+ def start(self) -> bytes:
532
+ return self._head() + self._block(0, {"type": "text", "text": ""})
533
+
534
+ def text(self, text: str) -> bytes:
535
+ self.said.append(text)
536
+ return sse({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text}},
537
+ "content_block_delta")
538
+
539
+ def end(self, bench=None) -> bytes:
540
+ delta = {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None},
541
+ "usage": {"output_tokens": self.output_tokens()}, **({"bench": bench} if bench is not None else {})}
542
+ return (sse({"type": "content_block_stop", "index": 0}, "content_block_stop")
543
+ + sse(delta, "message_delta") + sse({"type": "message_stop"}, "message_stop"))
544
+
545
+ def error(self, status: int, message: str) -> bytes:
546
+ return sse(anthropic_error(status, message), "error")
547
+
548
+ def keepalive(self) -> bytes:
549
+ return sse({"type": "ping"}, "ping")
550
+
551
+
552
+ def _anthropic_content(content) -> str | list[dict]:
553
+ if isinstance(content, str):
554
+ return content
555
+ out = []
556
+ for p in parts_of(content):
557
+ if p["type"] == "text":
558
+ out.append({"type": "text", "text": p["text"]})
559
+ elif (data := split_data_url(_image_url(p))):
560
+ out.append({"type": "image", "source": {"type": "base64", "media_type": data[0], "data": data[1]}})
561
+ else:
562
+ out.append({"type": "image", "source": {"type": "url", "url": _image_url(p)}})
563
+ return out
564
+
565
+
566
+ def _turns(messages: list[dict]) -> tuple[str, list[dict]]:
567
+ """(system text, the other turns) of OpenAI messages, for APIs that keep
568
+ the system text apart. ponytail: refuses tool turns; the built-in client
569
+ sends native APIs a panel seat's text, and route mode proxies chat
570
+ completions only. Translate them when a native endpoint is routed to."""
571
+ if any(m.get("role") in ("tool", "function") or m.get("tool_calls") for m in messages):
572
+ raise Unsupported("tool calls and results are sent to chat completions only, not a native API")
573
+ system = "\n\n".join(text_of(m.get("content")) for m in messages if m.get("role") in LEAD)
574
+ return system, [m for m in messages if m.get("role") not in LEAD]
575
+
576
+
577
+ def anthropic_request(messages: list[dict], model: str, options: dict | None = None, stream: bool = False) -> dict:
578
+ """OpenAI messages as an Anthropic Messages request (POST /v1/messages)."""
579
+ options = options or {}
580
+ system, turns = _turns(messages)
581
+ body = {"model": model, "max_tokens": options.get("max_tokens") or ANTHROPIC_MAX_TOKENS,
582
+ "messages": [{"role": "assistant" if m.get("role") == "assistant" else "user",
583
+ "content": _anthropic_content(m.get("content"))} for m in turns]}
584
+ if system:
585
+ body["system"] = system
586
+ for ours, theirs in (("temperature", "temperature"), ("top_p", "top_p"), ("stop", "stop_sequences")):
587
+ if options.get(ours) is not None:
588
+ body[theirs] = options[ours]
589
+ return {**body, **({"stream": True} if stream else {})}
590
+
591
+
592
+ def anthropic_text(reply: dict) -> str:
593
+ """The text of an Anthropic Messages reply; UpstreamError for an error."""
594
+ if reply.get("type") == "error":
595
+ raise UpstreamError(str((reply.get("error") or {}).get("message") or reply))
596
+ return "".join(b.get("text", "") for b in reply.get("content") or [] if b.get("type") == "text")
597
+
598
+
599
+ def anthropic_delta(event: dict) -> str:
600
+ """The text one Anthropic stream event adds ("" for the others)."""
601
+ if event.get("type") == "error":
602
+ raise UpstreamError(str((event.get("error") or {}).get("message") or event))
603
+ delta = event.get("delta") or {}
604
+ return delta.get("text", "") if event.get("type") == "content_block_delta" and delta.get("type") == "text_delta" else ""
605
+
606
+
607
+ # ---- OpenAI Responses ------------------------------------------------------
608
+
609
+ def _responses_parts(content, where: str) -> str | list[dict]:
610
+ if isinstance(content, str):
611
+ return content
612
+ if not isinstance(content, list):
613
+ raise Unsupported(f"{where}: content must be a string or a list of parts")
614
+ parts = []
615
+ for p in content:
616
+ kind = p.get("type") if isinstance(p, dict) else None
617
+ if kind in ("input_text", "output_text", "text", "refusal"):
618
+ parts.append(text_part(p.get("text") if kind != "refusal" else p.get("refusal")))
619
+ elif kind == "input_image":
620
+ url = p.get("image_url")
621
+ url = url.get("url") if isinstance(url, dict) else url
622
+ if not url:
623
+ raise Unsupported("an input_image must carry an image_url (a file_id names an upload this"
624
+ " gateway cannot reach)")
625
+ parts.append(image(str(url)))
626
+ else:
627
+ raise Unsupported(f"a {kind!r} input part is not supported here; send text and images")
628
+ return _content(parts)
629
+
630
+
631
+ def _responses_tools(body: dict) -> tuple[dict, list[str]]:
632
+ """The Responses API's tools, tool_choice, parallel_tool_calls and
633
+ text.format as OpenAI chat fields, and the names of its custom tools.
634
+ A custom tool (free-form input, as Codex's apply_patch) becomes a
635
+ function taking one string, `input`, its grammar in the description."""
636
+ tools, custom, out = body.get("tools") or [], [], []
637
+ if not isinstance(tools, list):
638
+ raise Unsupported('"tools" must be a list')
639
+ for t in tools:
640
+ kind = t.get("type") if isinstance(t, dict) else t
641
+ if kind == "function":
642
+ fn = _function(t.get("name"), t.get("description"), t.get("parameters"))
643
+ if t.get("strict") is not None:
644
+ fn["function"]["strict"] = t["strict"]
645
+ out.append(fn)
646
+ elif kind == "custom":
647
+ fmt = t.get("format") if isinstance(t.get("format"), dict) else {}
648
+ grammar = (f"\n\nThe input must follow this {fmt.get('syntax', '')} grammar:\n{fmt.get('definition', '')}"
649
+ if fmt.get("type") == "grammar" else "")
650
+ out.append(_function(t.get("name"), f"{t.get('description') or ''}{grammar}",
651
+ {"type": "object", "required": ["input"], "properties": {
652
+ "input": {"type": "string", "description": "the tool's whole input, as free text"}}}))
653
+ custom.append(t.get("name"))
654
+ else:
655
+ raise Unsupported(f"a {kind!r} tool is one OpenAI runs itself, which no other model can; send"
656
+ " function or custom tools")
657
+ up: dict = {"tools": out} if out else {}
658
+ choice = body.get("tool_choice")
659
+ if isinstance(choice, dict):
660
+ choice = {"type": "function", "function": {"name": choice["name"]}} if choice.get("name") else \
661
+ choice.get("mode") or "auto"
662
+ if choice and out:
663
+ up["tool_choice"] = choice
664
+ if body.get("parallel_tool_calls") is not None and out:
665
+ up["parallel_tool_calls"] = body["parallel_tool_calls"]
666
+ fmt = (body.get("text") or {}).get("format") if isinstance(body.get("text"), dict) else None
667
+ if isinstance(fmt, dict) and fmt.get("type") == "json_schema":
668
+ up["response_format"] = {"type": "json_schema", "json_schema": {
669
+ k: fmt[k] for k in ("name", "schema", "strict", "description") if k in fmt}}
670
+ elif isinstance(fmt, dict) and fmt.get("type") == "json_object":
671
+ up["response_format"] = {"type": "json_object"}
672
+ return up, custom
673
+
674
+
675
+ def responses_ask(body: dict) -> dict:
676
+ """POST /v1/responses. Nothing is stored, so a previous_response_id (or
677
+ a stored conversation) names nothing here. Function and custom tool calls
678
+ and their outputs become OpenAI tool calls and tool messages."""
679
+ if body.get("previous_response_id") or body.get("conversation"):
680
+ raise Unsupported("responses are never stored here, so previous_response_id and conversation name"
681
+ " nothing; send the whole conversation in input")
682
+ out = []
683
+ if body.get("instructions"):
684
+ out.append({"role": "system", "content": str(body["instructions"])})
685
+ given = body.get("input")
686
+ if isinstance(given, str):
687
+ out.append({"role": "user", "content": given})
688
+ elif isinstance(given, list):
689
+ for item in given:
690
+ if not isinstance(item, dict):
691
+ raise Unsupported('"input" must be a string or a list of items')
692
+ kind = item.get("type") or "message"
693
+ if kind == "reasoning":
694
+ continue # a model's own reasoning from an earlier turn
695
+ if kind in ("function_call", "custom_tool_call"):
696
+ call = _call(item.get("call_id") or item.get("id"), item.get("name"),
697
+ item.get("arguments") or "{}" if kind == "function_call"
698
+ else {"input": item.get("input") or ""})
699
+ if out and out[-1]["role"] == "assistant": # the same turn's text, or its other calls
700
+ out[-1]["tool_calls"] = out[-1].get("tool_calls", []) + [call]
701
+ out[-1]["content"] = text_of(out[-1]["content"]) or None
702
+ else:
703
+ out.append(_assistant("", [call]))
704
+ continue
705
+ if kind in ("function_call_output", "custom_tool_call_output"):
706
+ output = item.get("output")
707
+ out.append({"role": "tool", "tool_call_id": str(item.get("call_id") or ""),
708
+ "content": output if isinstance(output, str) else
709
+ text_of(_responses_parts(output or "", "a tool output"))})
710
+ continue
711
+ if kind != "message":
712
+ raise Unsupported(f"an input item of type {kind!r} is not supported here; send messages,"
713
+ " function calls and their outputs")
714
+ role = item.get("role")
715
+ if role not in ("user", "assistant", "system", "developer"):
716
+ raise Unsupported(f"an input message's role must be user, assistant, system or developer, not {role!r}")
717
+ out.append({"role": role, "content": _responses_parts(item.get("content"), role)})
718
+ else:
719
+ raise Unsupported('"input" must be a string or a list of items')
720
+ options = {"max_tokens": body.get("max_output_tokens"), "temperature": body.get("temperature"),
721
+ "top_p": body.get("top_p")}
722
+ tools, custom = _responses_tools(body)
723
+ return _ask(body, body.get("model") or "", out, body.get("stream"), options, {**options, **tools},
724
+ custom=custom)
725
+
726
+
727
+ def _response(rid: str, model: str, status: str, output: list, usage=(0, 0), created: int = 0, **extra) -> dict:
728
+ return {"id": rid, "object": "response", "created_at": created or _now(), "status": status,
729
+ "error": None, "incomplete_details": None, "instructions": None, "max_output_tokens": None,
730
+ "model": model, "output": output, "parallel_tool_calls": False, "previous_response_id": None,
731
+ "reasoning": {"effort": None, "summary": None}, "store": False, "temperature": None,
732
+ "text": {"format": {"type": "text"}}, "tool_choice": "none", "tools": [], "top_p": None,
733
+ "truncation": "disabled", "metadata": {},
734
+ "usage": {"input_tokens": usage[0], "input_tokens_details": {"cached_tokens": 0},
735
+ "output_tokens": usage[1], "output_tokens_details": {"reasoning_tokens": 0},
736
+ "total_tokens": sum(usage)}, **extra}
737
+
738
+
739
+ def _message_item(mid: str, text: str, status: str = "completed") -> dict:
740
+ return {"type": "message", "id": mid, "status": status, "role": "assistant",
741
+ "content": [{"type": "output_text", "text": text, "annotations": []}] if status == "completed" else []}
742
+
743
+
744
+ def responses_reply(text: str, model: str, bench=None, usage=(0, 0), seconds: float = 0, **_) -> dict:
745
+ return _response(_new_id("resp_"), model, "completed", [_message_item(_new_id("msg_"), text)], usage,
746
+ bench=bench)
747
+
748
+
749
+ def responses_error(status: int, message: str) -> dict:
750
+ return {"error": {"message": message, "type": "invalid_request_error" if status < 500 else "server_error",
751
+ "param": None, "code": str(status)}}
752
+
753
+
754
+ class ResponsesStream(Stream):
755
+ """The Responses API's typed events, numbered; response.completed
756
+ carries the whole response, the bench in it."""
757
+
758
+ def __init__(self, model: str, input_tokens: int = 0, **kw):
759
+ super().__init__(model, input_tokens, **kw)
760
+ self.rid, self.mid, self.created, self.seq = _new_id("resp_"), _new_id("msg_"), _now(), 0
761
+
762
+ def _event(self, kind: str, **data) -> bytes:
763
+ out = sse({"type": kind, "sequence_number": self.seq, **data}, kind)
764
+ self.seq += 1
765
+ return out
766
+
767
+ def _where(self) -> dict:
768
+ return {"item_id": self.mid, "output_index": 0, "content_index": 0}
769
+
770
+ def _head(self) -> bytes:
771
+ pending = _response(self.rid, self.model, "in_progress", [], created=self.created)
772
+ return (self._event("response.created", response=pending)
773
+ + self._event("response.in_progress", response=pending))
774
+
775
+ def _open(self) -> bytes:
776
+ part = {"type": "output_text", "text": "", "annotations": []}
777
+ return (self._event("response.output_item.added", output_index=0,
778
+ item=_message_item(self.mid, "", "in_progress"))
779
+ + self._event("response.content_part.added", **self._where(), part=part))
780
+
781
+ def _close(self) -> tuple[bytes, dict]:
782
+ text = "".join(self.said)
783
+ item = _message_item(self.mid, text)
784
+ return (self._event("response.output_text.done", **self._where(), text=text, logprobs=[])
785
+ + self._event("response.content_part.done", **self._where(), part=item["content"][0])
786
+ + self._event("response.output_item.done", output_index=0, item=item)), item
787
+
788
+ def start(self) -> bytes:
789
+ return self._head() + self._open()
790
+
791
+ def text(self, text: str) -> bytes:
792
+ self.said.append(text)
793
+ return self._event("response.output_text.delta", **self._where(), delta=text, logprobs=[])
794
+
795
+ def end(self, bench=None) -> bytes:
796
+ out, item = self._close()
797
+ done = _response(self.rid, self.model, "completed", [item], (self.input_tokens, self.output_tokens()),
798
+ self.created, **({"bench": bench} if bench is not None else {}))
799
+ return out + self._event("response.completed", response=done)
800
+
801
+ def error(self, status: int, message: str) -> bytes:
802
+ failed = _response(self.rid, self.model, "failed", [], created=self.created)
803
+ failed["error"] = {"code": "server_error" if status >= 500 else "invalid_request", "message": message}
804
+ return (self._event("error", code=str(status), message=message, param=None)
805
+ + self._event("response.failed", response=failed))
806
+
807
+
808
+ def responses_request(messages: list[dict], model: str, options: dict | None = None, stream: bool = False) -> dict:
809
+ """OpenAI messages as a Responses request (POST /v1/responses), never stored."""
810
+ options = options or {}
811
+ system, turns = _turns(messages)
812
+ items = []
813
+ for m in turns:
814
+ mine = m.get("role") == "assistant"
815
+ items.append({"role": "assistant" if mine else "user", "content": [
816
+ {"type": "output_text" if mine else "input_text", "text": p["text"]} if p["type"] == "text"
817
+ else {"type": "input_image", "image_url": _image_url(p)} for p in parts_of(m.get("content"))]})
818
+ body = {"model": model, "input": items, "store": False}
819
+ if system:
820
+ body["instructions"] = system
821
+ for ours, theirs in (("max_tokens", "max_output_tokens"), ("temperature", "temperature"), ("top_p", "top_p")):
822
+ if options.get(ours) is not None:
823
+ body[theirs] = options[ours]
824
+ return {**body, **({"stream": True} if stream else {})}
825
+
826
+
827
+ def responses_text(reply: dict) -> str:
828
+ if reply.get("error") and reply.get("status") != "completed":
829
+ raise UpstreamError(str(reply["error"].get("message") if isinstance(reply["error"], dict) else reply["error"]))
830
+ return "".join(c.get("text", "") for item in reply.get("output") or [] if item.get("type") == "message"
831
+ for c in item.get("content") or [] if c.get("type") == "output_text")
832
+
833
+
834
+ def responses_delta(event: dict) -> str:
835
+ kind = event.get("type")
836
+ if kind == "error":
837
+ raise UpstreamError(str(event.get("message") or event))
838
+ if kind == "response.failed":
839
+ raise UpstreamError(str(((event.get("response") or {}).get("error") or {}).get("message") or event))
840
+ return event.get("delta", "") if kind == "response.output_text.delta" else ""
841
+
842
+
843
+ # ---- Ollama ----------------------------------------------------------------
844
+
845
+ def ollama_model(model) -> str:
846
+ """Ollama names carry a tag, `:latest` when none is given; a collection's
847
+ tag is its reply mode (bench/core:merged), and `latest` is no mode."""
848
+ return model.removesuffix(":latest") if isinstance(model, str) else model
849
+
850
+
851
+ def _ollama_parts(text, pictures, where: str) -> str | list[dict]:
852
+ if pictures is not None and not (isinstance(pictures, list) and all(isinstance(p, str) for p in pictures)):
853
+ raise Unsupported(f"{where}: images must be a list of base64 strings")
854
+ parts = [text_part(text)] if text else []
855
+ parts += [image(data_url(sniff(p), p)) for p in pictures or []]
856
+ return _content(parts) if parts else ""
857
+
858
+
859
+ def _ollama_options(body: dict) -> dict:
860
+ o = body.get("options") if isinstance(body.get("options"), dict) else {}
861
+ return {"max_tokens": o.get("num_predict"), "temperature": o.get("temperature"), "top_p": o.get("top_p"),
862
+ "stop": o.get("stop")}
863
+
864
+
865
+ def ollama_chat_ask(body: dict) -> dict:
866
+ """POST /api/chat. Ollama streams unless told not to. Its tools are
867
+ already OpenAI's shape; its tool calls carry arguments as an object and
868
+ usually no id (numbered here, _Ids); `format` becomes response_format."""
869
+ out, ids = [], _Ids()
870
+ for m in _messages(body.get("messages")):
871
+ role = m.get("role")
872
+ if role == "tool":
873
+ out.append({"role": "tool", "tool_call_id": ids.result(m.get("tool_name") or m.get("name"),
874
+ m.get("tool_call_id")),
875
+ "content": str(m.get("content") or "")})
876
+ continue
877
+ if role not in ("system", "user", "assistant"):
878
+ raise Unsupported(f'a message\'s "role" must be system, user, assistant or tool, not {role!r}')
879
+ content = _ollama_parts(m.get("content"), m.get("images"), role)
880
+ calls = m.get("tool_calls") or []
881
+ if calls and (role != "assistant" or not isinstance(calls, list)):
882
+ raise Unsupported("tool_calls must be a list, in an assistant message")
883
+ out.append(_assistant(content, [_call(ids.call(fn.get("name"), tc.get("id")), fn.get("name"), fn.get("arguments"))
884
+ for tc in calls if isinstance(tc, dict)
885
+ for fn in [tc.get("function") or {}]]) if calls else
886
+ {"role": role, "content": content})
887
+ options = _ollama_options(body)
888
+ up = dict(options)
889
+ if (tools := body.get("tools")):
890
+ if not (isinstance(tools, list) and all(isinstance(t, dict) and t.get("type") == "function" for t in tools)):
891
+ raise Unsupported('"tools" must be a list of {"type": "function", "function": {...}} objects')
892
+ up["tools"] = tools
893
+ fmt = body.get("format")
894
+ if fmt == "json":
895
+ up["response_format"] = {"type": "json_object"}
896
+ elif isinstance(fmt, dict):
897
+ up["response_format"] = {"type": "json_schema", "json_schema": {"name": "response", "schema": fmt}}
898
+ return _ask(body, ollama_model(body.get("model") or ""), out, body.get("stream", True), options, up)
899
+
900
+
901
+ def ollama_generate_ask(body: dict) -> dict:
902
+ """POST /api/generate: a prompt and an optional system text."""
903
+ if not isinstance(body.get("prompt", ""), str):
904
+ raise Unsupported('"prompt" must be a string')
905
+ if body.get("context"):
906
+ raise Unsupported('"context" carries a model\'s own token state, which a panel cannot take up;'
907
+ " send the conversation to /api/chat instead")
908
+ out = [{"role": "system", "content": str(body["system"])}] if body.get("system") else []
909
+ out.append({"role": "user", "content": _ollama_parts(body.get("prompt"), body.get("images"), "prompt")})
910
+ return _ask(body, ollama_model(body.get("model") or ""), out, body.get("stream", True), _ollama_options(body))
911
+
912
+
913
+ def _ollama_at() -> str:
914
+ return dt.datetime.now(dt.timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z")
915
+
916
+
917
+ def _ollama_done(usage, seconds: float) -> dict:
918
+ # Real durations, in nanoseconds: clients divide by them for tokens a second.
919
+ ns = max(1, int(seconds * 1e9))
920
+ return {"done": True, "done_reason": "stop", "total_duration": ns, "load_duration": 0,
921
+ "prompt_eval_count": usage[0], "prompt_eval_duration": 0, "eval_count": usage[1], "eval_duration": ns}
922
+
923
+
924
+ def ollama_reply(text: str, model: str, bench=None, usage=(0, 0), seconds: float = 0, generate: bool = False, **_) -> dict:
925
+ said = {"response": text} if generate else {"message": {"role": "assistant", "content": text}}
926
+ return {"model": model, "created_at": _ollama_at(), **said, **_ollama_done(usage, seconds), "bench": bench}
927
+
928
+
929
+ def ollama_generate_reply(text: str, model: str, bench=None, usage=(0, 0), seconds: float = 0, **_) -> dict:
930
+ return ollama_reply(text, model, bench, usage, seconds, generate=True)
931
+
932
+
933
+ def ollama_error(status: int, message: str) -> dict:
934
+ return {"error": message}
935
+
936
+
937
+ class OllamaStream(Stream):
938
+ """One JSON object a line; the last says done, the bench in it. While
939
+ waiting, a line that adds nothing: NDJSON has no comments."""
940
+ content_type = "application/x-ndjson"
941
+ generate = False
942
+
943
+ def _line(self, text: str, **extra) -> bytes:
944
+ said = {"response": text} if self.generate else {"message": {"role": "assistant", "content": text}}
945
+ return (json.dumps({"model": self.model, "created_at": _ollama_at(), **said, "done": False, **extra},
946
+ ensure_ascii=False) + "\n").encode()
947
+
948
+ def text(self, text: str) -> bytes:
949
+ self.said.append(text)
950
+ return self._line(text)
951
+
952
+ def end(self, bench=None) -> bytes:
953
+ done = _ollama_done((self.input_tokens, self.output_tokens()), time.monotonic() - self.started)
954
+ return self._line("", **done, **({"bench": bench} if bench is not None else {}))
955
+
956
+ def error(self, status: int, message: str) -> bytes:
957
+ return (json.dumps({"error": message}, ensure_ascii=False) + "\n").encode()
958
+
959
+ def keepalive(self) -> bytes:
960
+ return self._line("")
961
+
962
+
963
+ class OllamaGenerateStream(OllamaStream):
964
+ generate = True
965
+
966
+
967
+ def ollama_tags(models: list[tuple[str, dict]]) -> dict:
968
+ """GET /api/tags: each (name, description) as an Ollama model."""
969
+ at = _ollama_at()
970
+ return {"models": [{"name": n, "model": n, "modified_at": at, "size": 0,
971
+ "digest": hashlib.sha256(n.encode()).hexdigest(), "details": _ollama_details(d)}
972
+ for n, d in models]}
973
+
974
+
975
+ def _ollama_details(described: dict) -> dict:
976
+ return {"parent_model": "", "format": "", "family": "benchwarmers", "families": ["benchwarmers"],
977
+ "parameter_size": "", "quantization_level": ""}
978
+
979
+
980
+ def ollama_show(name: str, described: dict) -> dict:
981
+ """POST /api/show: what a client reads to decide what to send (context
982
+ length, whether it takes images)."""
983
+ together = described.get("together") or {}
984
+ caps = together.get("capabilities") or []
985
+ info = {"general.architecture": "benchwarmers", "general.basename": name}
986
+ if together.get("context_window"):
987
+ info["benchwarmers.context_length"] = together["context_window"]
988
+ return {"modelfile": f"# {described.get('description') or name}", "parameters": "", "template": "{{ .Prompt }}",
989
+ "details": _ollama_details(described), "model_info": info, "modified_at": _ollama_at(),
990
+ "capabilities": ["completion"] + (["vision"] if "vision" in caps else [])}
991
+
992
+
993
+ def ollama_request(messages: list[dict], model: str, options: dict | None = None, stream: bool = False) -> dict:
994
+ """OpenAI messages as an Ollama /api/chat request. Ollama takes images
995
+ only as base64, so an http(s) image is refused, not fetched here."""
996
+ out = []
997
+ for m in messages:
998
+ if m.get("role") in ("tool", "function") or m.get("tool_calls"):
999
+ raise Unsupported("tool calls and results are sent to chat completions only, not a native API")
1000
+ parts = parts_of(m.get("content"))
1001
+ pictures = []
1002
+ for p in parts:
1003
+ if p["type"] == "image_url":
1004
+ data = split_data_url(_image_url(p))
1005
+ if not data:
1006
+ raise Unsupported("Ollama takes images as base64 only; send a data: URL")
1007
+ pictures.append(data[1])
1008
+ role = "system" if m.get("role") in LEAD else m.get("role", "user")
1009
+ out.append({"role": role, "content": "\n\n".join(p["text"] for p in parts if p["type"] == "text"),
1010
+ **({"images": pictures} if pictures else {})})
1011
+ opts = {theirs: options[ours] for ours, theirs in (("max_tokens", "num_predict"), ("temperature", "temperature"),
1012
+ ("top_p", "top_p"), ("stop", "stop"))
1013
+ if options and options.get(ours) is not None}
1014
+ return {"model": model, "messages": out, "stream": stream, **({"options": opts} if opts else {})}
1015
+
1016
+
1017
+ def ollama_text(reply: dict) -> str:
1018
+ if reply.get("error"):
1019
+ raise UpstreamError(str(reply["error"]))
1020
+ return reply["response"] if "response" in reply else str((reply.get("message") or {}).get("content") or "")
1021
+
1022
+
1023
+ ollama_delta = ollama_text # each NDJSON line is a reply's shape, with less in it
1024
+
1025
+
1026
+ # ---- Gemini ----------------------------------------------------------------
1027
+
1028
+ def _get(d: dict, camel: str, snake: str):
1029
+ """A field by either spelling: Gemini's REST API takes both."""
1030
+ return d.get(camel, d.get(snake)) if isinstance(d, dict) else None
1031
+
1032
+
1033
+ def _gemini_parts(parts, where: str) -> str | list[dict]:
1034
+ if isinstance(parts, dict):
1035
+ parts = [parts]
1036
+ if not isinstance(parts, list):
1037
+ raise Unsupported(f"{where}: parts must be a list")
1038
+ out = []
1039
+ for p in parts:
1040
+ if not isinstance(p, dict):
1041
+ raise Unsupported(f"{where}: each part must be an object")
1042
+ if p.get("thought"):
1043
+ continue # a model's own reasoning from an earlier turn
1044
+ if "text" in p:
1045
+ out.append(text_part(p["text"]))
1046
+ elif (blob := _get(p, "inlineData", "inline_data")) is not None:
1047
+ mime = _image_mime(_get(blob, "mimeType", "mime_type"), "an inlineData part")
1048
+ out.append(image(data_url(mime, str(blob.get("data") or ""))))
1049
+ elif (ref := _get(p, "fileData", "file_data")) is not None:
1050
+ uri = str(_get(ref, "fileUri", "file_uri") or "")
1051
+ _image_mime(_get(ref, "mimeType", "mime_type") or mimetypes.guess_type(uri)[0], "a fileData part")
1052
+ if not uri.lower().startswith(("https://", "http://")):
1053
+ raise Unsupported("a fileData part must name an image at an http(s) URL; an upload to"
1054
+ " Google's Files API cannot be read by other models")
1055
+ out.append(image(uri))
1056
+ elif any(k in p for k in ("executableCode", "executable_code", "codeExecutionResult", "code_execution_result")):
1057
+ raise Unsupported("a code execution part is Gemini's own tool, which no other model runs")
1058
+ elif any(k in p for k in ("functionCall", "function_call", "functionResponse", "function_response")):
1059
+ raise Unsupported(f"{where}: a function call or response cannot be here")
1060
+ else:
1061
+ raise Unsupported(f"{where}: a part with {', '.join(sorted(p)) or 'nothing'} is not supported here;"
1062
+ " send text and images")
1063
+ return _content(out)
1064
+
1065
+
1066
+ def _gemini_schema(schema):
1067
+ """Gemini's OpenAPI-style schema as JSON Schema: its types are upper case
1068
+ (OBJECT, STRING), JSON Schema's lower."""
1069
+ if isinstance(schema, list):
1070
+ return [_gemini_schema(s) for s in schema]
1071
+ if not isinstance(schema, dict):
1072
+ return schema
1073
+ return {k: v.lower() if k == "type" and isinstance(v, str) else _gemini_schema(v) for k, v in schema.items()}
1074
+
1075
+
1076
+ def _gemini_tools(body: dict) -> dict:
1077
+ """Gemini's function declarations, toolConfig and JSON output settings as OpenAI chat fields."""
1078
+ out = []
1079
+ for t in body.get("tools") or []:
1080
+ decls = _get(t, "functionDeclarations", "function_declarations") if isinstance(t, dict) else None
1081
+ if decls is None or set(t) - {"functionDeclarations", "function_declarations"}:
1082
+ raise Unsupported(f"a tool with {', '.join(sorted(t)) if isinstance(t, dict) else t} is one Gemini runs"
1083
+ " itself (Google Search, code execution, URL context), which no other model can;"
1084
+ " send functionDeclarations")
1085
+ for d in decls if isinstance(decls, list) else ():
1086
+ out.append(_function(d.get("name"), d.get("description"),
1087
+ _get(d, "parametersJsonSchema", "parameters_json_schema")
1088
+ or _gemini_schema(d.get("parameters"))))
1089
+ up: dict = {"tools": out} if out else {}
1090
+ config = _get(_get(body, "toolConfig", "tool_config") or {}, "functionCallingConfig", "function_calling_config") or {}
1091
+ mode, names = str(config.get("mode") or "").upper(), _get(config, "allowedFunctionNames", "allowed_function_names") or []
1092
+ if out and mode == "NONE":
1093
+ up["tool_choice"] = "none"
1094
+ elif out and mode in ("ANY", "VALIDATED"):
1095
+ up["tool_choice"] = {"type": "function", "function": {"name": names[0]}} if len(names) == 1 else "required"
1096
+ gen = _get(body, "generationConfig", "generation_config") or {}
1097
+ if _get(gen, "responseMimeType", "response_mime_type") == "application/json":
1098
+ schema = _get(gen, "responseJsonSchema", "response_json_schema") or \
1099
+ _gemini_schema(_get(gen, "responseSchema", "response_schema"))
1100
+ up["response_format"] = {"type": "json_schema", "json_schema": {"name": "response", "schema": schema}} \
1101
+ if schema else {"type": "json_object"}
1102
+ return up
1103
+
1104
+
1105
+ def gemini_ask(body: dict, model: str, stream: bool = False) -> dict:
1106
+ """POST /v1beta/models/{model}:generateContent (or :streamGenerateContent):
1107
+ the model is in the path, and so is whether it streams. A model turn's
1108
+ functionCall parts become its tool calls, a functionResponse a tool
1109
+ message (ids numbered when absent, _Ids)."""
1110
+ contents = body.get("contents")
1111
+ if isinstance(contents, dict):
1112
+ contents = [contents]
1113
+ if not (isinstance(contents, list) and all(isinstance(c, dict) for c in contents)):
1114
+ raise Unsupported('"contents" must be a list of content objects')
1115
+ out, ids = [], _Ids()
1116
+ if (system := _get(body, "systemInstruction", "system_instruction")):
1117
+ out.append({"role": "system", "content": text_of(_gemini_parts(
1118
+ system.get("parts") if isinstance(system, dict) else [{"text": str(system)}], "systemInstruction"))})
1119
+ for c in contents:
1120
+ role = c.get("role") or "user"
1121
+ if role not in ("user", "model", "function"):
1122
+ raise Unsupported(f'a content\'s "role" must be "user" or "model", not {role!r}')
1123
+ parts = c.get("parts")
1124
+ parts = [parts] if isinstance(parts, dict) else parts
1125
+ if not isinstance(parts, list):
1126
+ _gemini_parts(parts, role) # says why
1127
+ calls = [fc for p in parts if isinstance(p, dict) and (fc := _get(p, "functionCall", "function_call"))]
1128
+ results = [fr for p in parts if isinstance(p, dict) and (fr := _get(p, "functionResponse", "function_response"))]
1129
+ rest = [p for p in parts if not (isinstance(p, dict) and any(
1130
+ k in p for k in ("functionCall", "function_call", "functionResponse", "function_response")))]
1131
+ if (calls and role != "model") or (results and role == "model"):
1132
+ raise Unsupported("a functionCall belongs in a model turn, a functionResponse in a user turn")
1133
+ if role == "model":
1134
+ said = _gemini_parts(rest, role) if rest or not calls else ""
1135
+ out.append(_assistant(said, [_call(ids.call(fc.get("name"), fc.get("id")), fc.get("name"), fc.get("args") or {})
1136
+ for fc in calls]) if calls else {"role": "assistant", "content": said})
1137
+ continue
1138
+ out += [{"role": "tool", "tool_call_id": ids.result(fr.get("name"), fr.get("id")),
1139
+ "content": json.dumps(fr.get("response") or {}, ensure_ascii=False)} for fr in results]
1140
+ if rest or not results:
1141
+ out.append({"role": "user", "content": _gemini_parts(rest, role)})
1142
+ config = _get(body, "generationConfig", "generation_config") or {}
1143
+ options = {"max_tokens": _get(config, "maxOutputTokens", "max_output_tokens"),
1144
+ "temperature": config.get("temperature"), "top_p": _get(config, "topP", "top_p"),
1145
+ "stop": _get(config, "stopSequences", "stop_sequences")}
1146
+ return _ask(body, model, out, stream, options, {**options, **_gemini_tools(body)})
1147
+
1148
+
1149
+ def _gemini_chunk(text: str, model: str, rid: str, usage=None, finish: bool = False) -> dict:
1150
+ candidate = {"content": {"role": "model", "parts": [{"text": text}]}, "index": 0,
1151
+ **({"finishReason": "STOP"} if finish else {})}
1152
+ out = {"candidates": [candidate], "modelVersion": model, "responseId": rid}
1153
+ if usage is not None:
1154
+ out["usageMetadata"] = {"promptTokenCount": usage[0], "candidatesTokenCount": usage[1],
1155
+ "totalTokenCount": sum(usage)}
1156
+ return out
1157
+
1158
+
1159
+ def gemini_reply(text: str, model: str, bench=None, usage=(0, 0), seconds: float = 0, **_) -> dict:
1160
+ """No bench field: Google's SDK refuses fields it does not know, so the
1161
+ gateway sends the panel's call ids in headers instead (bench_headers)."""
1162
+ return _gemini_chunk(text, model, _new_id(""), usage, finish=True)
1163
+
1164
+
1165
+ def gemini_error(status: int, message: str) -> dict:
1166
+ return {"error": {"code": status, "message": message, "status": GEMINI_STATUS.get(status, "INTERNAL")}}
1167
+
1168
+
1169
+ class GeminiStream(Stream):
1170
+ """GenerateContentResponse chunks: as server-sent events with ?alt=sse
1171
+ (what Google's SDKs ask for), else one JSON array sent as it grows. A
1172
+ keep-alive is a chunk with no text, which every client takes in stride:
1173
+ the SDKs' readers take any line that is not `data:` for an error."""
1174
+
1175
+ def __init__(self, model: str, input_tokens: int = 0, sse: bool = True, **kw):
1176
+ super().__init__(model, input_tokens, **kw)
1177
+ self.sse, self.rid, self.first = sse, _new_id(""), True
1178
+ if not sse:
1179
+ self.content_type = "application/json"
1180
+
1181
+ def _send(self, data: dict, last: bool = False) -> bytes:
1182
+ if self.sse:
1183
+ return sse(data)
1184
+ lead, self.first = ("[" if self.first else ",\r\n"), False
1185
+ return (lead + json.dumps(data, ensure_ascii=False) + ("]" if last else "")).encode()
1186
+
1187
+ def text(self, text: str) -> bytes:
1188
+ self.said.append(text)
1189
+ return self._send(_gemini_chunk(text, self.model, self.rid))
1190
+
1191
+ def end(self, bench=None) -> bytes:
1192
+ return self._send(_gemini_chunk("", self.model, self.rid, (self.input_tokens, self.output_tokens()), True),
1193
+ last=True)
1194
+
1195
+ def error(self, status: int, message: str) -> bytes:
1196
+ return self._send(gemini_error(status, message), last=True)
1197
+
1198
+ def keepalive(self) -> bytes:
1199
+ return self._send(_gemini_chunk("", self.model, self.rid))
1200
+
1201
+
1202
+ def gemini_models(models: list[tuple[str, dict]]) -> dict:
1203
+ """GET /v1beta/models: each (name, description) as a Gemini model."""
1204
+ return {"models": [gemini_model(n, d) for n, d in models]}
1205
+
1206
+
1207
+ def gemini_model(name: str, described: dict) -> dict:
1208
+ together = described.get("together") or {}
1209
+ return {"name": f"models/{name}", "baseModelId": name, "version": "1", "displayName": name,
1210
+ "description": described.get("description") or "",
1211
+ **({"inputTokenLimit": together["context_window"]} if together.get("context_window") else {}),
1212
+ "supportedGenerationMethods": ["generateContent", "streamGenerateContent", "countTokens"]}
1213
+
1214
+
1215
+ def gemini_route(path: str) -> tuple[str, str] | None:
1216
+ """(model, method) of a Gemini POST path, `/v1beta/models/bench/core:generateContent`
1217
+ -> ("bench/core", "generateContent"); the model may be URL-encoded. None if it is not one."""
1218
+ for root in ("/v1beta/models/", "/v1/models/"):
1219
+ if path.startswith(root):
1220
+ model, sep, method = urllib.parse.unquote(path[len(root):]).rpartition(":")
1221
+ if sep and model:
1222
+ return model, method
1223
+ return None
1224
+
1225
+
1226
+ def gemini_request(messages: list[dict], options: dict | None = None) -> dict:
1227
+ """OpenAI messages as a Gemini generateContent body (the model goes in the
1228
+ path: gemini_path)."""
1229
+ options = options or {}
1230
+ system, turns = _turns(messages)
1231
+ contents = []
1232
+ for m in turns:
1233
+ parts = []
1234
+ for p in parts_of(m.get("content")):
1235
+ if p["type"] == "text":
1236
+ parts.append({"text": p["text"]})
1237
+ elif (data := split_data_url(_image_url(p))):
1238
+ parts.append({"inlineData": {"mimeType": data[0], "data": data[1]}})
1239
+ else:
1240
+ url = _image_url(p)
1241
+ parts.append({"fileData": {"mimeType": mimetypes.guess_type(urllib.parse.urlsplit(url).path)[0]
1242
+ or "image/jpeg", "fileUri": url}})
1243
+ contents.append({"role": "model" if m.get("role") == "assistant" else "user", "parts": parts})
1244
+ body = {"contents": contents}
1245
+ if system:
1246
+ body["systemInstruction"] = {"parts": [{"text": system}]}
1247
+ config = {theirs: options[ours] for ours, theirs in (("max_tokens", "maxOutputTokens"), ("temperature", "temperature"),
1248
+ ("top_p", "topP"), ("stop", "stopSequences"))
1249
+ if options.get(ours) is not None}
1250
+ return {**body, **({"generationConfig": config} if config else {})}
1251
+
1252
+
1253
+ def gemini_path(model: str, stream: bool = False) -> str:
1254
+ """The path a generateContent call goes to, under the API's base URL."""
1255
+ return f"/v1beta/models/{model}:" + ("streamGenerateContent?alt=sse" if stream else "generateContent")
1256
+
1257
+
1258
+ def gemini_text(reply: dict) -> str:
1259
+ if reply.get("error"):
1260
+ raise UpstreamError(str(reply["error"].get("message") if isinstance(reply["error"], dict) else reply["error"]))
1261
+ candidates = reply.get("candidates") or [{}]
1262
+ return "".join(p.get("text", "") for p in ((candidates[0].get("content") or {}).get("parts") or [])
1263
+ if not p.get("thought"))
1264
+
1265
+
1266
+ gemini_delta = gemini_text # each streamed chunk is a reply's shape
1267
+
1268
+
1269
+ # ---- route mode: one member's turn, in each format ---------------------------
1270
+
1271
+ ANTHROPIC_STOP = {"stop": "end_turn", "length": "max_tokens", "tool_calls": "tool_use"}
1272
+
1273
+
1274
+ def _piece(content) -> str:
1275
+ """A delta's text: a string, or the parts some providers stream."""
1276
+ if isinstance(content, list):
1277
+ return "".join(str(p.get("text") or "") for p in content if isinstance(p, dict))
1278
+ return content if isinstance(content, str) else ""
1279
+
1280
+
1281
+ class Turn:
1282
+ """One assistant turn read from an OpenAI chat-completions stream, chunk
1283
+ by chunk (or from the one chunk a whole reply makes): the answer, the
1284
+ reasoning, each tool call whole, why it stopped and the usage the
1285
+ upstream counted. A call is keyed by its id when it brings a new one,
1286
+ else its index, else it continues the last: providers differ in which
1287
+ of those they send after the first piece."""
1288
+
1289
+ def __init__(self):
1290
+ self.text, self.reasoning, self.calls = [], [], []
1291
+ self.finish, self.usage, self.model = "", {}, ""
1292
+ self._by_id, self._by_index, self._last, self._unnamed = {}, {}, None, set()
1293
+
1294
+ def feed(self, chunk: dict) -> tuple[str, bool]:
1295
+ """Add one chunk: (the answer text it adds, whether it adds to the
1296
+ answer: text or a tool call). Reasoning alone does not: a model that
1297
+ reasons and then says nothing has not answered."""
1298
+ if not isinstance(chunk, dict):
1299
+ return "", False
1300
+ if isinstance(chunk.get("usage"), dict) and chunk["usage"]:
1301
+ self.usage = chunk["usage"]
1302
+ self.model = chunk.get("model") or self.model
1303
+ choices = chunk.get("choices")
1304
+ if not (isinstance(choices, list) and choices and isinstance(choices[0], dict)):
1305
+ return "", False
1306
+ self.finish = choices[0].get("finish_reason") or self.finish
1307
+ delta = choices[0].get("delta") or choices[0].get("message") or {}
1308
+ if not isinstance(delta, dict):
1309
+ return "", False
1310
+ thought, text = _piece(delta.get("reasoning_content") or delta.get("reasoning")), _piece(delta.get("content"))
1311
+ self.reasoning.append(thought)
1312
+ self.text.append(text)
1313
+ called = False
1314
+ for d in delta.get("tool_calls") or ():
1315
+ if not isinstance(d, dict):
1316
+ continue
1317
+ cid, idx, fn = d.get("id"), d.get("index"), d.get("function") or {}
1318
+ if cid and cid in self._by_id:
1319
+ k = self._by_id[cid]
1320
+ elif cid and idx in self._by_index and self._by_index[idx] in self._unnamed:
1321
+ # The id came after the call's first piece: it is that call's.
1322
+ k = self._by_index[idx]
1323
+ self._unnamed.discard(k)
1324
+ self.calls[k]["id"], self._by_id[cid] = str(cid), k
1325
+ elif cid or not self.calls or (idx is not None and idx not in self._by_index):
1326
+ k = len(self.calls)
1327
+ self.calls.append({"id": str(cid or _new_id("call_")), "name": "", "arguments": ""})
1328
+ if cid:
1329
+ self._by_id[cid] = k
1330
+ else:
1331
+ self._unnamed.add(k) # its id made up, until one comes
1332
+ else:
1333
+ k = self._by_index[idx] if idx is not None else self._last
1334
+ if idx is not None:
1335
+ self._by_index[idx] = k
1336
+ self._last, call, args = k, self.calls[k], fn.get("arguments")
1337
+ call["name"] = call["name"] or str(fn.get("name") or "")
1338
+ call["arguments"] += args if isinstance(args, str) else json.dumps(args) if args is not None else ""
1339
+ called = True
1340
+ return text, bool(text or called)
1341
+
1342
+ def answer(self) -> str:
1343
+ return "".join(self.text)
1344
+
1345
+ def stop(self) -> str:
1346
+ """Why it stopped, in chat completions' words. A turn that called a
1347
+ tool says tool_calls whatever the provider said (some say stop)."""
1348
+ return "tool_calls" if self.calls else self.finish or "stop"
1349
+
1350
+ def tokens(self, input_tokens: int) -> tuple[int, int]:
1351
+ """(in, out): the upstream's counts, else estimates."""
1352
+ u = self.usage
1353
+ return (u.get("prompt_tokens") or u.get("input_tokens") or input_tokens,
1354
+ u.get("completion_tokens") or u.get("output_tokens")
1355
+ or estimate(self.answer() + "".join(c["arguments"] for c in self.calls)))
1356
+
1357
+
1358
+ def _arguments(call: dict) -> dict:
1359
+ """A call's arguments as an object, for the formats that send one. Text
1360
+ that is not a JSON object is kept under "arguments" rather than lost."""
1361
+ try:
1362
+ value = json.loads(call["arguments"] or "{}")
1363
+ except ValueError:
1364
+ return {"arguments": call["arguments"]}
1365
+ return value if isinstance(value, dict) else {"arguments": value}
1366
+
1367
+
1368
+ def _call_item(call: dict, custom: bool) -> dict:
1369
+ """A tool call as a Responses output item."""
1370
+ if custom:
1371
+ return {"type": "custom_tool_call", "id": _new_id("ctc_"), "call_id": call["id"], "name": call["name"],
1372
+ "input": str(_arguments(call).get("input", call["arguments"])), "status": "completed"}
1373
+ return {"type": "function_call", "id": _new_id("fc_"), "call_id": call["id"], "name": call["name"],
1374
+ "arguments": call["arguments"] or "{}", "status": "completed"}
1375
+
1376
+
1377
+ def _gemini_calls(turn: Turn) -> list[dict]:
1378
+ return [{"functionCall": {"id": c["id"], "name": c["name"], "args": _arguments(c)}} for c in turn.calls]
1379
+
1380
+
1381
+ def _ollama_calls(turn: Turn) -> list[dict]:
1382
+ return [{"function": {"name": c["name"], "arguments": _arguments(c)}} for c in turn.calls]
1383
+
1384
+
1385
+ def _bench(bench) -> dict:
1386
+ return {"bench": bench} if bench is not None else {}
1387
+
1388
+
1389
+ class ChatRoute(ChatStream):
1390
+ """The member's own chunks as they came, tool-call deltas exactly as
1391
+ sent, under the name asked for; then a closing chunk with no choice
1392
+ carrying the bench, as include_usage's last chunk has none."""
1393
+
1394
+ def start(self) -> bytes:
1395
+ return b""
1396
+
1397
+ def chunk(self, chunk: dict) -> bytes:
1398
+ self.turn.feed(chunk)
1399
+ self.id = chunk.get("id") or self.id
1400
+ return sse({**chunk, "object": "chat.completion.chunk", "created": chunk.get("created") or self.created,
1401
+ "model": self.model})
1402
+
1403
+ def end(self, bench=None) -> bytes:
1404
+ return sse({"id": self.id, "object": "chat.completion.chunk", "created": self.created, "model": self.model,
1405
+ "choices": [], **_bench(bench)}) + b"data: [DONE]\n\n"
1406
+
1407
+
1408
+ class AnthropicRoute(AnthropicStream):
1409
+ """message_start; the text as one text block as it comes; each tool call
1410
+ as a tool_use block once the turn is done; the stop reason and the
1411
+ member's own token counts."""
1412
+ opened = False
1413
+
1414
+ def start(self) -> bytes:
1415
+ return self._head()
1416
+
1417
+ def text(self, text: str) -> bytes:
1418
+ out = b"" if self.opened else self._block(0, {"type": "text", "text": ""})
1419
+ self.opened = True
1420
+ return out + super().text(text)
1421
+
1422
+ def end(self, bench=None) -> bytes:
1423
+ out, i = b"", 0
1424
+ if self.opened or not self.turn.calls: # a turn with nothing to say still has its block
1425
+ out += (b"" if self.opened else self._block(0, {"type": "text", "text": ""})) \
1426
+ + sse({"type": "content_block_stop", "index": 0}, "content_block_stop")
1427
+ i = 1
1428
+ for call in self.turn.calls:
1429
+ out += (self._block(i, {"type": "tool_use", "id": call["id"], "name": call["name"], "input": {}})
1430
+ + sse({"type": "content_block_delta", "index": i, "delta": {
1431
+ "type": "input_json_delta", "partial_json": json.dumps(_arguments(call))}}, "content_block_delta")
1432
+ + sse({"type": "content_block_stop", "index": i}, "content_block_stop"))
1433
+ i += 1
1434
+ used = self.turn.tokens(self.input_tokens)
1435
+ delta = {"type": "message_delta", "delta": {"stop_reason": ANTHROPIC_STOP.get(self.turn.stop(), "end_turn"),
1436
+ "stop_sequence": None},
1437
+ "usage": {"input_tokens": used[0], "output_tokens": used[1]}, **_bench(bench)}
1438
+ return out + sse(delta, "message_delta") + sse({"type": "message_stop"}, "message_stop")
1439
+
1440
+
1441
+ class ResponsesRoute(ResponsesStream):
1442
+ """The text as one message item as it comes, then each tool call as a
1443
+ function_call (or custom_tool_call) item once the turn is done."""
1444
+ opened = False
1445
+
1446
+ def __init__(self, model: str, input_tokens: int = 0, custom=(), **kw):
1447
+ super().__init__(model, input_tokens, **kw)
1448
+ self.custom = set(custom or ())
1449
+
1450
+ def start(self) -> bytes:
1451
+ return self._head()
1452
+
1453
+ def text(self, text: str) -> bytes:
1454
+ out = b"" if self.opened else self._open()
1455
+ self.opened = True
1456
+ return out + super().text(text)
1457
+
1458
+ def end(self, bench=None) -> bytes:
1459
+ out, items = self._close() if self.opened else (b"", None)
1460
+ items = [items] if items else []
1461
+ for call in self.turn.calls:
1462
+ item, n = _call_item(call, call["name"] in self.custom), len(items)
1463
+ pending = {**item, "status": "in_progress", **({"arguments": ""} if item["type"] == "function_call" else {})}
1464
+ out += self._event("response.output_item.added", output_index=n, item=pending)
1465
+ if item["type"] == "function_call":
1466
+ out += (self._event("response.function_call_arguments.delta", item_id=item["id"], output_index=n,
1467
+ delta=item["arguments"])
1468
+ + self._event("response.function_call_arguments.done", item_id=item["id"], output_index=n,
1469
+ arguments=item["arguments"]))
1470
+ out += self._event("response.output_item.done", output_index=n, item=item)
1471
+ items.append(item)
1472
+ done = _response(self.rid, self.model, "completed", items, self.turn.tokens(self.input_tokens), self.created,
1473
+ **_bench(bench))
1474
+ return out + self._event("response.completed", response=done)
1475
+
1476
+
1477
+ class GeminiRoute(GeminiStream):
1478
+ """The text as it comes; the tool calls as functionCall parts, whole, in
1479
+ the last chunk (Gemini streams each call whole anyway)."""
1480
+
1481
+ def end(self, bench=None) -> bytes:
1482
+ last = _gemini_chunk("", self.model, self.rid, self.turn.tokens(self.input_tokens), True)
1483
+ if self.turn.calls:
1484
+ last["candidates"][0]["content"]["parts"] = _gemini_calls(self.turn)
1485
+ if self.turn.finish == "length":
1486
+ last["candidates"][0]["finishReason"] = "MAX_TOKENS"
1487
+ return self._send(last, last=True)
1488
+
1489
+
1490
+ class OllamaRoute(OllamaStream):
1491
+ """The text as it comes; the tool calls in one line of their own, then
1492
+ the done line (Ollama streams each call whole anyway)."""
1493
+
1494
+ def end(self, bench=None) -> bytes:
1495
+ out = b""
1496
+ if self.turn.calls:
1497
+ out = (json.dumps({"model": self.model, "created_at": _ollama_at(), "message": {
1498
+ "role": "assistant", "content": "", "tool_calls": _ollama_calls(self.turn)}, "done": False},
1499
+ ensure_ascii=False) + "\n").encode()
1500
+ done = _ollama_done(self.turn.tokens(self.input_tokens), time.monotonic() - self.started)
1501
+ if self.turn.finish == "length":
1502
+ done["done_reason"] = "length"
1503
+ return out + self._line("", **done, **_bench(bench))
1504
+
1505
+
1506
+ def route_reply(fmt: str, turn: Turn, model: str, bench: dict, input_tokens: int, seconds: float,
1507
+ custom=()) -> dict:
1508
+ """A whole route-mode turn as the format's reply: the text reply, with
1509
+ the tool calls, stop reason and the member's own token counts put in."""
1510
+ used, calls, said = turn.tokens(input_tokens), turn.calls, turn.answer()
1511
+ reply = INBOUND[fmt].reply(said, model, bench, used, seconds)
1512
+ if fmt == "chat":
1513
+ choice = reply["choices"][0]
1514
+ choice["finish_reason"] = turn.stop()
1515
+ if calls:
1516
+ choice["message"].update(content=said or None, tool_calls=[
1517
+ _call(c["id"], c["name"], c["arguments"] or "{}") for c in calls])
1518
+ if (thought := "".join(turn.reasoning)):
1519
+ choice["message"]["reasoning_content"] = thought
1520
+ reply["usage"] = turn.usage or {"prompt_tokens": used[0], "completion_tokens": used[1], "total_tokens": sum(used)}
1521
+ elif fmt == "anthropic":
1522
+ reply["content"] = ([{"type": "text", "text": said}] if said or not calls else []) + [
1523
+ {"type": "tool_use", "id": c["id"], "name": c["name"], "input": _arguments(c)} for c in calls]
1524
+ reply["stop_reason"] = ANTHROPIC_STOP.get(turn.stop(), "end_turn")
1525
+ elif fmt == "responses":
1526
+ reply["output"] = (reply["output"] if said or not calls else []) + [
1527
+ _call_item(c, c["name"] in set(custom or ())) for c in calls]
1528
+ elif fmt == "gemini":
1529
+ candidate = reply["candidates"][0]
1530
+ if calls:
1531
+ candidate["content"]["parts"] = ([{"text": said}] if said else []) + _gemini_calls(turn)
1532
+ if turn.finish == "length":
1533
+ candidate["finishReason"] = "MAX_TOKENS"
1534
+ elif fmt == "ollama_chat" and calls:
1535
+ reply["message"]["tool_calls"] = _ollama_calls(turn)
1536
+ if fmt.startswith("ollama") and turn.finish == "length":
1537
+ reply["done_reason"] = "length"
1538
+ return reply
1539
+
1540
+
1541
+ # ---- the gateway's table ---------------------------------------------------
1542
+
1543
+ class Format(NamedTuple):
1544
+ """One inbound format: how a request is read, an answer written, an
1545
+ answer streamed and a refusal shaped."""
1546
+ ask: Callable[..., dict]
1547
+ reply: Callable[..., dict]
1548
+ stream: type
1549
+ error: Callable[[int, str], dict]
1550
+
1551
+
1552
+ INBOUND = {
1553
+ "chat": Format(chat_ask, chat_reply, ChatStream, chat_error),
1554
+ "completion": Format(completion_ask, completion_reply, CompletionStream, chat_error),
1555
+ "anthropic": Format(anthropic_ask, anthropic_reply, AnthropicStream, anthropic_error),
1556
+ "responses": Format(responses_ask, responses_reply, ResponsesStream, responses_error),
1557
+ "ollama_chat": Format(ollama_chat_ask, ollama_reply, OllamaStream, ollama_error),
1558
+ "ollama_generate": Format(ollama_generate_ask, ollama_generate_reply, OllamaGenerateStream, ollama_error),
1559
+ "gemini": Format(gemini_ask, gemini_reply, GeminiStream, gemini_error),
1560
+ }
1561
+
1562
+ # Each format's stream in route mode; completions and Ollama's generate carry no tools.
1563
+ ROUTE = {"chat": ChatRoute, "completion": CompletionStream, "anthropic": AnthropicRoute,
1564
+ "responses": ResponsesRoute, "ollama_chat": OllamaRoute, "ollama_generate": OllamaGenerateStream,
1565
+ "gemini": GeminiRoute}
1566
+
1567
+ OLLAMA_ROUTES = {"/api/chat":"ollama_chat", "/api/generate": "ollama_generate", "/api/show": "ollama_chat",
1568
+ "/api/tags": "ollama_chat", "/api/version": "ollama_chat"}
1569
+ POST_ROUTES = {"/v1/chat/completions": "chat", "/chat/completions": "chat", "/v1/completions": "completion",
1570
+ "/completions": "completion", "/v1/messages": "anthropic", "/v1/messages/count_tokens": "anthropic",
1571
+ "/v1/responses": "responses", "/responses": "responses",
1572
+ "/api/chat": "ollama_chat", "/api/generate": "ollama_generate"}
1573
+
1574
+
1575
+ def format_of(path: str) -> str:
1576
+ """Which format a route speaks, so even a refusal before the body is read
1577
+ (a missing key, a bad Host) comes back in a shape its client reads."""
1578
+ if path in OLLAMA_ROUTES:
1579
+ return OLLAMA_ROUTES[path]
1580
+ if path in POST_ROUTES:
1581
+ return POST_ROUTES[path]
1582
+ if path.startswith("/v1beta/") or gemini_route(path):
1583
+ return "gemini"
1584
+ return "chat"