@wendellresearch-ai/benchwarmers 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/metrics/client.py CHANGED
@@ -84,6 +84,21 @@ just before the call, and ChatGPT's plan is reached through the Responses API
84
84
  (streamed, unstored) rather than chat completions. `llm -m` cannot do either,
85
85
  so for those models use `python metrics/client.py <alias> "prompt"`.
86
86
 
87
+ NATIVE ANTHROPIC AND GEMINI: an endpoint whose `api` is `anthropic` or
88
+ `gemini` (the Add endpoint form) is called in that API, through here
89
+ whatever BENCH_CLIENT says: metrics/formats.py builds the request (system
90
+ text apart, the model's options it knows, images as the API's own parts),
91
+ the key goes as x-api-key or x-goog-api-key, and the stream is read into
92
+ the same result, usage and finish included. Anthropic's error events read
93
+ as the status that carries them (overloaded_error is 529), so the text
94
+ contract holds. Tool calls are not sent to them (route mode proxies chat
95
+ completions only).
96
+
97
+ FOR A PROXY (the gateway's route mode): `body`, `on_chunk`, `first_token`
98
+ and `retries` (see chat()) pass a request's own fields, relay each chunk
99
+ exactly, give up early on a member that sends nothing, and leave failover
100
+ to the caller.
101
+
87
102
  RETRIES MATCH THE SDK UNDER llm: two more tries on a connection error, 408,
88
103
  409, 429 or 5xx, waiting the Retry-After when it is 60 s or less, else about
89
104
  0.5 then 1 s. Without them this client would surface 429s llm absorbed, and
@@ -108,6 +123,7 @@ import sys
108
123
  import threading
109
124
  import time
110
125
  import urllib.error
126
+ import urllib.parse
111
127
  import urllib.request
112
128
 
113
129
  ROOT = pathlib.Path(__file__).resolve().parent.parent
@@ -115,6 +131,7 @@ from paths import DATA # noqa: E402 -- the install's data folder (metrics/path
115
131
  sys.path.insert(0, str(ROOT / "metrics"))
116
132
  import connect # noqa: E402
117
133
  import endpoints # noqa: E402
134
+ import formats # noqa: E402
118
135
 
119
136
  OPTIONS = DATA / "config" / "model-options.json"
120
137
  MAX_RETRIES = 2
@@ -145,11 +162,12 @@ def chosen() -> tuple[str, str]:
145
162
 
146
163
  def backend(alias: str = "") -> str:
147
164
  """`llm` or `builtin`, as chosen(). A model on an endpoint connected by
148
- sign-in whose token renews, or that speaks only the Responses API
149
- (ChatGPT's plan), is always `builtin`: llm can do neither."""
165
+ sign-in whose token renews, or that speaks another API than chat
166
+ completions (ChatGPT's plan, native Anthropic or Gemini), is always
167
+ `builtin`: llm can do neither."""
150
168
  if alias:
151
169
  entry = endpoints.entry_of(alias)
152
- if entry.get("api") == "responses" or (entry.get("connect") or {}).get("refreshes"):
170
+ if (entry.get("api") or "chat") != "chat" or (entry.get("connect") or {}).get("refreshes"):
153
171
  return "builtin"
154
172
  return chosen()[0]
155
173
 
@@ -225,15 +243,17 @@ def _wait_at_most(response, deadline: float) -> None:
225
243
  pass
226
244
 
227
245
 
228
- def _chunks(response, deadline: float):
246
+ def _chunks(response, deadline: float, relay: _Relay | None = None):
229
247
  """The body in pieces as they arrive. Raises _Late past the deadline (or
230
- when STOP is set), ValueError past MAX_REPLY, since the gateway is a
231
- long-lived process, and _Broken if the connection drops part way."""
248
+ the relay's first-piece cutoff, or when STOP is set), ValueError past
249
+ MAX_REPLY, since the gateway is a long-lived process, and _Broken if the
250
+ connection drops part way."""
232
251
  size = 0
233
252
  while True:
234
- if STOP.is_set() or time.monotonic() > deadline:
253
+ limit = relay.cutoff(deadline) if relay else deadline
254
+ if STOP.is_set() or time.monotonic() > limit:
235
255
  raise _Late
236
- _wait_at_most(response, deadline)
256
+ _wait_at_most(response, limit)
237
257
  try:
238
258
  chunk = response.read1(65536)
239
259
  except TimeoutError:
@@ -293,26 +313,46 @@ def _events(lines):
293
313
 
294
314
 
295
315
  class _Relay:
296
- """on_delta, kept from breaking the call: one that raises is dropped."""
316
+ """on_delta and on_chunk, kept from breaking the call: one that raises is
317
+ dropped. `first_by`: when the first piece must have come by (monotonic),
318
+ for a caller that would rather try another model than wait (route mode)."""
297
319
 
298
- def __init__(self, on_delta, started: float):
320
+ def __init__(self, on_delta, started: float, on_chunk=None, first_by: float | None = None):
299
321
  self.fn, self.started, self.first = on_delta, started, None
322
+ self.chunk_fn, self.first_by = on_chunk, first_by
323
+
324
+ def mark(self) -> None:
325
+ """The first piece (text, reasoning or a tool call) has arrived."""
326
+ if self.first is None:
327
+ self.first = round(time.monotonic() - self.started, 2)
328
+
329
+ def cutoff(self, deadline: float) -> float:
330
+ """What a wait may last until: the deadline, or sooner while the first piece is still awaited."""
331
+ return min(deadline, self.first_by) if self.first is None and self.first_by is not None else deadline
300
332
 
301
333
  def __call__(self, kind: str, piece: str) -> None:
302
334
  if not piece:
303
335
  return
304
- if self.first is None:
305
- self.first = round(time.monotonic() - self.started, 2)
336
+ self.mark()
306
337
  if self.fn is not None:
307
338
  try:
308
339
  self.fn(kind, piece)
309
340
  except Exception: # noqa: BLE001
310
341
  self.fn = None
311
342
 
343
+ def chunk(self, chunk: dict) -> None:
344
+ if self.chunk_fn is not None:
345
+ try:
346
+ self.chunk_fn(chunk)
347
+ except Exception: # noqa: BLE001
348
+ self.chunk_fn = None
349
+
312
350
 
313
351
  def _chat_stream(events, relay: _Relay) -> dict:
314
- """A streamed chat completion as chat()'s fields."""
352
+ """A streamed chat completion as chat()'s fields. Each chunk that is not
353
+ an error goes to relay.chunk as it came, for a proxy (route mode)."""
315
354
  text, reasoning, model, usage, finish, seen, done = [], [], "", {}, "", False, False
355
+ calls: set = set()
316
356
  for ev in events:
317
357
  if ev == "[DONE]":
318
358
  done = True
@@ -331,6 +371,7 @@ def _chat_stream(events, relay: _Relay) -> dict:
331
371
  if isinstance(ev.get("usage"), dict) and ev["usage"]:
332
372
  usage = ev["usage"]
333
373
  if not (isinstance(choices, list) and choices and isinstance(choices[0], dict)):
374
+ relay.chunk(ev)
334
375
  continue
335
376
  seen = True
336
377
  delta = choices[0].get("delta") or choices[0].get("message") or {}
@@ -341,6 +382,11 @@ def _chat_stream(events, relay: _Relay) -> dict:
341
382
  if piece := _text(delta.get("content")):
342
383
  text.append(piece)
343
384
  relay("text", piece)
385
+ for tc in delta.get("tool_calls") or ():
386
+ if isinstance(tc, dict):
387
+ calls.add(tc["index"] if tc.get("index") is not None else tc.get("id"))
388
+ relay.mark()
389
+ relay.chunk(ev)
344
390
  finish = choices[0].get("finish_reason") or finish
345
391
  if not seen:
346
392
  return {"error": "Error: client failed: the reply had no choices: the stream sent none"}
@@ -348,11 +394,12 @@ def _chat_stream(events, relay: _Relay) -> dict:
348
394
  return {"error": f"Error: client failed: the stream broke off before it finished"
349
395
  f" ({len(''.join(text))} characters in)"}
350
396
  return {"text": "".join(text), "reasoning": "".join(reasoning), "model": model, "usage": usage,
351
- "finish": finish}
397
+ "finish": finish, "tool_calls": len(calls)}
352
398
 
353
399
 
354
- def _chat_json(raw: bytes) -> dict:
355
- """A plain JSON chat completion (an upstream that does not stream) as chat()'s fields."""
400
+ def _chat_json(raw: bytes, relay: _Relay | None = None) -> dict:
401
+ """A plain JSON chat completion (an upstream that does not stream) as
402
+ chat()'s fields; to relay.chunk as the one chunk it would have streamed."""
356
403
  try:
357
404
  data = json.loads(raw or b"{}")
358
405
  except ValueError:
@@ -369,36 +416,132 @@ def _chat_json(raw: bytes) -> dict:
369
416
  if not (isinstance(choices, list) and choices and isinstance(choices[0], dict)):
370
417
  return {"error": f"Error: client failed: the reply had no choices: {str(data)[:1000]}"}
371
418
  message = choices[0].get("message") or {}
372
- return {"text": _text(message.get("content")),
373
- "reasoning": _text(message.get("reasoning_content") or message.get("reasoning")),
374
- "model": data.get("model") or "", "usage": data.get("usage") or {},
375
- "finish": choices[0].get("finish_reason") or ""}
376
-
377
-
378
- def _reply(response, deadline: float, responses: bool, relay: _Relay) -> dict:
379
- """The reply's fields, read as it arrives. Raises _Late past the deadline."""
419
+ calls = [tc for tc in message.get("tool_calls") or () if isinstance(tc, dict)]
420
+ out = {"text": _text(message.get("content")),
421
+ "reasoning": _text(message.get("reasoning_content") or message.get("reasoning")),
422
+ "model": data.get("model") or "", "usage": data.get("usage") or {},
423
+ "finish": choices[0].get("finish_reason") or "", "tool_calls": len(calls)}
424
+ if relay is not None:
425
+ if out["text"] or out["reasoning"] or calls:
426
+ relay.mark()
427
+ delta = {"role": "assistant", **{k: message[k] for k in ("content", "reasoning_content", "reasoning")
428
+ if message.get(k)}}
429
+ if calls:
430
+ delta["tool_calls"] = [{"index": i, **tc} for i, tc in enumerate(calls)]
431
+ relay.chunk({"id": data.get("id") or "", "object": "chat.completion.chunk", "created": data.get("created"),
432
+ "model": out["model"], "usage": out["usage"] or None,
433
+ "choices": [{"index": 0, "delta": delta, "finish_reason": out["finish"] or "stop"}]})
434
+ return out
435
+
436
+
437
+ def _reply(response, deadline: float, api: str, relay: _Relay) -> dict:
438
+ """The reply's fields, read as it arrives, in the endpoint's API (`api`:
439
+ chat, responses, anthropic or gemini). Raises _Late past the deadline."""
380
440
  ctype = (response.headers.get("content-type") or "").lower()
441
+ stream = {"responses": _responses_events, "anthropic": _anthropic_events, "gemini": _gemini_events}
381
442
  try:
382
- if responses:
383
- return _responses_events(_events(_lines(_chunks(response, deadline))), relay)
443
+ if api == "responses" or (api in stream and "event-stream" in ctype):
444
+ return stream[api](_events(_lines(_chunks(response, deadline, relay))), relay)
384
445
  if "event-stream" in ctype:
385
- return _chat_stream(_events(_lines(_chunks(response, deadline))), relay)
386
- raw = b"".join(_chunks(response, deadline))
446
+ return _chat_stream(_events(_lines(_chunks(response, deadline, relay))), relay)
447
+ raw = b"".join(_chunks(response, deadline, relay))
387
448
  except _Broken as e:
388
449
  # Not retried: what arrived may already have been relayed.
389
450
  return {"error": f"Error: client failed: the reply broke off part way ({e})"}
451
+ if api in stream: # a native API answering with one JSON body
452
+ try:
453
+ return stream[api]([json.loads(raw or b"{}")], relay)
454
+ except ValueError:
455
+ return {"error": "Error: client failed: the reply was not JSON: " + raw[:1000].decode("utf-8", "replace")}
390
456
  if raw.lstrip().startswith(b"data:"): # a stream sent without its content type
391
457
  return _chat_stream(_events(_lines([raw])), relay)
392
- return _chat_json(raw)
458
+ return _chat_json(raw, relay)
459
+
460
+
461
+ # Anthropic's error types as the HTTP statuses that carry them, so an error
462
+ # sent mid-stream reads with the same markers (429, 529) as one sent as a status.
463
+ ANTHROPIC_STATUS = {"invalid_request_error": 400, "authentication_error": 401, "permission_error": 403,
464
+ "not_found_error": 404, "request_too_large": 413, "rate_limit_error": 429,
465
+ "api_error": 500, "overloaded_error": 529}
466
+ ANTHROPIC_FINISH = {"end_turn": "stop", "stop_sequence": "stop", "max_tokens": "length", "tool_use": "tool_calls"}
467
+
468
+
469
+ def _anthropic_events(events, relay: _Relay) -> dict:
470
+ """A native Anthropic Messages stream (or one whole reply) as chat()'s
471
+ fields. Finished only once message_stop (or a whole message) arrives."""
472
+ text, reasoning, model, usage, finish, done = [], [], "", {}, "", False
473
+ for ev in events:
474
+ if not isinstance(ev, dict):
475
+ continue
476
+ kind = ev.get("type")
477
+ if kind == "error":
478
+ err = ev.get("error") if isinstance(ev.get("error"), dict) else {}
479
+ return {"error": f"Error: Error code: {ANTHROPIC_STATUS.get(err.get('type'), err.get('type') or 'stream')} - {ev}"}
480
+ if kind == "message": # a whole reply, not a stream
481
+ text.append(formats.anthropic_text(ev))
482
+ relay("text", text[-1])
483
+ model, usage, finish, done = ev.get("model") or "", ev.get("usage") or {}, ev.get("stop_reason") or "", True
484
+ elif kind == "message_start":
485
+ message = ev.get("message") or {}
486
+ model, usage = message.get("model") or "", dict(message.get("usage") or {})
487
+ elif kind == "content_block_delta":
488
+ delta = ev.get("delta") or {}
489
+ if delta.get("type") == "text_delta":
490
+ text.append(delta.get("text") or "")
491
+ relay("text", delta.get("text") or "")
492
+ elif delta.get("type") == "thinking_delta":
493
+ reasoning.append(delta.get("thinking") or "")
494
+ relay("reasoning", delta.get("thinking") or "")
495
+ elif kind == "message_delta":
496
+ finish = (ev.get("delta") or {}).get("stop_reason") or finish
497
+ usage.update({k: v for k, v in (ev.get("usage") or {}).items() if v is not None})
498
+ elif kind == "message_stop":
499
+ done = True
500
+ if not done:
501
+ return {"error": f"Error: client failed: the stream broke off before it finished ({len(''.join(text))} characters in)"}
502
+ return {"text": "".join(text), "reasoning": "".join(reasoning), "model": model, "usage": usage,
503
+ "finish": ANTHROPIC_FINISH.get(finish, finish)}
393
504
 
394
505
 
395
- def _exchange(url: str, payload: dict, headers: dict, deadline: float, responses: bool,
396
- relay: _Relay, observe) -> dict:
397
- """Send the call with the SDK's retries; chat()'s fields plus `attempts`.
398
- `observe(status, headers, body)` sees every response, for quota records."""
506
+ def _gemini_events(events, relay: _Relay) -> dict:
507
+ """A native Gemini generateContent stream (or one whole reply) as chat()'s
508
+ fields. A stream has no end marker: it is finished once a candidate says
509
+ why it stopped."""
510
+ text, reasoning, model, usage, finish = [], [], "", {}, ""
511
+ for ev in events:
512
+ if not isinstance(ev, dict):
513
+ continue
514
+ if ev.get("error"):
515
+ err = ev["error"] if isinstance(ev["error"], dict) else {}
516
+ return {"error": f"Error: Error code: {err.get('code') or 'stream'} - {ev}"}
517
+ if (blocked := (ev.get("promptFeedback") or {}).get("blockReason")):
518
+ return {"error": f"Error: client failed: the prompt was blocked ({blocked}): {ev}"}
519
+ model = ev.get("modelVersion") or model
520
+ if (meta := ev.get("usageMetadata")):
521
+ usage = {"prompt_tokens": meta.get("promptTokenCount"), "completion_tokens": meta.get("candidatesTokenCount"),
522
+ **({"completion_tokens_details": {"reasoning_tokens": meta["thoughtsTokenCount"]}}
523
+ if meta.get("thoughtsTokenCount") else {})}
524
+ candidate = (ev.get("candidates") or [{}])[0]
525
+ for part in (candidate.get("content") or {}).get("parts") or ():
526
+ if part.get("text"):
527
+ (reasoning if part.get("thought") else text).append(part["text"])
528
+ relay("reasoning" if part.get("thought") else "text", part["text"])
529
+ finish = candidate.get("finishReason") or finish
530
+ if not finish:
531
+ return {"error": f"Error: client failed: the stream broke off before it finished ({len(''.join(text))} characters in)"}
532
+ return {"text": "".join(text), "reasoning": "".join(reasoning), "model": model,
533
+ "usage": {k: v for k, v in usage.items() if v is not None},
534
+ "finish": {"STOP": "stop", "MAX_TOKENS": "length"}.get(finish, finish.lower())}
535
+
536
+
537
+ def _exchange(url: str, payload: dict, headers: dict, deadline: float, api: str,
538
+ relay: _Relay, observe, retries: int = MAX_RETRIES) -> dict:
539
+ """Send the call with the SDK's retries (`retries` more tries); chat()'s
540
+ fields plus `attempts`. `observe(status, headers, body)` sees every
541
+ response, for quota records."""
399
542
  attempts, attempt, fell_back = 0, 0, False
400
543
  while True:
401
- left = deadline - time.monotonic()
544
+ left = relay.cutoff(deadline) - time.monotonic()
402
545
  if left <= 0:
403
546
  return {"timed_out": True, "attempts": attempts}
404
547
  request = urllib.request.Request(url, json.dumps(payload).encode(), headers)
@@ -408,7 +551,7 @@ def _exchange(url: str, payload: dict, headers: dict, deadline: float, responses
408
551
  with _OPENER.open(request, timeout=left) as r:
409
552
  status = r.status
410
553
  observe(status, r.headers, None)
411
- got = _reply(r, deadline, responses, relay)
554
+ got = _reply(r, deadline, api, relay)
412
555
  return {"status": status, "attempts": attempts, **got}
413
556
  except _Late:
414
557
  return {"status": status, "timed_out": True, "attempts": attempts}
@@ -424,7 +567,7 @@ def _exchange(url: str, payload: dict, headers: dict, deadline: float, responses
424
567
  observe(e.code, e.headers, shown)
425
568
  # An upstream that does not know stream_options, or will not stream:
426
569
  # the same call once more without them, not counted as a retry.
427
- if (not responses and not fell_back and e.code in (400, 422) and payload.get("stream")
570
+ if (api == "chat" and not fell_back and e.code in (400, 422) and payload.get("stream")
428
571
  and "stream" in text.lower()):
429
572
  drop = ("stream_options",) if "stream_options" in text.lower() else ("stream", "stream_options")
430
573
  payload = {k: v for k, v in payload.items() if k not in drop}
@@ -434,10 +577,10 @@ def _exchange(url: str, payload: dict, headers: dict, deadline: float, responses
434
577
  err = f"Error: Error code: {e.code}" + (f" - {shown}" if text else "") \
435
578
  + (f" Retry-After: {e.headers.get('retry-after')}" if e.headers.get("retry-after") else "") \
436
579
  + (f" (redirect to {e.headers.get('location')} not followed)" if 300 <= e.code < 400 else "")
437
- if not (e.code in RETRY_STATUS or e.code >= 500) or attempt == MAX_RETRIES:
580
+ if not (e.code in RETRY_STATUS or e.code >= 500) or attempt >= retries:
438
581
  return {"status": e.code, "error": err, "attempts": attempts}
439
582
  wait = wait or _backoff(attempt)
440
- if time.monotonic() + wait >= deadline:
583
+ if time.monotonic() + wait >= relay.cutoff(deadline):
441
584
  return {"status": e.code, "error": err, "attempts": attempts}
442
585
  time.sleep(wait)
443
586
  attempt += 1
@@ -445,11 +588,11 @@ def _exchange(url: str, payload: dict, headers: dict, deadline: float, responses
445
588
  reason = getattr(e, "reason", e)
446
589
  # The deadline itself, or the OS giving up on a connection early
447
590
  # (a dropped SYN), which is a connection error and is retried.
448
- if isinstance(reason, TimeoutError) and time.monotonic() >= deadline - 0.5:
591
+ if isinstance(reason, TimeoutError) and time.monotonic() >= relay.cutoff(deadline) - 0.5:
449
592
  return {"timed_out": True, "attempts": attempts}
450
593
  err = f"Error: client failed: Connection error. ({reason})"
451
594
  wait = _backoff(attempt)
452
- if attempt == MAX_RETRIES or time.monotonic() + wait >= deadline:
595
+ if attempt >= retries or time.monotonic() + wait >= relay.cutoff(deadline):
453
596
  return {"error": err, "attempts": attempts}
454
597
  time.sleep(wait)
455
598
  attempt += 1
@@ -520,29 +663,81 @@ def _caller() -> str:
520
663
 
521
664
 
522
665
  def chat(alias: str, prompt: str, system: str = "", timeout: float = 420,
523
- messages: list[dict] | None = None, on_delta=None, purpose: str = "") -> dict:
666
+ messages: list[dict] | None = None, on_delta=None, purpose: str = "",
667
+ body: dict | None = None, on_chunk=None, first_token: float | None = None,
668
+ retries: int | None = None) -> dict:
524
669
  """One chat completion. Returns {text, reasoning, error, timed_out, status,
525
- model (as served), usage, finish, first_token_s, attempts, seconds}, and
526
- `budget_retry` when an empty answer was tried again; `error` is "" on
527
- success, else the contract text. `messages`, when given, are sent as they
528
- are instead of system + prompt: the gateway's requests that carry images,
529
- which `llm -m` cannot send. `on_delta(kind, text)` hears the answer
530
- ("text") and the reasoning ("reasoning") as they arrive. `purpose` goes
531
- into the ledger line beside the calling script's name."""
670
+ model (as served), usage, finish, tool_calls (how many), first_token_s,
671
+ attempts, seconds}, and `budget_retry` when an empty answer was tried
672
+ again; `error` is "" on success, else the contract text. `messages`, when
673
+ given, are sent as they are instead of system + prompt: the gateway's
674
+ requests that carry images, which `llm -m` cannot send. `on_delta(kind,
675
+ text)` hears the answer ("text") and the reasoning ("reasoning") as they
676
+ arrive. `purpose` goes into the ledger line beside the calling script's name.
677
+
678
+ For a proxy (the gateway's route mode), chat completions only: `body`,
679
+ the request's other fields (tools, tool_choice, response_format,
680
+ temperature, ...), which win over the model's own options; `on_chunk`,
681
+ which hears each stream chunk exactly as it came (or the one a whole
682
+ reply would have been); `first_token`, seconds to wait for the first
683
+ piece (text, reasoning or a tool call) before giving up as timed out;
684
+ `retries`, how many more tries a 429, 5xx or connection error gets (the
685
+ SDK's two by default). An empty answer is not tried again: it is relayed."""
532
686
  started, t0 = time.monotonic(), time.time()
533
687
  deadline = started + timeout
534
688
  res = {"text": "", "reasoning": "", "error": "", "timed_out": False, "status": 0,
535
- "model": "", "usage": {}, "finish": "", "first_token_s": None, "attempts": 0}
689
+ "model": "", "usage": {}, "finish": "", "tool_calls": 0, "first_token_s": None, "attempts": 0}
536
690
  ctx = {"connection": "", "endpoint": "", "ticket": None, "requested": "", "refused": False}
691
+ relay = _Relay(on_delta, started, on_chunk, started + first_token if first_token else None)
537
692
  try:
538
- _call(alias, prompt, system, messages, deadline, on_delta, res, ctx, started)
693
+ _call(alias, prompt, system, messages, deadline, relay, res, ctx, body,
694
+ MAX_RETRIES if retries is None else retries)
539
695
  finally:
540
696
  res["seconds"] = round(time.monotonic() - started, 1)
541
697
  _record(alias, res, ctx, t0, purpose)
542
698
  return res
543
699
 
544
700
 
545
- def _call(alias, prompt, system, messages, deadline, on_delta, res, ctx, started) -> None:
701
+ # Anthropic's API version header: required on every call, and the one its
702
+ # Messages API has been served under since it launched.
703
+ ANTHROPIC_VERSION = "2023-06-01"
704
+
705
+
706
+ def _request(api: str, base: str, model: str, alias: str, prompt: str, system: str,
707
+ messages: list[dict] | None, body: dict | None) -> tuple[str, dict]:
708
+ """(url, payload) for one call in the endpoint's API. Raises ValueError
709
+ for what that API cannot be sent from here."""
710
+ if body is not None and api != "chat":
711
+ raise ValueError(f"a request's own fields (tools and the rest) go to chat completions only, not the {api} API")
712
+ if api == "responses":
713
+ # ChatGPT's plan (metrics/connect.py): streamed, unstored, no system
714
+ # message items, no sampling or length options.
715
+ if messages is not None:
716
+ # ponytail: chat parts are not Responses input items; converting
717
+ # them (input_text, input_image) waits for a caller that needs it.
718
+ raise ValueError("given messages are sent to chat completions only, not the Responses API")
719
+ return base + "/responses", {"model": model, "input": [{"role": "user", "content": prompt}],
720
+ "stream": True, "store": False, **({"instructions": system} if system else {})}
721
+ if messages is None:
722
+ messages = ([{"role": "system", "content": system}] if system else []) + [{"role": "user", "content": prompt}]
723
+ opts = _options(alias)
724
+ if api == "anthropic":
725
+ # formats.py builds the native request; the model's options that it
726
+ # knows (max_tokens, temperature, top_p, stop) go with it.
727
+ return base + "/messages", formats.anthropic_request(messages, model, opts, stream=True)
728
+ if api == "gemini":
729
+ # The base is the API's version root (.../v1beta), as its model list is under it.
730
+ return (f"{base}/models/{urllib.parse.quote(model.removeprefix('models/'), safe='-._~')}"
731
+ ":streamGenerateContent?alt=sse", formats.gemini_request(messages, opts))
732
+ payload = {"model": model, "messages": messages, **opts}
733
+ if body:
734
+ if "max_completion_tokens" in body:
735
+ payload.pop("max_tokens", None) # both at once are refused by some APIs
736
+ payload.update({k: v for k, v in body.items() if k not in ("model", "messages", "stream", "stream_options")})
737
+ return base + "/chat/completions", {**payload, "stream": True, "stream_options": {"include_usage": True}}
738
+
739
+
740
+ def _call(alias, prompt, system, messages, deadline, relay, res, ctx, body, retries) -> None:
546
741
  reg = endpoints.registrations().get(alias)
547
742
  if reg is None:
548
743
  res["error"] = f"Error: client failed: no model is registered as {alias!r}"
@@ -553,27 +748,16 @@ def _call(alias, prompt, system, messages, deadline, on_delta, res, ctx, started
553
748
  res["error"] = f"Error: client failed: endpoint base must be http(s), not {base!r}"
554
749
  return
555
750
  model = ctx["requested"] = reg.get("model_name") or alias
556
- if responses := endpoints.entry_of(alias).get("api") == "responses":
557
- # ChatGPT's plan (metrics/connect.py): streamed, unstored, no system
558
- # message items, no sampling or length options.
559
- if messages is not None:
560
- # ponytail: chat parts are not Responses input items; converting
561
- # them (input_text, input_image) waits for a caller that needs it.
562
- res["error"] = ("Error: client failed: given messages are sent to chat completions only,"
563
- " not the Responses API")
564
- return
565
- url = base + "/responses"
566
- payload = {"model": model, "input": [{"role": "user", "content": prompt}],
567
- "stream": True, "store": False, **({"instructions": system} if system else {})}
568
- else:
569
- url = base + "/chat/completions"
570
- if messages is None:
571
- messages = ([{"role": "system", "content": system}] if system else []) \
572
- + [{"role": "user", "content": prompt}]
573
- payload = {"model": model, "messages": messages, **_options(alias),
574
- "stream": True, "stream_options": {"include_usage": True}}
751
+ api = endpoints.entry_of(alias).get("api") or "chat"
752
+ try:
753
+ url, payload = _request(api, base, model, alias, prompt, system, messages, body)
754
+ except ValueError as e: # formats.Unsupported is one too
755
+ res["error"] = f"Error: client failed: {e}"
756
+ return
575
757
  headers = {"Content-Type": "application/json", "User-Agent": "benchwarmers",
576
758
  **reg.get("headers", {})}
759
+ if api == "anthropic":
760
+ headers["anthropic-version"] = ANTHROPIC_VERSION
577
761
  if name := reg.get("api_key_name", ""):
578
762
  if why := connect.fresh(name): # a signed-in token past renewal
579
763
  res["error"] = f"Error: client failed: {why}"
@@ -583,7 +767,7 @@ def _call(alias, prompt, system, messages, deadline, on_delta, res, ctx, started
583
767
  if not (key := endpoints.keys().get(name, "")):
584
768
  res["error"] = f"Error: client failed: no key named {name!r} is set (llm keys set {name})"
585
769
  return
586
- headers["Authorization"] = f"Bearer {key}"
770
+ headers.update(endpoints.auth_headers(api, key))
587
771
 
588
772
  try:
589
773
  if str(ROOT / "suite") not in sys.path: # route reads the registry through suite/run.py
@@ -598,8 +782,12 @@ def _call(alias, prompt, system, messages, deadline, on_delta, res, ctx, started
598
782
  return
599
783
  try:
600
784
  import pacing
601
- ctx["ticket"], why = pacing.admit(alias, ctx["connection"], _estimate(prompt, system, messages),
602
- deadline - time.monotonic())
785
+ # A proxy that would rather try another model waits for room no
786
+ # longer than for a first piece. ponytail: the in-flight slot then
787
+ # expires that much sooner too; per-call expiry if it ever matters.
788
+ ctx["ticket"], why = pacing.admit(alias, ctx["connection"],
789
+ _estimate(prompt, system, messages) + len(json.dumps(body or "")) // 4,
790
+ relay.cutoff(deadline) - time.monotonic())
603
791
  except Exception: # noqa: BLE001 -- a broken limit file is not a refusal
604
792
  why = ""
605
793
  if why:
@@ -616,12 +804,13 @@ def _call(alias, prompt, system, messages, deadline, on_delta, res, ctx, started
616
804
  except Exception: # noqa: BLE001
617
805
  pass
618
806
 
619
- relay = _Relay(on_delta, started)
620
- got = _exchange(url, payload, headers, deadline, responses, relay, observe)
621
- if (not responses and not got.get("error") and not got.get("timed_out") and got.get("text") == ""
807
+ got = _exchange(url, payload, headers, deadline, api, relay, observe, retries)
808
+ # Not for a proxy: what it relayed already went to its caller.
809
+ if (api in ("chat", "anthropic") and relay.chunk_fn is None and not got.get("error")
810
+ and not got.get("timed_out") and got.get("text") == "" and not got.get("tool_calls")
622
811
  and got.get("finish") == "length" and (grow := _budget(payload, got.get("usage") or {}, alias))):
623
812
  key, used, bigger = grow
624
- again = _exchange(url, {**payload, key: bigger}, headers, deadline, responses, relay, observe)
813
+ again = _exchange(url, {**payload, key: bigger}, headers, deadline, api, relay, observe, retries)
625
814
  attempts = got.get("attempts", 0) + again.get("attempts", 0)
626
815
  if again.get("error") or again.get("timed_out"):
627
816
  # The empty answer stands: the retry's failure (a 400 for a budget
@@ -650,7 +839,7 @@ def _record(alias: str, res: dict, ctx: dict, t0: float, purpose: str) -> None:
650
839
  print(f"note: {alias} asked for {ctx['requested']} and was served {res['model']}"
651
840
  " (a silent swap?)", file=sys.stderr)
652
841
  status = ("refused" if ctx["refused"] else "timeout" if res["timed_out"] else
653
- "error" if res["error"] else "ok" if res["text"] else "empty")
842
+ "error" if res["error"] else "ok" if res["text"] or res.get("tool_calls") else "empty")
654
843
  import ledger
655
844
  t = ctx.get("sent") or t0
656
845
  row = {"t": round(t, 3), "at": dt.datetime.fromtimestamp(t).astimezone().isoformat(timespec="seconds"),
@@ -762,7 +951,7 @@ def _selftest() -> None:
762
951
  {"choices": [{"delta": {"content": "rea"}}]},
763
952
  {"choices": [{"delta": {"content": [{"type": "text", "text": "dy"}]}, "finish_reason": "stop"}]},
764
953
  {"choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 5}}, "[DONE]"))), relay)
765
- assert got == {"text": "ready", "reasoning": "hmm so", "model": "m-2", "finish": "stop",
954
+ assert got == {"text": "ready", "reasoning": "hmm so", "model": "m-2", "finish": "stop", "tool_calls": 0,
766
955
  "usage": {"prompt_tokens": 3, "completion_tokens": 5}}, got
767
956
  assert heard == [("reasoning", "hmm "), ("reasoning", "so"), ("text", "rea"), ("text", "dy")], heard
768
957
  assert relay.first is not None
@@ -782,7 +971,41 @@ def _selftest() -> None:
782
971
  bad("text", "x")
783
972
  assert bad.fn is None
784
973
  assert _chat_json(b'{"choices":[{"message":{"content":"","reasoning":"r"},"finish_reason":"length"}]}') \
785
- == {"text": "", "reasoning": "r", "model": "", "usage": {}, "finish": "length"}
974
+ == {"text": "", "reasoning": "r", "model": "", "usage": {}, "finish": "length", "tool_calls": 0}
975
+ # A proxy hears every chunk as it came, and a whole reply as the one chunk it would have streamed.
976
+ seen: list = []
977
+ relay = _Relay(None, 0, seen.append, first_by=0)
978
+ assert relay.cutoff(10) == 0 and _Relay(None, 0).cutoff(10) == 10 # awaiting the first piece
979
+ call = {"index": 0, "id": "c1", "function": {"name": "f", "arguments": "{}"}}
980
+ got = _chat_stream(_events(_lines(chunks({"choices": [{"delta": {"tool_calls": [call]}}]},
981
+ {"choices": [{"delta": {}, "finish_reason": "tool_calls"}]}, "[DONE]"))), relay)
982
+ assert got["tool_calls"] == 1 and len(seen) == 2 and seen[0]["choices"][0]["delta"]["tool_calls"] == [call]
983
+ assert relay.cutoff(10) == 10 # a tool call is a first piece
984
+ seen.clear()
985
+ _chat_json(b'{"id":"x","choices":[{"message":{"content":null,"tool_calls":[{"id":"c1","function":{}}]},'
986
+ b'"finish_reason":"tool_calls"}]}', _Relay(None, 0, seen.append))
987
+ assert seen[0]["choices"][0]["delta"]["tool_calls"] == [{"index": 0, "id": "c1", "function": {}}], seen
988
+ # Native streams: Anthropic Messages and Gemini, read into the same fields.
989
+ got = _anthropic_events(_events(_lines(chunks(
990
+ {"type": "message_start", "message": {"model": "claude-x", "usage": {"input_tokens": 9}}},
991
+ {"type": "content_block_delta", "index": 0, "delta": {"type": "thinking_delta", "thinking": "hm"}},
992
+ {"type": "content_block_delta", "index": 1, "delta": {"type": "text_delta", "text": "ready"}},
993
+ {"type": "message_delta", "delta": {"stop_reason": "end_turn"}, "usage": {"output_tokens": 4}},
994
+ {"type": "message_stop"}))), _Relay(None, 0))
995
+ assert got == {"text": "ready", "reasoning": "hm", "model": "claude-x", "finish": "stop",
996
+ "usage": {"input_tokens": 9, "output_tokens": 4}}, got
997
+ err = _anthropic_events([{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}], _Relay(None, 0))
998
+ assert err["error"].startswith("Error: Error code: 529 - "), err
999
+ assert "broke off" in _anthropic_events([{"type": "message_start", "message": {}}], _Relay(None, 0))["error"]
1000
+ got = _gemini_events([{"candidates": [{"content": {"parts": [{"text": "hm", "thought": True}, {"text": "rea"}]}}]},
1001
+ {"candidates": [{"content": {"parts": [{"text": "dy"}]}, "finishReason": "STOP"}],
1002
+ "modelVersion": "gemini-x", "usageMetadata": {"promptTokenCount": 3, "candidatesTokenCount": 2}}],
1003
+ _Relay(None, 0))
1004
+ assert got == {"text": "ready", "reasoning": "hm", "model": "gemini-x", "finish": "stop",
1005
+ "usage": {"prompt_tokens": 3, "completion_tokens": 2}}, got
1006
+ assert _gemini_events([{"error": {"code": 429, "message": "quota"}}], _Relay(None, 0))["error"].startswith(
1007
+ "Error: Error code: 429 - ")
1008
+ assert "broke off" in _gemini_events([{"candidates": [{"content": {"parts": [{"text": "x"}]}}]}], _Relay(None, 0))["error"]
786
1009
  assert not _swapped("openai/gpt-oss-120b:free", "gpt-oss-120b") and _swapped("llama-3.3-70b", "llama-3.1-8b")
787
1010
  print("ok")
788
1011