@wendellresearch-ai/benchwarmers 0.2.1 → 0.2.2

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,1021 @@
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 will call
14
+ the native Anthropic, Gemini, Ollama and Responses APIs with them (not wired
15
+ yet; metrics/client.py speaks chat completions and Responses itself today):
16
+
17
+ inbound, the gateway answering a client outbound, the client calling an API
18
+ <fmt>_ask(body) native request -> Ask <fmt>_request(messages, ...) OpenAI messages -> native request
19
+ <fmt>_reply(text...) an answer -> native reply <fmt>_text(reply) native reply -> its text
20
+ <Fmt>Stream an answer as it comes -> <fmt>_delta(event) one native stream event -> its text
21
+ native stream bytes
22
+ <fmt>_error(status, message) -> native error body
23
+
24
+ An Ask is a dict: `model` (as the client named it), `messages` (OpenAI
25
+ style), `stream`, `options` (max_tokens, temperature, top_p, stop, when
26
+ given) and the bench's own `with` and `reply`, read from any format's body
27
+ (an SDK's extra_body). The gateway reads `options` but does not pass them
28
+ on: each seat uses its own call options (config/model-options.json).
29
+
30
+ WHAT IS REFUSED, NOT DROPPED
31
+ ----------------------------
32
+ A request carrying tools, or a conversation holding tool calls or results,
33
+ is refused (Unsupported, a 400) rather than answered as if the tools were not
34
+ there: a panel's reply is several models' text, not a tool call, and an agent
35
+ that waits for one would hang or loop. A route mode that proxies one model
36
+ with tools is planned. So is anything else that would be lost on the way: a
37
+ PDF or audio part, an uploaded file id, a stored previous response. A
38
+ model's own reasoning from an earlier turn (Anthropic thinking blocks,
39
+ Responses reasoning items, Gemini thought parts) is dropped: each seat
40
+ reasons for itself. Structured-output settings (Ollama `format`, Gemini's
41
+ responseSchema, Responses `text.format`) are ignored: a panel answers in
42
+ markdown.
43
+
44
+ Token counts in replies are estimates (characters / 4 plus 10%), as the
45
+ gateway's size check makes; durations are real.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import base64
51
+ import binascii
52
+ import datetime as dt
53
+ import hashlib
54
+ import json
55
+ import mimetypes
56
+ import secrets
57
+ import time
58
+ import urllib.parse
59
+ from typing import Callable, NamedTuple
60
+
61
+ TOOLS = ("this request carries tools or tool calls, and the bench gateway answers with a panel of models,"
62
+ " not a tool call, so tool use is not supported yet (a route mode that proxies one model, tools"
63
+ " and all, is coming). Send it without tools.")
64
+ REPLIES = ("all", "merged") # bench_collections.REPLIES; not imported, so this file stands alone
65
+ LEAD = ("system", "developer") # newer OpenAI SDKs send "developer" where older ones send "system"
66
+ # The Ollama API version whose shapes these follow. Clients check it to
67
+ # decide which fields to send; a lower one would have them hold back.
68
+ OLLAMA_VERSION = "0.12.0"
69
+ # Anthropic requires max_tokens; this is what the outbound request asks for
70
+ # when the caller set none (the seat's own option, in the built-in client).
71
+ ANTHROPIC_MAX_TOKENS = 8192
72
+ ANTHROPIC_ERRORS = {400: "invalid_request_error", 401: "authentication_error", 403: "permission_error",
73
+ 404: "not_found_error", 413: "request_too_large", 429: "rate_limit_error",
74
+ 503: "overloaded_error"}
75
+ GEMINI_STATUS = {400: "INVALID_ARGUMENT", 401: "UNAUTHENTICATED", 403: "PERMISSION_DENIED", 404: "NOT_FOUND",
76
+ 413: "INVALID_ARGUMENT", 415: "INVALID_ARGUMENT", 429: "RESOURCE_EXHAUSTED",
77
+ 503: "UNAVAILABLE"}
78
+
79
+
80
+ class Unsupported(ValueError):
81
+ """A request that cannot be put to a panel without losing part of it:
82
+ answered 400 with the reason, never silently trimmed."""
83
+
84
+
85
+ class UpstreamError(RuntimeError):
86
+ """An error a native API sent in place of an answer (outbound)."""
87
+
88
+
89
+ def estimate(text: str) -> int:
90
+ """Tokens in `text`: characters / 4 plus 10%, which errs high
91
+ (gateway.estimate_tokens says why). At least 1 for any text: a count
92
+ of 0 for "hi" reads as nothing sent."""
93
+ return max(1, int(len(text) / 4 * 1.1)) if text else 0
94
+
95
+
96
+ # ---- the parts every format shares ---------------------------------------
97
+
98
+ def image(url: str) -> dict:
99
+ """An OpenAI image part."""
100
+ return {"type": "image_url", "image_url": {"url": url}}
101
+
102
+
103
+ def text_part(text) -> dict:
104
+ return {"type": "text", "text": str(text or "")}
105
+
106
+
107
+ def data_url(mime: str, data: str) -> str:
108
+ return f"data:{mime};base64,{data}"
109
+
110
+
111
+ def split_data_url(url: str) -> tuple[str, str] | None:
112
+ """("image/png", "<base64>") for a base64 data: URL, else None."""
113
+ if url[:5].lower() != "data:":
114
+ return None
115
+ head, sep, data = url[5:].partition(",")
116
+ mime, _, enc = head.partition(";")
117
+ return (mime or "application/octet-stream", data) if sep and enc.lower() == "base64" else None
118
+
119
+
120
+ def sniff(b64: str) -> str:
121
+ """An image's type from its first bytes, for Ollama, which sends bare
122
+ base64 with no type. PNG when it cannot tell: every model reads one."""
123
+ try:
124
+ head = base64.b64decode(b64[:24] + "=" * (-len(b64[:24]) % 4), validate=False)
125
+ except (binascii.Error, ValueError):
126
+ return "image/png"
127
+ for magic, mime in ((b"\x89PNG", "image/png"), (b"\xff\xd8\xff", "image/jpeg"), (b"GIF8", "image/gif"),
128
+ (b"RIFF", "image/webp"), (b"BM", "image/bmp")):
129
+ if head.startswith(magic):
130
+ return mime
131
+ return "image/png"
132
+
133
+
134
+ def _image_mime(mime: str, where: str) -> str:
135
+ if not str(mime or "").lower().startswith("image/"):
136
+ raise Unsupported(f"{where} is {mime or 'of no type'}; only images can be passed to a panel")
137
+ return mime
138
+
139
+
140
+ def _content(parts: list[dict]) -> str | list[dict]:
141
+ """Text alone as one string, as most clients send it; with an image, the parts."""
142
+ if all(p["type"] == "text" for p in parts):
143
+ return "\n\n".join(p["text"] for p in parts)
144
+ return parts
145
+
146
+
147
+ def parts_of(content) -> list[dict]:
148
+ """OpenAI message content as a list of text and image_url parts."""
149
+ if content is None:
150
+ return []
151
+ if isinstance(content, str):
152
+ return [text_part(content)] if content else []
153
+ out = []
154
+ for p in content if isinstance(content, list) else ():
155
+ if not isinstance(p, dict):
156
+ continue
157
+ if p.get("type") == "image_url":
158
+ u = p.get("image_url")
159
+ out.append(image(u.get("url", "") if isinstance(u, dict) else str(u or "")))
160
+ elif p.get("type") in ("text", "input_text", "output_text"):
161
+ out.append(text_part(p.get("text")))
162
+ return out
163
+
164
+
165
+ def text_of(content) -> str:
166
+ return "\n\n".join(p["text"] for p in parts_of(content) if p["type"] == "text")
167
+
168
+
169
+ def _image_url(part: dict) -> str:
170
+ return part["image_url"]["url"]
171
+
172
+
173
+ def _no_tools(tools) -> None:
174
+ if tools:
175
+ raise Unsupported(TOOLS)
176
+
177
+
178
+ def _ask(body: dict, model, messages: list[dict], stream, options: dict) -> dict:
179
+ """The Ask every format's request becomes, with the bench's own fields
180
+ checked as POST /v1/chat/completions always checked them."""
181
+ if not isinstance(model, str):
182
+ raise Unsupported('"model" must be a collection name such as bench/core')
183
+ nominated, opts = body.get("with") or [], body.get("bench") or {}
184
+ if not (isinstance(nominated, list) and all(isinstance(a, str) for a in nominated)):
185
+ raise Unsupported('"with" must be a list of model aliases')
186
+ if not isinstance(opts, dict) or opts.get("reply", "all") not in REPLIES:
187
+ raise Unsupported('"bench" must be an object whose "reply" is "all" or "merged"')
188
+ stop = options.get("stop")
189
+ if isinstance(stop, str):
190
+ options = {**options, "stop": [stop]}
191
+ return {"model": model, "messages": messages, "stream": bool(stream),
192
+ "options": {k: v for k, v in options.items() if v is not None},
193
+ "with": nominated, "reply": opts.get("reply") or ""}
194
+
195
+
196
+ def _messages(value, what: str = '"messages"') -> list[dict]:
197
+ if not (isinstance(value, list) and all(isinstance(m, dict) for m in value)):
198
+ raise Unsupported(f"{what} must be a list of message objects")
199
+ return value
200
+
201
+
202
+ def _now() -> int:
203
+ return int(time.time())
204
+
205
+
206
+ def _new_id(prefix: str) -> str:
207
+ return prefix + secrets.token_hex(12)
208
+
209
+
210
+ def sse(data: dict | str, event: str = "") -> bytes:
211
+ """One server-sent event."""
212
+ payload = data if isinstance(data, str) else json.dumps(data, ensure_ascii=False)
213
+ return (f"event: {event}\n" if event else "").encode() + f"data: {payload}\n\n".encode()
214
+
215
+
216
+ class Stream:
217
+ """An answer sent as it comes, in one format's stream shape. Each method
218
+ returns the bytes to write: start() right after the 200, text() for each
219
+ piece, then end() or error(), and keepalive() while waiting, so a client
220
+ or proxy with an idle timeout keeps the connection."""
221
+ content_type = "text/event-stream"
222
+
223
+ def __init__(self, model: str, input_tokens: int = 0, **_):
224
+ self.model, self.input_tokens, self.said = model, input_tokens, []
225
+ self.started = time.monotonic()
226
+
227
+ def start(self) -> bytes:
228
+ return b""
229
+
230
+ def text(self, text: str) -> bytes:
231
+ raise NotImplementedError
232
+
233
+ def end(self, bench: dict | None = None) -> bytes:
234
+ raise NotImplementedError
235
+
236
+ def error(self, status: int, message: str) -> bytes:
237
+ raise NotImplementedError
238
+
239
+ def keepalive(self) -> bytes:
240
+ return b": keep-alive\n\n" # an SSE comment: every SSE parser skips it
241
+
242
+ def output_tokens(self) -> int:
243
+ return estimate("".join(self.said))
244
+
245
+
246
+ # ---- OpenAI chat completions -----------------------------------------------
247
+
248
+ def chat_ask(body: dict) -> dict:
249
+ """POST /v1/chat/completions. The messages are already OpenAI's; the
250
+ gateway's own image check reads them as they are."""
251
+ messages = _messages(body.get("messages"))
252
+ _no_tools(body.get("tools") or body.get("functions"))
253
+ if any(m.get("role") in ("tool", "function") or m.get("tool_calls") or m.get("function_call")
254
+ for m in messages):
255
+ raise Unsupported(TOOLS)
256
+ return _ask(body, body.get("model") or "", messages, body.get("stream"),
257
+ {"max_tokens": body.get("max_completion_tokens") or body.get("max_tokens"),
258
+ "temperature": body.get("temperature"), "top_p": body.get("top_p"), "stop": body.get("stop")})
259
+
260
+
261
+ def chat_reply(text: str, model: str, bench: dict | None = None, usage=(0, 0), seconds: float = 0, **_) -> dict:
262
+ """A chat.completion, as the gateway has always answered: no usage, the
263
+ bench beside the OpenAI fields (clients ignore keys they do not know)."""
264
+ return {"id": _new_id("chatcmpl-"), "object": "chat.completion", "created": _now(), "model": model,
265
+ "choices": [{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": text}}],
266
+ "bench": bench}
267
+
268
+
269
+ def chat_error(status: int, message: str) -> dict:
270
+ return {"error": {"message": message, "type": "bench_gateway", "code": status}}
271
+
272
+
273
+ class ChatStream(Stream):
274
+ """chat.completion.chunk events, then `data: [DONE]`. The bench rides on
275
+ the last chunk, the one with the finish_reason."""
276
+
277
+ def __init__(self, model: str, input_tokens: int = 0, **kw):
278
+ super().__init__(model, input_tokens, **kw)
279
+ self.id, self.created = _new_id("chatcmpl-"), _now()
280
+
281
+ def _chunk(self, delta: dict, finish=None, **extra) -> bytes:
282
+ return sse({"id": self.id, "object": "chat.completion.chunk", "created": self.created, "model": self.model,
283
+ "choices": [{"index": 0, "delta": delta, "finish_reason": finish}], **extra})
284
+
285
+ def start(self) -> bytes:
286
+ return self._chunk({"role": "assistant", "content": ""})
287
+
288
+ def text(self, text: str) -> bytes:
289
+ self.said.append(text)
290
+ return self._chunk({"content": text})
291
+
292
+ def end(self, bench=None) -> bytes:
293
+ return self._chunk({}, "stop", **({"bench": bench} if bench is not None else {})) + b"data: [DONE]\n\n"
294
+
295
+ def error(self, status: int, message: str) -> bytes:
296
+ # The OpenAI SDKs raise on a chunk whose data holds "error".
297
+ return sse(chat_error(status, message)) + b"data: [DONE]\n\n"
298
+
299
+
300
+ # ---- OpenAI legacy completions ---------------------------------------------
301
+
302
+ def completion_ask(body: dict) -> dict:
303
+ """POST /v1/completions: one prompt, as a user message."""
304
+ prompt = body.get("prompt")
305
+ if isinstance(prompt, list) and len(prompt) == 1 and isinstance(prompt[0], str):
306
+ prompt = prompt[0]
307
+ if not isinstance(prompt, str):
308
+ raise Unsupported('"prompt" must be one string (a list of prompts, or of token ids, is not supported)')
309
+ return _ask(body, body.get("model") or "", [{"role": "user", "content": prompt}], body.get("stream"),
310
+ {"max_tokens": body.get("max_tokens"), "temperature": body.get("temperature"),
311
+ "top_p": body.get("top_p"), "stop": body.get("stop")})
312
+
313
+
314
+ def completion_reply(text: str, model: str, bench=None, usage=(0, 0), seconds: float = 0, **_) -> dict:
315
+ return {"id": _new_id("cmpl-"), "object": "text_completion", "created": _now(), "model": model,
316
+ "choices": [{"text": text, "index": 0, "logprobs": None, "finish_reason": "stop"}],
317
+ "usage": {"prompt_tokens": usage[0], "completion_tokens": usage[1], "total_tokens": sum(usage)},
318
+ "bench": bench}
319
+
320
+
321
+ class CompletionStream(ChatStream):
322
+ def _chunk(self, delta: dict, finish=None, **extra) -> bytes:
323
+ return sse({"id": self.id, "object": "text_completion", "created": self.created, "model": self.model,
324
+ "choices": [{"text": delta.get("content", ""), "index": 0, "logprobs": None,
325
+ "finish_reason": finish}], **extra})
326
+
327
+ def start(self) -> bytes:
328
+ return b""
329
+
330
+
331
+ # ---- Anthropic Messages ----------------------------------------------------
332
+
333
+ def _anthropic_blocks(content, where: str) -> str | list[dict]:
334
+ if isinstance(content, str):
335
+ return content
336
+ if not isinstance(content, list):
337
+ raise Unsupported(f"{where}: content must be a string or a list of content blocks")
338
+ parts = []
339
+ for b in content:
340
+ kind = b.get("type") if isinstance(b, dict) else None
341
+ if kind == "text":
342
+ parts.append(text_part(b.get("text")))
343
+ elif kind == "image":
344
+ src = b.get("source") if isinstance(b.get("source"), dict) else {}
345
+ if src.get("type") == "base64":
346
+ data = str(src.get("data") or "")
347
+ parts.append(image(data_url(_image_mime(src.get("media_type") or sniff(data), "an image block"), data)))
348
+ elif src.get("type") == "url":
349
+ parts.append(image(str(src.get("url") or "")))
350
+ else:
351
+ raise Unsupported(f"an image source of type {src.get('type')!r} cannot be passed on;"
352
+ " send it as base64 or a url")
353
+ elif isinstance(kind, str) and kind.endswith(("tool_use", "tool_result")):
354
+ raise Unsupported(TOOLS)
355
+ elif kind in ("thinking", "redacted_thinking"):
356
+ continue # a model's own reasoning from an earlier turn
357
+ else:
358
+ raise Unsupported(f"a {kind!r} content block is not supported here; send text and images")
359
+ return _content(parts)
360
+
361
+
362
+ def anthropic_ask(body: dict) -> dict:
363
+ """POST /v1/messages."""
364
+ _no_tools(body.get("tools"))
365
+ out = []
366
+ if body.get("system"):
367
+ out.append({"role": "system", "content": text_of(_anthropic_blocks(body["system"], "system"))})
368
+ for m in _messages(body.get("messages")):
369
+ if m.get("role") not in ("user", "assistant"):
370
+ raise Unsupported(f'a message\'s "role" must be "user" or "assistant", not {m.get("role")!r}')
371
+ out.append({"role": m["role"], "content": _anthropic_blocks(m.get("content"), m["role"])})
372
+ return _ask(body, body.get("model") or "", out, body.get("stream"),
373
+ {"max_tokens": body.get("max_tokens"), "temperature": body.get("temperature"),
374
+ "top_p": body.get("top_p"), "stop": body.get("stop_sequences")})
375
+
376
+
377
+ def anthropic_reply(text: str, model: str, bench=None, usage=(0, 0), seconds: float = 0, **_) -> dict:
378
+ return {"id": _new_id("msg_"), "type": "message", "role": "assistant", "model": model,
379
+ "content": [{"type": "text", "text": text}], "stop_reason": "end_turn", "stop_sequence": None,
380
+ "usage": {"input_tokens": usage[0], "output_tokens": usage[1]}, "bench": bench}
381
+
382
+
383
+ def anthropic_error(status: int, message: str) -> dict:
384
+ return {"type": "error", "error": {"type": ANTHROPIC_ERRORS.get(status, "api_error"), "message": message}}
385
+
386
+
387
+ class AnthropicStream(Stream):
388
+ """message_start, one text block's deltas, message_delta (the bench
389
+ beside its usage), message_stop; `ping` while waiting, as the API does."""
390
+
391
+ def start(self) -> bytes:
392
+ message = {"id": _new_id("msg_"), "type": "message", "role": "assistant", "model": self.model,
393
+ "content": [], "stop_reason": None, "stop_sequence": None,
394
+ "usage": {"input_tokens": self.input_tokens, "output_tokens": 0}}
395
+ return (sse({"type": "message_start", "message": message}, "message_start")
396
+ + sse({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
397
+ "content_block_start"))
398
+
399
+ def text(self, text: str) -> bytes:
400
+ self.said.append(text)
401
+ return sse({"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text}},
402
+ "content_block_delta")
403
+
404
+ def end(self, bench=None) -> bytes:
405
+ delta = {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None},
406
+ "usage": {"output_tokens": self.output_tokens()}, **({"bench": bench} if bench is not None else {})}
407
+ return (sse({"type": "content_block_stop", "index": 0}, "content_block_stop")
408
+ + sse(delta, "message_delta") + sse({"type": "message_stop"}, "message_stop"))
409
+
410
+ def error(self, status: int, message: str) -> bytes:
411
+ return sse(anthropic_error(status, message), "error")
412
+
413
+ def keepalive(self) -> bytes:
414
+ return sse({"type": "ping"}, "ping")
415
+
416
+
417
+ def _anthropic_content(content) -> str | list[dict]:
418
+ if isinstance(content, str):
419
+ return content
420
+ out = []
421
+ for p in parts_of(content):
422
+ if p["type"] == "text":
423
+ out.append({"type": "text", "text": p["text"]})
424
+ elif (data := split_data_url(_image_url(p))):
425
+ out.append({"type": "image", "source": {"type": "base64", "media_type": data[0], "data": data[1]}})
426
+ else:
427
+ out.append({"type": "image", "source": {"type": "url", "url": _image_url(p)}})
428
+ return out
429
+
430
+
431
+ def _turns(messages: list[dict]) -> tuple[str, list[dict]]:
432
+ """(system text, the other turns) of OpenAI messages, for APIs that keep
433
+ the system text apart; refuses tool turns, which have no place there."""
434
+ if any(m.get("role") in ("tool", "function") or m.get("tool_calls") for m in messages):
435
+ raise Unsupported(TOOLS)
436
+ system = "\n\n".join(text_of(m.get("content")) for m in messages if m.get("role") in LEAD)
437
+ return system, [m for m in messages if m.get("role") not in LEAD]
438
+
439
+
440
+ def anthropic_request(messages: list[dict], model: str, options: dict | None = None, stream: bool = False) -> dict:
441
+ """OpenAI messages as an Anthropic Messages request (POST /v1/messages)."""
442
+ options = options or {}
443
+ system, turns = _turns(messages)
444
+ body = {"model": model, "max_tokens": options.get("max_tokens") or ANTHROPIC_MAX_TOKENS,
445
+ "messages": [{"role": "assistant" if m.get("role") == "assistant" else "user",
446
+ "content": _anthropic_content(m.get("content"))} for m in turns]}
447
+ if system:
448
+ body["system"] = system
449
+ for ours, theirs in (("temperature", "temperature"), ("top_p", "top_p"), ("stop", "stop_sequences")):
450
+ if options.get(ours) is not None:
451
+ body[theirs] = options[ours]
452
+ return {**body, **({"stream": True} if stream else {})}
453
+
454
+
455
+ def anthropic_text(reply: dict) -> str:
456
+ """The text of an Anthropic Messages reply; UpstreamError for an error."""
457
+ if reply.get("type") == "error":
458
+ raise UpstreamError(str((reply.get("error") or {}).get("message") or reply))
459
+ return "".join(b.get("text", "") for b in reply.get("content") or [] if b.get("type") == "text")
460
+
461
+
462
+ def anthropic_delta(event: dict) -> str:
463
+ """The text one Anthropic stream event adds ("" for the others)."""
464
+ if event.get("type") == "error":
465
+ raise UpstreamError(str((event.get("error") or {}).get("message") or event))
466
+ delta = event.get("delta") or {}
467
+ return delta.get("text", "") if event.get("type") == "content_block_delta" and delta.get("type") == "text_delta" else ""
468
+
469
+
470
+ # ---- OpenAI Responses ------------------------------------------------------
471
+
472
+ def _responses_parts(content, where: str) -> str | list[dict]:
473
+ if isinstance(content, str):
474
+ return content
475
+ if not isinstance(content, list):
476
+ raise Unsupported(f"{where}: content must be a string or a list of parts")
477
+ parts = []
478
+ for p in content:
479
+ kind = p.get("type") if isinstance(p, dict) else None
480
+ if kind in ("input_text", "output_text", "text", "refusal"):
481
+ parts.append(text_part(p.get("text") if kind != "refusal" else p.get("refusal")))
482
+ elif kind == "input_image":
483
+ url = p.get("image_url")
484
+ url = url.get("url") if isinstance(url, dict) else url
485
+ if not url:
486
+ raise Unsupported("an input_image must carry an image_url (a file_id names an upload this"
487
+ " gateway cannot reach)")
488
+ parts.append(image(str(url)))
489
+ else:
490
+ raise Unsupported(f"a {kind!r} input part is not supported here; send text and images")
491
+ return _content(parts)
492
+
493
+
494
+ def responses_ask(body: dict) -> dict:
495
+ """POST /v1/responses. Nothing is stored, so a previous_response_id (or
496
+ a stored conversation) names nothing here."""
497
+ _no_tools(body.get("tools"))
498
+ if body.get("previous_response_id") or body.get("conversation"):
499
+ raise Unsupported("responses are never stored here, so previous_response_id and conversation name"
500
+ " nothing; send the whole conversation in input")
501
+ out = []
502
+ if body.get("instructions"):
503
+ out.append({"role": "system", "content": str(body["instructions"])})
504
+ given = body.get("input")
505
+ if isinstance(given, str):
506
+ out.append({"role": "user", "content": given})
507
+ elif isinstance(given, list):
508
+ for item in given:
509
+ if not isinstance(item, dict):
510
+ raise Unsupported('"input" must be a string or a list of items')
511
+ kind = item.get("type") or "message"
512
+ if kind == "reasoning":
513
+ continue # a model's own reasoning from an earlier turn
514
+ if kind != "message":
515
+ raise Unsupported(TOOLS if "call" in kind or "tool" in kind else
516
+ f"an input item of type {kind!r} is not supported here; send messages")
517
+ role = item.get("role")
518
+ if role not in ("user", "assistant", "system", "developer"):
519
+ raise Unsupported(f"an input message's role must be user, assistant, system or developer, not {role!r}")
520
+ out.append({"role": role, "content": _responses_parts(item.get("content"), role)})
521
+ else:
522
+ raise Unsupported('"input" must be a string or a list of items')
523
+ return _ask(body, body.get("model") or "", out, body.get("stream"),
524
+ {"max_tokens": body.get("max_output_tokens"), "temperature": body.get("temperature"),
525
+ "top_p": body.get("top_p")})
526
+
527
+
528
+ def _response(rid: str, model: str, status: str, output: list, usage=(0, 0), created: int = 0, **extra) -> dict:
529
+ return {"id": rid, "object": "response", "created_at": created or _now(), "status": status,
530
+ "error": None, "incomplete_details": None, "instructions": None, "max_output_tokens": None,
531
+ "model": model, "output": output, "parallel_tool_calls": False, "previous_response_id": None,
532
+ "reasoning": {"effort": None, "summary": None}, "store": False, "temperature": None,
533
+ "text": {"format": {"type": "text"}}, "tool_choice": "none", "tools": [], "top_p": None,
534
+ "truncation": "disabled", "metadata": {},
535
+ "usage": {"input_tokens": usage[0], "input_tokens_details": {"cached_tokens": 0},
536
+ "output_tokens": usage[1], "output_tokens_details": {"reasoning_tokens": 0},
537
+ "total_tokens": sum(usage)}, **extra}
538
+
539
+
540
+ def _message_item(mid: str, text: str, status: str = "completed") -> dict:
541
+ return {"type": "message", "id": mid, "status": status, "role": "assistant",
542
+ "content": [{"type": "output_text", "text": text, "annotations": []}] if status == "completed" else []}
543
+
544
+
545
+ def responses_reply(text: str, model: str, bench=None, usage=(0, 0), seconds: float = 0, **_) -> dict:
546
+ return _response(_new_id("resp_"), model, "completed", [_message_item(_new_id("msg_"), text)], usage,
547
+ bench=bench)
548
+
549
+
550
+ def responses_error(status: int, message: str) -> dict:
551
+ return {"error": {"message": message, "type": "invalid_request_error" if status < 500 else "server_error",
552
+ "param": None, "code": str(status)}}
553
+
554
+
555
+ class ResponsesStream(Stream):
556
+ """The Responses API's typed events, numbered; response.completed
557
+ carries the whole response, the bench in it."""
558
+
559
+ def __init__(self, model: str, input_tokens: int = 0, **kw):
560
+ super().__init__(model, input_tokens, **kw)
561
+ self.rid, self.mid, self.created, self.seq = _new_id("resp_"), _new_id("msg_"), _now(), 0
562
+
563
+ def _event(self, kind: str, **data) -> bytes:
564
+ out = sse({"type": kind, "sequence_number": self.seq, **data}, kind)
565
+ self.seq += 1
566
+ return out
567
+
568
+ def _where(self) -> dict:
569
+ return {"item_id": self.mid, "output_index": 0, "content_index": 0}
570
+
571
+ def start(self) -> bytes:
572
+ pending = _response(self.rid, self.model, "in_progress", [], created=self.created)
573
+ part = {"type": "output_text", "text": "", "annotations": []}
574
+ return (self._event("response.created", response=pending)
575
+ + self._event("response.in_progress", response=pending)
576
+ + self._event("response.output_item.added", output_index=0,
577
+ item=_message_item(self.mid, "", "in_progress"))
578
+ + self._event("response.content_part.added", **self._where(), part=part))
579
+
580
+ def text(self, text: str) -> bytes:
581
+ self.said.append(text)
582
+ return self._event("response.output_text.delta", **self._where(), delta=text, logprobs=[])
583
+
584
+ def end(self, bench=None) -> bytes:
585
+ text = "".join(self.said)
586
+ item = _message_item(self.mid, text)
587
+ done = _response(self.rid, self.model, "completed", [item], (self.input_tokens, self.output_tokens()),
588
+ self.created, **({"bench": bench} if bench is not None else {}))
589
+ return (self._event("response.output_text.done", **self._where(), text=text, logprobs=[])
590
+ + self._event("response.content_part.done", **self._where(), part=item["content"][0])
591
+ + self._event("response.output_item.done", output_index=0, item=item)
592
+ + self._event("response.completed", response=done))
593
+
594
+ def error(self, status: int, message: str) -> bytes:
595
+ failed = _response(self.rid, self.model, "failed", [], created=self.created)
596
+ failed["error"] = {"code": "server_error" if status >= 500 else "invalid_request", "message": message}
597
+ return (self._event("error", code=str(status), message=message, param=None)
598
+ + self._event("response.failed", response=failed))
599
+
600
+
601
+ def responses_request(messages: list[dict], model: str, options: dict | None = None, stream: bool = False) -> dict:
602
+ """OpenAI messages as a Responses request (POST /v1/responses), never stored."""
603
+ options = options or {}
604
+ system, turns = _turns(messages)
605
+ items = []
606
+ for m in turns:
607
+ mine = m.get("role") == "assistant"
608
+ items.append({"role": "assistant" if mine else "user", "content": [
609
+ {"type": "output_text" if mine else "input_text", "text": p["text"]} if p["type"] == "text"
610
+ else {"type": "input_image", "image_url": _image_url(p)} for p in parts_of(m.get("content"))]})
611
+ body = {"model": model, "input": items, "store": False}
612
+ if system:
613
+ body["instructions"] = system
614
+ for ours, theirs in (("max_tokens", "max_output_tokens"), ("temperature", "temperature"), ("top_p", "top_p")):
615
+ if options.get(ours) is not None:
616
+ body[theirs] = options[ours]
617
+ return {**body, **({"stream": True} if stream else {})}
618
+
619
+
620
+ def responses_text(reply: dict) -> str:
621
+ if reply.get("error") and reply.get("status") != "completed":
622
+ raise UpstreamError(str(reply["error"].get("message") if isinstance(reply["error"], dict) else reply["error"]))
623
+ return "".join(c.get("text", "") for item in reply.get("output") or [] if item.get("type") == "message"
624
+ for c in item.get("content") or [] if c.get("type") == "output_text")
625
+
626
+
627
+ def responses_delta(event: dict) -> str:
628
+ kind = event.get("type")
629
+ if kind == "error":
630
+ raise UpstreamError(str(event.get("message") or event))
631
+ if kind == "response.failed":
632
+ raise UpstreamError(str(((event.get("response") or {}).get("error") or {}).get("message") or event))
633
+ return event.get("delta", "") if kind == "response.output_text.delta" else ""
634
+
635
+
636
+ # ---- Ollama ----------------------------------------------------------------
637
+
638
+ def ollama_model(model) -> str:
639
+ """Ollama names carry a tag, `:latest` when none is given; a collection's
640
+ tag is its reply mode (bench/core:merged), and `latest` is no mode."""
641
+ return model.removesuffix(":latest") if isinstance(model, str) else model
642
+
643
+
644
+ def _ollama_parts(text, pictures, where: str) -> str | list[dict]:
645
+ if pictures is not None and not (isinstance(pictures, list) and all(isinstance(p, str) for p in pictures)):
646
+ raise Unsupported(f"{where}: images must be a list of base64 strings")
647
+ parts = [text_part(text)] if text else []
648
+ parts += [image(data_url(sniff(p), p)) for p in pictures or []]
649
+ return _content(parts) if parts else ""
650
+
651
+
652
+ def _ollama_options(body: dict) -> dict:
653
+ o = body.get("options") if isinstance(body.get("options"), dict) else {}
654
+ return {"max_tokens": o.get("num_predict"), "temperature": o.get("temperature"), "top_p": o.get("top_p"),
655
+ "stop": o.get("stop")}
656
+
657
+
658
+ def ollama_chat_ask(body: dict) -> dict:
659
+ """POST /api/chat. Ollama streams unless told not to."""
660
+ _no_tools(body.get("tools"))
661
+ out = []
662
+ for m in _messages(body.get("messages")):
663
+ if m.get("role") == "tool" or m.get("tool_calls"):
664
+ raise Unsupported(TOOLS)
665
+ if m.get("role") not in ("system", "user", "assistant"):
666
+ raise Unsupported(f'a message\'s "role" must be system, user or assistant, not {m.get("role")!r}')
667
+ out.append({"role": m["role"], "content": _ollama_parts(m.get("content"), m.get("images"), m["role"])})
668
+ return _ask(body, ollama_model(body.get("model") or ""), out, body.get("stream", True), _ollama_options(body))
669
+
670
+
671
+ def ollama_generate_ask(body: dict) -> dict:
672
+ """POST /api/generate: a prompt and an optional system text."""
673
+ if not isinstance(body.get("prompt", ""), str):
674
+ raise Unsupported('"prompt" must be a string')
675
+ if body.get("context"):
676
+ raise Unsupported('"context" carries a model\'s own token state, which a panel cannot take up;'
677
+ " send the conversation to /api/chat instead")
678
+ out = [{"role": "system", "content": str(body["system"])}] if body.get("system") else []
679
+ out.append({"role": "user", "content": _ollama_parts(body.get("prompt"), body.get("images"), "prompt")})
680
+ return _ask(body, ollama_model(body.get("model") or ""), out, body.get("stream", True), _ollama_options(body))
681
+
682
+
683
+ def _ollama_at() -> str:
684
+ return dt.datetime.now(dt.timezone.utc).isoformat(timespec="microseconds").replace("+00:00", "Z")
685
+
686
+
687
+ def _ollama_done(usage, seconds: float) -> dict:
688
+ # Real durations, in nanoseconds: clients divide by them for tokens a second.
689
+ ns = max(1, int(seconds * 1e9))
690
+ return {"done": True, "done_reason": "stop", "total_duration": ns, "load_duration": 0,
691
+ "prompt_eval_count": usage[0], "prompt_eval_duration": 0, "eval_count": usage[1], "eval_duration": ns}
692
+
693
+
694
+ def ollama_reply(text: str, model: str, bench=None, usage=(0, 0), seconds: float = 0, generate: bool = False, **_) -> dict:
695
+ said = {"response": text} if generate else {"message": {"role": "assistant", "content": text}}
696
+ return {"model": model, "created_at": _ollama_at(), **said, **_ollama_done(usage, seconds), "bench": bench}
697
+
698
+
699
+ def ollama_generate_reply(text: str, model: str, bench=None, usage=(0, 0), seconds: float = 0, **_) -> dict:
700
+ return ollama_reply(text, model, bench, usage, seconds, generate=True)
701
+
702
+
703
+ def ollama_error(status: int, message: str) -> dict:
704
+ return {"error": message}
705
+
706
+
707
+ class OllamaStream(Stream):
708
+ """One JSON object a line; the last says done, the bench in it. While
709
+ waiting, a line that adds nothing: NDJSON has no comments."""
710
+ content_type = "application/x-ndjson"
711
+ generate = False
712
+
713
+ def _line(self, text: str, **extra) -> bytes:
714
+ said = {"response": text} if self.generate else {"message": {"role": "assistant", "content": text}}
715
+ return (json.dumps({"model": self.model, "created_at": _ollama_at(), **said, "done": False, **extra},
716
+ ensure_ascii=False) + "\n").encode()
717
+
718
+ def text(self, text: str) -> bytes:
719
+ self.said.append(text)
720
+ return self._line(text)
721
+
722
+ def end(self, bench=None) -> bytes:
723
+ done = _ollama_done((self.input_tokens, self.output_tokens()), time.monotonic() - self.started)
724
+ return self._line("", **done, **({"bench": bench} if bench is not None else {}))
725
+
726
+ def error(self, status: int, message: str) -> bytes:
727
+ return (json.dumps({"error": message}, ensure_ascii=False) + "\n").encode()
728
+
729
+ def keepalive(self) -> bytes:
730
+ return self._line("")
731
+
732
+
733
+ class OllamaGenerateStream(OllamaStream):
734
+ generate = True
735
+
736
+
737
+ def ollama_tags(models: list[tuple[str, dict]]) -> dict:
738
+ """GET /api/tags: each (name, description) as an Ollama model."""
739
+ at = _ollama_at()
740
+ return {"models": [{"name": n, "model": n, "modified_at": at, "size": 0,
741
+ "digest": hashlib.sha256(n.encode()).hexdigest(), "details": _ollama_details(d)}
742
+ for n, d in models]}
743
+
744
+
745
+ def _ollama_details(described: dict) -> dict:
746
+ return {"parent_model": "", "format": "", "family": "benchwarmers", "families": ["benchwarmers"],
747
+ "parameter_size": "", "quantization_level": ""}
748
+
749
+
750
+ def ollama_show(name: str, described: dict) -> dict:
751
+ """POST /api/show: what a client reads to decide what to send (context
752
+ length, whether it takes images)."""
753
+ together = described.get("together") or {}
754
+ caps = together.get("capabilities") or []
755
+ info = {"general.architecture": "benchwarmers", "general.basename": name}
756
+ if together.get("context_window"):
757
+ info["benchwarmers.context_length"] = together["context_window"]
758
+ return {"modelfile": f"# {described.get('description') or name}", "parameters": "", "template": "{{ .Prompt }}",
759
+ "details": _ollama_details(described), "model_info": info, "modified_at": _ollama_at(),
760
+ "capabilities": ["completion"] + (["vision"] if "vision" in caps else [])}
761
+
762
+
763
+ def ollama_request(messages: list[dict], model: str, options: dict | None = None, stream: bool = False) -> dict:
764
+ """OpenAI messages as an Ollama /api/chat request. Ollama takes images
765
+ only as base64, so an http(s) image is refused, not fetched here."""
766
+ out = []
767
+ for m in messages:
768
+ if m.get("role") in ("tool", "function") or m.get("tool_calls"):
769
+ raise Unsupported(TOOLS)
770
+ parts = parts_of(m.get("content"))
771
+ pictures = []
772
+ for p in parts:
773
+ if p["type"] == "image_url":
774
+ data = split_data_url(_image_url(p))
775
+ if not data:
776
+ raise Unsupported("Ollama takes images as base64 only; send a data: URL")
777
+ pictures.append(data[1])
778
+ role = "system" if m.get("role") in LEAD else m.get("role", "user")
779
+ out.append({"role": role, "content": "\n\n".join(p["text"] for p in parts if p["type"] == "text"),
780
+ **({"images": pictures} if pictures else {})})
781
+ opts = {theirs: options[ours] for ours, theirs in (("max_tokens", "num_predict"), ("temperature", "temperature"),
782
+ ("top_p", "top_p"), ("stop", "stop"))
783
+ if options and options.get(ours) is not None}
784
+ return {"model": model, "messages": out, "stream": stream, **({"options": opts} if opts else {})}
785
+
786
+
787
+ def ollama_text(reply: dict) -> str:
788
+ if reply.get("error"):
789
+ raise UpstreamError(str(reply["error"]))
790
+ return reply["response"] if "response" in reply else str((reply.get("message") or {}).get("content") or "")
791
+
792
+
793
+ ollama_delta = ollama_text # each NDJSON line is a reply's shape, with less in it
794
+
795
+
796
+ # ---- Gemini ----------------------------------------------------------------
797
+
798
+ def _get(d: dict, camel: str, snake: str):
799
+ """A field by either spelling: Gemini's REST API takes both."""
800
+ return d.get(camel, d.get(snake)) if isinstance(d, dict) else None
801
+
802
+
803
+ def _gemini_parts(parts, where: str) -> str | list[dict]:
804
+ if isinstance(parts, dict):
805
+ parts = [parts]
806
+ if not isinstance(parts, list):
807
+ raise Unsupported(f"{where}: parts must be a list")
808
+ out = []
809
+ for p in parts:
810
+ if not isinstance(p, dict):
811
+ raise Unsupported(f"{where}: each part must be an object")
812
+ if p.get("thought"):
813
+ continue # a model's own reasoning from an earlier turn
814
+ if "text" in p:
815
+ out.append(text_part(p["text"]))
816
+ elif (blob := _get(p, "inlineData", "inline_data")) is not None:
817
+ mime = _image_mime(_get(blob, "mimeType", "mime_type"), "an inlineData part")
818
+ out.append(image(data_url(mime, str(blob.get("data") or ""))))
819
+ elif (ref := _get(p, "fileData", "file_data")) is not None:
820
+ uri = str(_get(ref, "fileUri", "file_uri") or "")
821
+ _image_mime(_get(ref, "mimeType", "mime_type") or mimetypes.guess_type(uri)[0], "a fileData part")
822
+ if not uri.lower().startswith(("https://", "http://")):
823
+ raise Unsupported("a fileData part must name an image at an http(s) URL; an upload to"
824
+ " Google's Files API cannot be read by other models")
825
+ out.append(image(uri))
826
+ elif any(k in p for k in ("functionCall", "function_call", "functionResponse", "function_response",
827
+ "executableCode", "executable_code", "codeExecutionResult",
828
+ "code_execution_result")):
829
+ raise Unsupported(TOOLS)
830
+ else:
831
+ raise Unsupported(f"{where}: a part with {', '.join(sorted(p)) or 'nothing'} is not supported here;"
832
+ " send text and images")
833
+ return _content(out)
834
+
835
+
836
+ def gemini_ask(body: dict, model: str, stream: bool = False) -> dict:
837
+ """POST /v1beta/models/{model}:generateContent (or :streamGenerateContent):
838
+ the model is in the path, and so is whether it streams."""
839
+ _no_tools(body.get("tools"))
840
+ contents = body.get("contents")
841
+ if isinstance(contents, dict):
842
+ contents = [contents]
843
+ if not (isinstance(contents, list) and all(isinstance(c, dict) for c in contents)):
844
+ raise Unsupported('"contents" must be a list of content objects')
845
+ out = []
846
+ if (system := _get(body, "systemInstruction", "system_instruction")):
847
+ out.append({"role": "system", "content": text_of(_gemini_parts(
848
+ system.get("parts") if isinstance(system, dict) else [{"text": str(system)}], "systemInstruction"))})
849
+ for c in contents:
850
+ role = c.get("role") or "user"
851
+ if role == "function":
852
+ raise Unsupported(TOOLS)
853
+ if role not in ("user", "model"):
854
+ raise Unsupported(f'a content\'s "role" must be "user" or "model", not {role!r}')
855
+ out.append({"role": "assistant" if role == "model" else "user", "content": _gemini_parts(c.get("parts"), role)})
856
+ config = _get(body, "generationConfig", "generation_config") or {}
857
+ return _ask(body, model, out, stream,
858
+ {"max_tokens": _get(config, "maxOutputTokens", "max_output_tokens"),
859
+ "temperature": config.get("temperature"), "top_p": _get(config, "topP", "top_p"),
860
+ "stop": _get(config, "stopSequences", "stop_sequences")})
861
+
862
+
863
+ def _gemini_chunk(text: str, model: str, rid: str, usage=None, finish: bool = False) -> dict:
864
+ candidate = {"content": {"role": "model", "parts": [{"text": text}]}, "index": 0,
865
+ **({"finishReason": "STOP"} if finish else {})}
866
+ out = {"candidates": [candidate], "modelVersion": model, "responseId": rid}
867
+ if usage is not None:
868
+ out["usageMetadata"] = {"promptTokenCount": usage[0], "candidatesTokenCount": usage[1],
869
+ "totalTokenCount": sum(usage)}
870
+ return out
871
+
872
+
873
+ def gemini_reply(text: str, model: str, bench=None, usage=(0, 0), seconds: float = 0, **_) -> dict:
874
+ """No bench field: Google's SDK refuses fields it does not know, so the
875
+ gateway sends the panel's call ids in headers instead (bench_headers)."""
876
+ return _gemini_chunk(text, model, _new_id(""), usage, finish=True)
877
+
878
+
879
+ def gemini_error(status: int, message: str) -> dict:
880
+ return {"error": {"code": status, "message": message, "status": GEMINI_STATUS.get(status, "INTERNAL")}}
881
+
882
+
883
+ class GeminiStream(Stream):
884
+ """GenerateContentResponse chunks: as server-sent events with ?alt=sse
885
+ (what Google's SDKs ask for), else one JSON array sent as it grows. A
886
+ keep-alive is a chunk with no text, which every client takes in stride:
887
+ the SDKs' readers take any line that is not `data:` for an error."""
888
+
889
+ def __init__(self, model: str, input_tokens: int = 0, sse: bool = True, **kw):
890
+ super().__init__(model, input_tokens, **kw)
891
+ self.sse, self.rid, self.first = sse, _new_id(""), True
892
+ if not sse:
893
+ self.content_type = "application/json"
894
+
895
+ def _send(self, data: dict, last: bool = False) -> bytes:
896
+ if self.sse:
897
+ return sse(data)
898
+ lead, self.first = ("[" if self.first else ",\r\n"), False
899
+ return (lead + json.dumps(data, ensure_ascii=False) + ("]" if last else "")).encode()
900
+
901
+ def text(self, text: str) -> bytes:
902
+ self.said.append(text)
903
+ return self._send(_gemini_chunk(text, self.model, self.rid))
904
+
905
+ def end(self, bench=None) -> bytes:
906
+ return self._send(_gemini_chunk("", self.model, self.rid, (self.input_tokens, self.output_tokens()), True),
907
+ last=True)
908
+
909
+ def error(self, status: int, message: str) -> bytes:
910
+ return self._send(gemini_error(status, message), last=True)
911
+
912
+ def keepalive(self) -> bytes:
913
+ return self._send(_gemini_chunk("", self.model, self.rid))
914
+
915
+
916
+ def gemini_models(models: list[tuple[str, dict]]) -> dict:
917
+ """GET /v1beta/models: each (name, description) as a Gemini model."""
918
+ return {"models": [gemini_model(n, d) for n, d in models]}
919
+
920
+
921
+ def gemini_model(name: str, described: dict) -> dict:
922
+ together = described.get("together") or {}
923
+ return {"name": f"models/{name}", "baseModelId": name, "version": "1", "displayName": name,
924
+ "description": described.get("description") or "",
925
+ **({"inputTokenLimit": together["context_window"]} if together.get("context_window") else {}),
926
+ "supportedGenerationMethods": ["generateContent", "streamGenerateContent", "countTokens"]}
927
+
928
+
929
+ def gemini_route(path: str) -> tuple[str, str] | None:
930
+ """(model, method) of a Gemini POST path, `/v1beta/models/bench/core:generateContent`
931
+ -> ("bench/core", "generateContent"); the model may be URL-encoded. None if it is not one."""
932
+ for root in ("/v1beta/models/", "/v1/models/"):
933
+ if path.startswith(root):
934
+ model, sep, method = urllib.parse.unquote(path[len(root):]).rpartition(":")
935
+ if sep and model:
936
+ return model, method
937
+ return None
938
+
939
+
940
+ def gemini_request(messages: list[dict], options: dict | None = None) -> dict:
941
+ """OpenAI messages as a Gemini generateContent body (the model goes in the
942
+ path: gemini_path)."""
943
+ options = options or {}
944
+ system, turns = _turns(messages)
945
+ contents = []
946
+ for m in turns:
947
+ parts = []
948
+ for p in parts_of(m.get("content")):
949
+ if p["type"] == "text":
950
+ parts.append({"text": p["text"]})
951
+ elif (data := split_data_url(_image_url(p))):
952
+ parts.append({"inlineData": {"mimeType": data[0], "data": data[1]}})
953
+ else:
954
+ url = _image_url(p)
955
+ parts.append({"fileData": {"mimeType": mimetypes.guess_type(urllib.parse.urlsplit(url).path)[0]
956
+ or "image/jpeg", "fileUri": url}})
957
+ contents.append({"role": "model" if m.get("role") == "assistant" else "user", "parts": parts})
958
+ body = {"contents": contents}
959
+ if system:
960
+ body["systemInstruction"] = {"parts": [{"text": system}]}
961
+ config = {theirs: options[ours] for ours, theirs in (("max_tokens", "maxOutputTokens"), ("temperature", "temperature"),
962
+ ("top_p", "topP"), ("stop", "stopSequences"))
963
+ if options.get(ours) is not None}
964
+ return {**body, **({"generationConfig": config} if config else {})}
965
+
966
+
967
+ def gemini_path(model: str, stream: bool = False) -> str:
968
+ """The path a generateContent call goes to, under the API's base URL."""
969
+ return f"/v1beta/models/{model}:" + ("streamGenerateContent?alt=sse" if stream else "generateContent")
970
+
971
+
972
+ def gemini_text(reply: dict) -> str:
973
+ if reply.get("error"):
974
+ raise UpstreamError(str(reply["error"].get("message") if isinstance(reply["error"], dict) else reply["error"]))
975
+ candidates = reply.get("candidates") or [{}]
976
+ return "".join(p.get("text", "") for p in ((candidates[0].get("content") or {}).get("parts") or [])
977
+ if not p.get("thought"))
978
+
979
+
980
+ gemini_delta = gemini_text # each streamed chunk is a reply's shape
981
+
982
+
983
+ # ---- the gateway's table ---------------------------------------------------
984
+
985
+ class Format(NamedTuple):
986
+ """One inbound format: how a request is read, an answer written, an
987
+ answer streamed and a refusal shaped."""
988
+ ask: Callable[..., dict]
989
+ reply: Callable[..., dict]
990
+ stream: type
991
+ error: Callable[[int, str], dict]
992
+
993
+
994
+ INBOUND = {
995
+ "chat": Format(chat_ask, chat_reply, ChatStream, chat_error),
996
+ "completion": Format(completion_ask, completion_reply, CompletionStream, chat_error),
997
+ "anthropic": Format(anthropic_ask, anthropic_reply, AnthropicStream, anthropic_error),
998
+ "responses": Format(responses_ask, responses_reply, ResponsesStream, responses_error),
999
+ "ollama_chat": Format(ollama_chat_ask, ollama_reply, OllamaStream, ollama_error),
1000
+ "ollama_generate": Format(ollama_generate_ask, ollama_generate_reply, OllamaGenerateStream, ollama_error),
1001
+ "gemini": Format(gemini_ask, gemini_reply, GeminiStream, gemini_error),
1002
+ }
1003
+
1004
+ OLLAMA_ROUTES = {"/api/chat": "ollama_chat", "/api/generate": "ollama_generate", "/api/show": "ollama_chat",
1005
+ "/api/tags": "ollama_chat", "/api/version": "ollama_chat"}
1006
+ POST_ROUTES = {"/v1/chat/completions": "chat", "/chat/completions": "chat", "/v1/completions": "completion",
1007
+ "/completions": "completion", "/v1/messages": "anthropic", "/v1/messages/count_tokens": "anthropic",
1008
+ "/v1/responses": "responses", "/responses": "responses",
1009
+ "/api/chat": "ollama_chat", "/api/generate": "ollama_generate"}
1010
+
1011
+
1012
+ def format_of(path: str) -> str:
1013
+ """Which format a route speaks, so even a refusal before the body is read
1014
+ (a missing key, a bad Host) comes back in a shape its client reads."""
1015
+ if path in OLLAMA_ROUTES:
1016
+ return OLLAMA_ROUTES[path]
1017
+ if path in POST_ROUTES:
1018
+ return POST_ROUTES[path]
1019
+ if path.startswith("/v1beta/") or gemini_route(path):
1020
+ return "gemini"
1021
+ return "chat"