@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.
@@ -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 will call
14
- the native Anthropic, Gemini, Ollama and Responses APIs with them (not wired
15
- yet; metrics/client.py speaks chat completions and Responses itself today):
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, temperature, top_p, stop, when
26
- given) and the bench's own `with` and `reply`, read from any format's body
27
- (an SDK's extra_body). The gateway reads `options` but does not pass them
28
- on: each seat uses its own call options (config/model-options.json).
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 request carrying tools, or a conversation holding tool calls or results,
33
- is refused (Unsupported, a 400) rather than answered as if the tools were not
34
- there: a panel's reply is several models' text, not a tool call, and an agent
35
- that waits for one would hang or loop. A route mode that proxies one model
36
- with tools is planned. So is anything else that would be lost on the way: a
37
- PDF or audio part, an uploaded file id, a stored previous response. A
38
- model's own reasoning from an earlier turn (Anthropic thinking blocks,
39
- Responses reasoning items, Gemini thought parts) is dropped: each seat
40
- reasons for itself. Structured-output settings (Ollama `format`, Gemini's
41
- responseSchema, Responses `text.format`) are ignored: a panel answers in
42
- markdown.
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 calls, and the bench gateway answers with a panel of models,"
62
- " not a tool call, so tool use is not supported yet (a route mode that proxies one model, tools"
63
- " and all, is coming). Send it without tools.")
64
- REPLIES = ("all", "merged") # bench_collections.REPLIES; not imported, so this file stands alone
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 _no_tools(tools) -> None:
174
- if tools:
175
- raise Unsupported(TOOLS)
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 _ask(body: dict, model, messages: list[dict], stream, options: dict) -> dict:
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 "merged"')
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
- return {"model": model, "messages": messages, "stream": bool(stream),
192
- "options": {k: v for k, v in options.items() if v is not None},
193
- "with": nominated, "reply": opts.get("reply") or ""}
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
- _no_tools(body.get("tools") or body.get("functions"))
253
- if any(m.get("role") in ("tool", "function") or m.get("tool_calls") or m.get("function_call")
254
- for m in messages):
255
- raise Unsupported(TOOLS)
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
- raise Unsupported(TOOLS)
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
- _no_tools(body.get("tools"))
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.append({"role": m["role"], "content": _anthropic_blocks(m.get("content"), m["role"])})
372
- return _ask(body, body.get("model") or "", out, body.get("stream"),
373
- {"max_tokens": body.get("max_tokens"), "temperature": body.get("temperature"),
374
- "top_p": body.get("top_p"), "stop": body.get("stop_sequences")})
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 start(self) -> bytes:
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 (sse({"type": "message_start", "message": message}, "message_start")
396
- + sse({"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
397
- "content_block_start"))
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; refuses tool turns, which have no place there."""
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(TOOLS)
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
- _no_tools(body.get("tools"))
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(TOOLS if "call" in kind or "tool" in kind else
516
- f"an input item of type {kind!r} is not supported here; send messages")
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
- return _ask(body, body.get("model") or "", out, body.get("stream"),
524
- {"max_tokens": body.get("max_output_tokens"), "temperature": body.get("temperature"),
525
- "top_p": body.get("top_p")})
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 start(self) -> bytes:
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
- + self._event("response.output_item.added", output_index=0,
577
- item=_message_item(self.mid, "", "in_progress"))
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
- text = "".join(self.said)
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 (self._event("response.output_text.done", **self._where(), text=text, logprobs=[])
590
- + self._event("response.content_part.done", **self._where(), part=item["content"][0])
591
- + self._event("response.output_item.done", output_index=0, item=item)
592
- + self._event("response.completed", response=done))
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
- _no_tools(body.get("tools"))
661
- out = []
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
- if m.get("role") == "tool" or m.get("tool_calls"):
664
- raise Unsupported(TOOLS)
665
- if m.get("role") not in ("system", "user", "assistant"):
666
- raise Unsupported(f'a message\'s "role" must be system, user or assistant, not {m.get("role")!r}')
667
- out.append({"role": m["role"], "content": _ollama_parts(m.get("content"), m.get("images"), m["role"])})
668
- return _ask(body, ollama_model(body.get("model") or ""), out, body.get("stream", True), _ollama_options(body))
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(TOOLS)
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 ("functionCall", "function_call", "functionResponse", "function_response",
827
- "executableCode", "executable_code", "codeExecutionResult",
828
- "code_execution_result")):
829
- raise Unsupported(TOOLS)
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
- _no_tools(body.get("tools"))
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 == "function":
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
- out.append({"role": "assistant" if role == "model" else "user", "content": _gemini_parts(c.get("parts"), role)})
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
- return _ask(body, model, out, stream,
858
- {"max_tokens": _get(config, "maxOutputTokens", "max_output_tokens"),
859
- "temperature": config.get("temperature"), "top_p": _get(config, "topP", "top_p"),
860
- "stop": _get(config, "stopSequences", "stop_sequences")})
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
- OLLAMA_ROUTES = {"/api/chat": "ollama_chat", "/api/generate": "ollama_generate", "/api/show": "ollama_chat",
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",