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