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