@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.
- package/AGENTS.md +6 -1
- package/GATEWAY.md +149 -6
- package/README.md +42 -2
- package/USAGE.md +16 -0
- package/VERSION +1 -1
- package/cli.py +9 -1
- package/gateway.py +275 -91
- package/metrics/catalog.py +10 -1
- package/metrics/client.py +537 -127
- package/metrics/cost.py +38 -19
- package/metrics/formats.py +1021 -0
- package/metrics/guard.py +37 -7
- package/metrics/learn.py +19 -6
- package/metrics/ledger.py +148 -0
- package/metrics/model_roles.py +13 -3
- package/metrics/pacing.py +248 -0
- package/metrics/quota.py +383 -0
- package/metrics/route.py +5 -3
- package/package.json +2 -1
- package/perf/run.py +30 -7
- package/scripts/check.py +5 -0
- package/scripts/onboard.py +10 -1
- package/scripts/test_client.py +50 -6
- package/scripts/test_formats.py +549 -0
- package/scripts/test_merged.py +4 -3
- package/scripts/test_quota.py +334 -0
- package/scripts/test_serve_edit.py +10 -5
- package/scripts/test_server_json.py +79 -0
- package/scripts/test_verdict.py +5 -1
- package/scripts/test_vision.py +6 -3
- package/serve.py +104 -36
- package/ui/bench.js +3 -2
|
@@ -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"
|