@wendellresearch-ai/benchwarmers 0.2.2 → 0.3.1
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 +11 -8
- package/GATEWAY.md +169 -12
- package/PRIVACY.md +7 -5
- package/README.md +81 -37
- package/SECURITY.md +5 -4
- package/USAGE.md +37 -5
- package/VERSION +1 -1
- package/cli.py +1 -1
- package/gateway.py +352 -10
- package/metrics/bench_collections.py +7 -3
- package/metrics/client.py +312 -88
- package/metrics/endpoints.py +25 -2
- package/metrics/formats.py +658 -95
- package/package.json +1 -1
- package/scripts/check.py +2 -0
- package/scripts/test_auth.py +2 -1
- package/scripts/test_formats.py +21 -12
- package/scripts/test_merged.py +2 -2
- package/scripts/test_native.py +188 -0
- package/scripts/test_route.py +429 -0
- package/scripts/test_serve_edit.py +2 -2
- package/serve.py +66 -8
- package/ui/bench.js +3 -1
package/metrics/formats.py
CHANGED
|
@@ -10,9 +10,10 @@ filtering sees them), and the panel's answer back into the format's own
|
|
|
10
10
|
response and streaming events. Pure functions, stdlib only, no I/O: the
|
|
11
11
|
gateway does the HTTP.
|
|
12
12
|
|
|
13
|
-
The same shapes run the other way for the built-in client, which
|
|
14
|
-
|
|
15
|
-
|
|
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):
|
|
16
17
|
|
|
17
18
|
inbound, the gateway answering a client outbound, the client calling an API
|
|
18
19
|
<fmt>_ask(body) native request -> Ask <fmt>_request(messages, ...) OpenAI messages -> native request
|
|
@@ -22,24 +23,40 @@ yet; metrics/client.py speaks chat completions and Responses itself today):
|
|
|
22
23
|
<fmt>_error(status, message) -> native error body
|
|
23
24
|
|
|
24
25
|
An Ask is a dict: `model` (as the client named it), `messages` (OpenAI
|
|
25
|
-
style), `stream`, `options` (max_tokens,
|
|
26
|
-
given)
|
|
27
|
-
(an SDK's extra_body)
|
|
28
|
-
|
|
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.
|
|
29
45
|
|
|
30
46
|
WHAT IS REFUSED, NOT DROPPED
|
|
31
47
|
----------------------------
|
|
32
|
-
A
|
|
33
|
-
is
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
responseSchema, Responses `text.format`)
|
|
42
|
-
|
|
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.
|
|
43
60
|
|
|
44
61
|
Token counts in replies are estimates (characters / 4 plus 10%), as the
|
|
45
62
|
gateway's size check makes; durations are real.
|
|
@@ -58,10 +75,10 @@ import time
|
|
|
58
75
|
import urllib.parse
|
|
59
76
|
from typing import Callable, NamedTuple
|
|
60
77
|
|
|
61
|
-
TOOLS = ("this request carries tools or tool
|
|
62
|
-
" not a tool call
|
|
63
|
-
"
|
|
64
|
-
REPLIES = ("all", "merged")
|
|
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
|
|
65
82
|
LEAD = ("system", "developer") # newer OpenAI SDKs send "developer" where older ones send "system"
|
|
66
83
|
# The Ollama API version whose shapes these follow. Clients check it to
|
|
67
84
|
# decide which fields to send; a lower one would have them hold back.
|
|
@@ -170,27 +187,73 @@ def _image_url(part: dict) -> str:
|
|
|
170
187
|
return part["image_url"]["url"]
|
|
171
188
|
|
|
172
189
|
|
|
173
|
-
def
|
|
174
|
-
|
|
175
|
-
|
|
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": {}}}}
|
|
176
196
|
|
|
177
197
|
|
|
178
|
-
def
|
|
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:
|
|
179
238
|
"""The Ask every format's request becomes, with the bench's own fields
|
|
180
|
-
checked as POST /v1/chat/completions always checked them.
|
|
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."""
|
|
181
242
|
if not isinstance(model, str):
|
|
182
243
|
raise Unsupported('"model" must be a collection name such as bench/core')
|
|
183
244
|
nominated, opts = body.get("with") or [], body.get("bench") or {}
|
|
184
245
|
if not (isinstance(nominated, list) and all(isinstance(a, str) for a in nominated)):
|
|
185
246
|
raise Unsupported('"with" must be a list of model aliases')
|
|
186
247
|
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 "
|
|
248
|
+
raise Unsupported('"bench" must be an object whose "reply" is "all", "merged" or "route"')
|
|
188
249
|
stop = options.get("stop")
|
|
189
250
|
if isinstance(stop, str):
|
|
190
251
|
options = {**options, "stop": [stop]}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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}
|
|
194
257
|
|
|
195
258
|
|
|
196
259
|
def _messages(value, what: str = '"messages"') -> list[dict]:
|
|
@@ -222,7 +285,7 @@ class Stream:
|
|
|
222
285
|
|
|
223
286
|
def __init__(self, model: str, input_tokens: int = 0, **_):
|
|
224
287
|
self.model, self.input_tokens, self.said = model, input_tokens, []
|
|
225
|
-
self.started = time.monotonic()
|
|
288
|
+
self.started, self.turn = time.monotonic(), Turn()
|
|
226
289
|
|
|
227
290
|
def start(self) -> bytes:
|
|
228
291
|
return b""
|
|
@@ -239,23 +302,33 @@ class Stream:
|
|
|
239
302
|
def keepalive(self) -> bytes:
|
|
240
303
|
return b": keep-alive\n\n" # an SSE comment: every SSE parser skips it
|
|
241
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
|
+
|
|
242
311
|
def output_tokens(self) -> int:
|
|
243
312
|
return estimate("".join(self.said))
|
|
244
313
|
|
|
245
314
|
|
|
246
315
|
# ---- OpenAI chat completions -----------------------------------------------
|
|
247
316
|
|
|
317
|
+
CHAT_OWN = ("model", "messages", "stream", "stream_options", "with", "bench")
|
|
318
|
+
|
|
319
|
+
|
|
248
320
|
def chat_ask(body: dict) -> dict:
|
|
249
321
|
"""POST /v1/chat/completions. The messages are already OpenAI's; the
|
|
250
|
-
gateway's own image check reads them as they are
|
|
322
|
+
gateway's own image check reads them as they are, and route mode passes
|
|
323
|
+
every other field on as it came."""
|
|
251
324
|
messages = _messages(body.get("messages"))
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
raise Unsupported(TOOLS)
|
|
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"')
|
|
256
328
|
return _ask(body, body.get("model") or "", messages, body.get("stream"),
|
|
257
329
|
{"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")}
|
|
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})
|
|
259
332
|
|
|
260
333
|
|
|
261
334
|
def chat_reply(text: str, model: str, bench: dict | None = None, usage=(0, 0), seconds: float = 0, **_) -> dict:
|
|
@@ -351,7 +424,10 @@ def _anthropic_blocks(content, where: str) -> str | list[dict]:
|
|
|
351
424
|
raise Unsupported(f"an image source of type {src.get('type')!r} cannot be passed on;"
|
|
352
425
|
" send it as base64 or a url")
|
|
353
426
|
elif isinstance(kind, str) and kind.endswith(("tool_use", "tool_result")):
|
|
354
|
-
|
|
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")
|
|
355
431
|
elif kind in ("thinking", "redacted_thinking"):
|
|
356
432
|
continue # a model's own reasoning from an earlier turn
|
|
357
433
|
else:
|
|
@@ -359,19 +435,73 @@ def _anthropic_blocks(content, where: str) -> str | list[dict]:
|
|
|
359
435
|
return _content(parts)
|
|
360
436
|
|
|
361
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
|
+
|
|
362
491
|
def anthropic_ask(body: dict) -> dict:
|
|
363
|
-
"""POST /v1/messages.
|
|
364
|
-
|
|
492
|
+
"""POST /v1/messages. Parameters with no OpenAI counterpart (top_k,
|
|
493
|
+
thinking, metadata, service_tier) are not passed on."""
|
|
365
494
|
out = []
|
|
366
495
|
if body.get("system"):
|
|
367
496
|
out.append({"role": "system", "content": text_of(_anthropic_blocks(body["system"], "system"))})
|
|
368
497
|
for m in _messages(body.get("messages")):
|
|
369
498
|
if m.get("role") not in ("user", "assistant"):
|
|
370
499
|
raise Unsupported(f'a message\'s "role" must be "user" or "assistant", not {m.get("role")!r}')
|
|
371
|
-
out
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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)})
|
|
375
505
|
|
|
376
506
|
|
|
377
507
|
def anthropic_reply(text: str, model: str, bench=None, usage=(0, 0), seconds: float = 0, **_) -> dict:
|
|
@@ -388,13 +518,18 @@ class AnthropicStream(Stream):
|
|
|
388
518
|
"""message_start, one text block's deltas, message_delta (the bench
|
|
389
519
|
beside its usage), message_stop; `ping` while waiting, as the API does."""
|
|
390
520
|
|
|
391
|
-
def
|
|
521
|
+
def _head(self) -> bytes:
|
|
392
522
|
message = {"id": _new_id("msg_"), "type": "message", "role": "assistant", "model": self.model,
|
|
393
523
|
"content": [], "stop_reason": None, "stop_sequence": None,
|
|
394
524
|
"usage": {"input_tokens": self.input_tokens, "output_tokens": 0}}
|
|
395
|
-
return
|
|
396
|
-
|
|
397
|
-
|
|
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": ""})
|
|
398
533
|
|
|
399
534
|
def text(self, text: str) -> bytes:
|
|
400
535
|
self.said.append(text)
|
|
@@ -430,9 +565,11 @@ def _anthropic_content(content) -> str | list[dict]:
|
|
|
430
565
|
|
|
431
566
|
def _turns(messages: list[dict]) -> tuple[str, list[dict]]:
|
|
432
567
|
"""(system text, the other turns) of OpenAI messages, for APIs that keep
|
|
433
|
-
the system text apart
|
|
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."""
|
|
434
571
|
if any(m.get("role") in ("tool", "function") or m.get("tool_calls") for m in messages):
|
|
435
|
-
raise Unsupported(
|
|
572
|
+
raise Unsupported("tool calls and results are sent to chat completions only, not a native API")
|
|
436
573
|
system = "\n\n".join(text_of(m.get("content")) for m in messages if m.get("role") in LEAD)
|
|
437
574
|
return system, [m for m in messages if m.get("role") not in LEAD]
|
|
438
575
|
|
|
@@ -491,10 +628,54 @@ def _responses_parts(content, where: str) -> str | list[dict]:
|
|
|
491
628
|
return _content(parts)
|
|
492
629
|
|
|
493
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
|
+
|
|
494
675
|
def responses_ask(body: dict) -> dict:
|
|
495
676
|
"""POST /v1/responses. Nothing is stored, so a previous_response_id (or
|
|
496
|
-
a stored conversation) names nothing here.
|
|
497
|
-
|
|
677
|
+
a stored conversation) names nothing here. Function and custom tool calls
|
|
678
|
+
and their outputs become OpenAI tool calls and tool messages."""
|
|
498
679
|
if body.get("previous_response_id") or body.get("conversation"):
|
|
499
680
|
raise Unsupported("responses are never stored here, so previous_response_id and conversation name"
|
|
500
681
|
" nothing; send the whole conversation in input")
|
|
@@ -511,18 +692,36 @@ def responses_ask(body: dict) -> dict:
|
|
|
511
692
|
kind = item.get("type") or "message"
|
|
512
693
|
if kind == "reasoning":
|
|
513
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
|
|
514
711
|
if kind != "message":
|
|
515
|
-
raise Unsupported(
|
|
516
|
-
|
|
712
|
+
raise Unsupported(f"an input item of type {kind!r} is not supported here; send messages,"
|
|
713
|
+
" function calls and their outputs")
|
|
517
714
|
role = item.get("role")
|
|
518
715
|
if role not in ("user", "assistant", "system", "developer"):
|
|
519
716
|
raise Unsupported(f"an input message's role must be user, assistant, system or developer, not {role!r}")
|
|
520
717
|
out.append({"role": role, "content": _responses_parts(item.get("content"), role)})
|
|
521
718
|
else:
|
|
522
719
|
raise Unsupported('"input" must be a string or a list of items')
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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)
|
|
526
725
|
|
|
527
726
|
|
|
528
727
|
def _response(rid: str, model: str, status: str, output: list, usage=(0, 0), created: int = 0, **extra) -> dict:
|
|
@@ -568,28 +767,36 @@ class ResponsesStream(Stream):
|
|
|
568
767
|
def _where(self) -> dict:
|
|
569
768
|
return {"item_id": self.mid, "output_index": 0, "content_index": 0}
|
|
570
769
|
|
|
571
|
-
def
|
|
770
|
+
def _head(self) -> bytes:
|
|
572
771
|
pending = _response(self.rid, self.model, "in_progress", [], created=self.created)
|
|
573
|
-
part = {"type": "output_text", "text": "", "annotations": []}
|
|
574
772
|
return (self._event("response.created", response=pending)
|
|
575
|
-
+ self._event("response.in_progress", response=pending)
|
|
576
|
-
|
|
577
|
-
|
|
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"))
|
|
578
779
|
+ self._event("response.content_part.added", **self._where(), part=part))
|
|
579
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
|
+
|
|
580
791
|
def text(self, text: str) -> bytes:
|
|
581
792
|
self.said.append(text)
|
|
582
793
|
return self._event("response.output_text.delta", **self._where(), delta=text, logprobs=[])
|
|
583
794
|
|
|
584
795
|
def end(self, bench=None) -> bytes:
|
|
585
|
-
|
|
586
|
-
item = _message_item(self.mid, text)
|
|
796
|
+
out, item = self._close()
|
|
587
797
|
done = _response(self.rid, self.model, "completed", [item], (self.input_tokens, self.output_tokens()),
|
|
588
798
|
self.created, **({"bench": bench} if bench is not None else {}))
|
|
589
|
-
return
|
|
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))
|
|
799
|
+
return out + self._event("response.completed", response=done)
|
|
593
800
|
|
|
594
801
|
def error(self, status: int, message: str) -> bytes:
|
|
595
802
|
failed = _response(self.rid, self.model, "failed", [], created=self.created)
|
|
@@ -656,16 +863,39 @@ def _ollama_options(body: dict) -> dict:
|
|
|
656
863
|
|
|
657
864
|
|
|
658
865
|
def ollama_chat_ask(body: dict) -> dict:
|
|
659
|
-
"""POST /api/chat. Ollama streams unless told not to.
|
|
660
|
-
|
|
661
|
-
|
|
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()
|
|
662
870
|
for m in _messages(body.get("messages")):
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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)
|
|
669
899
|
|
|
670
900
|
|
|
671
901
|
def ollama_generate_ask(body: dict) -> dict:
|
|
@@ -766,7 +996,7 @@ def ollama_request(messages: list[dict], model: str, options: dict | None = None
|
|
|
766
996
|
out = []
|
|
767
997
|
for m in messages:
|
|
768
998
|
if m.get("role") in ("tool", "function") or m.get("tool_calls"):
|
|
769
|
-
raise Unsupported(
|
|
999
|
+
raise Unsupported("tool calls and results are sent to chat completions only, not a native API")
|
|
770
1000
|
parts = parts_of(m.get("content"))
|
|
771
1001
|
pictures = []
|
|
772
1002
|
for p in parts:
|
|
@@ -823,41 +1053,97 @@ def _gemini_parts(parts, where: str) -> str | list[dict]:
|
|
|
823
1053
|
raise Unsupported("a fileData part must name an image at an http(s) URL; an upload to"
|
|
824
1054
|
" Google's Files API cannot be read by other models")
|
|
825
1055
|
out.append(image(uri))
|
|
826
|
-
elif any(k in p for k in ("
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
raise Unsupported(
|
|
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")
|
|
830
1060
|
else:
|
|
831
1061
|
raise Unsupported(f"{where}: a part with {', '.join(sorted(p)) or 'nothing'} is not supported here;"
|
|
832
1062
|
" send text and images")
|
|
833
1063
|
return _content(out)
|
|
834
1064
|
|
|
835
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
|
+
|
|
836
1105
|
def gemini_ask(body: dict, model: str, stream: bool = False) -> dict:
|
|
837
1106
|
"""POST /v1beta/models/{model}:generateContent (or :streamGenerateContent):
|
|
838
|
-
the model is in the path, and so is whether it streams.
|
|
839
|
-
|
|
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)."""
|
|
840
1110
|
contents = body.get("contents")
|
|
841
1111
|
if isinstance(contents, dict):
|
|
842
1112
|
contents = [contents]
|
|
843
1113
|
if not (isinstance(contents, list) and all(isinstance(c, dict) for c in contents)):
|
|
844
1114
|
raise Unsupported('"contents" must be a list of content objects')
|
|
845
|
-
out = []
|
|
1115
|
+
out, ids = [], _Ids()
|
|
846
1116
|
if (system := _get(body, "systemInstruction", "system_instruction")):
|
|
847
1117
|
out.append({"role": "system", "content": text_of(_gemini_parts(
|
|
848
1118
|
system.get("parts") if isinstance(system, dict) else [{"text": str(system)}], "systemInstruction"))})
|
|
849
1119
|
for c in contents:
|
|
850
1120
|
role = c.get("role") or "user"
|
|
851
|
-
if role
|
|
852
|
-
raise Unsupported(TOOLS)
|
|
853
|
-
if role not in ("user", "model"):
|
|
1121
|
+
if role not in ("user", "model", "function"):
|
|
854
1122
|
raise Unsupported(f'a content\'s "role" must be "user" or "model", not {role!r}')
|
|
855
|
-
|
|
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)})
|
|
856
1142
|
config = _get(body, "generationConfig", "generation_config") or {}
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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)})
|
|
861
1147
|
|
|
862
1148
|
|
|
863
1149
|
def _gemini_chunk(text: str, model: str, rid: str, usage=None, finish: bool = False) -> dict:
|
|
@@ -980,6 +1266,278 @@ def gemini_text(reply: dict) -> str:
|
|
|
980
1266
|
gemini_delta = gemini_text # each streamed chunk is a reply's shape
|
|
981
1267
|
|
|
982
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
|
+
|
|
983
1541
|
# ---- the gateway's table ---------------------------------------------------
|
|
984
1542
|
|
|
985
1543
|
class Format(NamedTuple):
|
|
@@ -1001,7 +1559,12 @@ INBOUND = {
|
|
|
1001
1559
|
"gemini": Format(gemini_ask, gemini_reply, GeminiStream, gemini_error),
|
|
1002
1560
|
}
|
|
1003
1561
|
|
|
1004
|
-
|
|
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",
|
|
1005
1568
|
"/api/tags": "ollama_chat", "/api/version": "ollama_chat"}
|
|
1006
1569
|
POST_ROUTES = {"/v1/chat/completions": "chat", "/chat/completions": "chat", "/v1/completions": "completion",
|
|
1007
1570
|
"/completions": "completion", "/v1/messages": "anthropic", "/v1/messages/count_tokens": "anthropic",
|