courier-encode 0.1.0__py3-none-any.whl

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.
encode/relay.py ADDED
@@ -0,0 +1,1222 @@
1
+ """relay() and relay_async() — the chat/completions + responses wrapper.
2
+
3
+ Single entry point that:
4
+
5
+ - talks to /v1/chat/completions or /v1/responses (auto-selected from inputs)
6
+ - runs an automatic tool-call loop with Python callables
7
+ - supports response_format (Pydantic model or dict) for structured outputs
8
+ - exposes intercept callbacks via both ``relay(...).intercept(cb)`` and
9
+ ``on_intercept=`` kwarg, with explicit ``event.stop()`` for early termination
10
+ - supports streaming (final iteration only when tools are present)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import inspect
16
+ import itertools
17
+ import json
18
+ from collections.abc import AsyncIterator, Callable, Iterator, Sequence
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Literal
21
+
22
+ from pydantic import BaseModel
23
+
24
+ from . import _http, errors, tools
25
+ from ._schema import pydantic_to_response_format
26
+ from ._streaming import (
27
+ StreamEvent,
28
+ aiter_chat_completions,
29
+ aiter_responses,
30
+ iter_chat_completions,
31
+ iter_responses,
32
+ )
33
+ from .messages import Messages, to_chat_messages, to_responses_input
34
+ from .responses import AssistantTurn, RelayResponse, ToolCallRecord, Usage
35
+
36
+ InterceptCallback = Callable[["InterceptEvent"], Any]
37
+ AsyncInterceptCallback = Callable[["InterceptEvent"], Any]
38
+
39
+
40
+ @dataclass
41
+ class InterceptEvent:
42
+ iteration: int
43
+ endpoint: Literal["chat", "responses"]
44
+ assistant_turn: AssistantTurn
45
+ tool_calls: list[ToolCallRecord]
46
+ messages_so_far: list[dict[str, Any]]
47
+ raw_response: dict[str, Any]
48
+ will_continue: bool
49
+ _stopped: bool = field(default=False, repr=False)
50
+
51
+ def stop(self) -> None:
52
+ """Mark the loop for termination after the current iteration completes."""
53
+ self._stopped = True
54
+
55
+ @property
56
+ def stopped(self) -> bool:
57
+ return self._stopped
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Request building
62
+ # ---------------------------------------------------------------------------
63
+
64
+ _PASSTHROUGH_CHAT = (
65
+ "temperature",
66
+ "top_p",
67
+ "max_tokens",
68
+ "stop",
69
+ "presence_penalty",
70
+ "frequency_penalty",
71
+ "user",
72
+ "tool_choice",
73
+ )
74
+
75
+ _PASSTHROUGH_RESPONSES = (
76
+ "temperature",
77
+ "top_p",
78
+ "max_output_tokens",
79
+ "max_tokens",
80
+ "stop",
81
+ "presence_penalty",
82
+ "frequency_penalty",
83
+ "user",
84
+ "tool_choice",
85
+ "instructions",
86
+ )
87
+
88
+
89
+ def _resolve_endpoint(
90
+ endpoint: Literal["chat", "responses", "auto"],
91
+ *,
92
+ messages: Any,
93
+ input: Any,
94
+ instructions: Any,
95
+ ) -> Literal["chat", "responses"]:
96
+ if endpoint != "auto":
97
+ return endpoint
98
+ if input is not None or instructions is not None:
99
+ return "responses"
100
+ if messages is not None:
101
+ return "chat"
102
+ raise ValueError("relay() requires `messages=` (chat) or `input=`/`instructions=` (responses)")
103
+
104
+
105
+ def _build_response_format(
106
+ response_format: type[BaseModel] | dict[str, Any] | None,
107
+ endpoint: Literal["chat", "responses"],
108
+ ) -> tuple[Any, type[BaseModel] | None]:
109
+ """Return (request_value, parser_model)."""
110
+ if response_format is None:
111
+ return None, None
112
+ if isinstance(response_format, dict):
113
+ return response_format, None
114
+ if isinstance(response_format, type) and issubclass(response_format, BaseModel):
115
+ rf = pydantic_to_response_format(response_format)
116
+ if endpoint == "responses":
117
+ return {"format": rf}, response_format
118
+ return rf, response_format
119
+ raise TypeError(
120
+ f"response_format must be a Pydantic model class or dict, got {type(response_format).__name__}"
121
+ )
122
+
123
+
124
+ def _build_chat_payload(
125
+ *,
126
+ model: str,
127
+ messages: list[dict[str, Any]],
128
+ tool_dicts: list[dict[str, Any]] | None,
129
+ response_format: Any,
130
+ stream: bool,
131
+ extra: dict[str, Any],
132
+ ) -> dict[str, Any]:
133
+ payload: dict[str, Any] = {"model": model, "messages": messages}
134
+ if tool_dicts:
135
+ payload["tools"] = tool_dicts
136
+ if response_format is not None:
137
+ payload["response_format"] = response_format
138
+ if stream:
139
+ payload["stream"] = True
140
+ payload.update({k: v for k, v in extra.items() if v is not None})
141
+ return payload
142
+
143
+
144
+ def _build_responses_payload(
145
+ *,
146
+ model: str,
147
+ input_items: list[dict[str, Any]],
148
+ tool_dicts: list[dict[str, Any]] | None,
149
+ response_format: Any,
150
+ stream: bool,
151
+ extra: dict[str, Any],
152
+ ) -> dict[str, Any]:
153
+ payload: dict[str, Any] = {"model": model, "input": input_items}
154
+ if tool_dicts:
155
+ payload["tools"] = tool_dicts
156
+ if response_format is not None:
157
+ # response_format for /v1/responses lives under `text`
158
+ payload["text"] = response_format
159
+ if stream:
160
+ payload["stream"] = True
161
+ payload.update({k: v for k, v in extra.items() if v is not None})
162
+ return payload
163
+
164
+
165
+ def _extract_chat_assistant(resp_body: dict[str, Any]) -> tuple[dict[str, Any], str | None]:
166
+ choice = (resp_body.get("choices") or [{}])[0]
167
+ return choice.get("message") or {}, choice.get("finish_reason")
168
+
169
+
170
+ def _extract_responses_assistant(
171
+ resp_body: dict[str, Any],
172
+ ) -> tuple[str | None, list[dict[str, Any]], list[dict[str, Any]]]:
173
+ """Return (assistant_text, function_calls, all_output_items)."""
174
+ output = resp_body.get("output") or []
175
+ assistant_text_parts: list[str] = []
176
+ function_calls: list[dict[str, Any]] = []
177
+ for item in output:
178
+ if item.get("type") == "message" and item.get("role") == "assistant":
179
+ for part in item.get("content") or []:
180
+ if part.get("type") == "output_text" and part.get("text"):
181
+ assistant_text_parts.append(part["text"])
182
+ elif item.get("type") == "function_call":
183
+ function_calls.append(item)
184
+ text = "".join(assistant_text_parts) if assistant_text_parts else None
185
+ return text, function_calls, output
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ # RelayHandle (sync)
190
+ # ---------------------------------------------------------------------------
191
+
192
+
193
+ @dataclass
194
+ class _RelayConfig:
195
+ """Frozen call-time configuration shared by sync and async handles."""
196
+
197
+ model: str
198
+ messages: Any
199
+ input: Any
200
+ instructions: Any
201
+ tools: Any
202
+ tool_choice: Any
203
+ response_format: Any
204
+ web_search: bool
205
+ max_tool_iterations: int | None
206
+ stream: bool
207
+ temperature: Any
208
+ top_p: Any
209
+ max_tokens: Any
210
+ max_output_tokens: Any
211
+ stop: Any
212
+ presence_penalty: Any
213
+ frequency_penalty: Any
214
+ user: Any
215
+ endpoint: Literal["chat", "responses"]
216
+ extra_body: dict[str, Any]
217
+
218
+
219
+ class RelayHandle:
220
+ def __init__(
221
+ self,
222
+ client: Any,
223
+ config: _RelayConfig,
224
+ interceptors: list[InterceptCallback],
225
+ ) -> None:
226
+ self._client = client
227
+ self._config = config
228
+ self._interceptors = list(interceptors)
229
+ self._memo: RelayResponse | None = None
230
+
231
+ def intercept(self, callback: InterceptCallback) -> RelayHandle:
232
+ self._interceptors.append(callback)
233
+ return self
234
+
235
+ def execute(self) -> RelayResponse:
236
+ if self._memo is not None:
237
+ return self._memo
238
+ try:
239
+ self._memo = _execute_sync(self._client, self._config, self._interceptors)
240
+ except errors.MaxToolIterationsError as e:
241
+ _absorb_into_messages(self._config.messages, e.partial)
242
+ raise
243
+ _absorb_into_messages(self._config.messages, self._memo)
244
+ return self._memo
245
+
246
+ @property
247
+ def response(self) -> RelayResponse:
248
+ return self.execute()
249
+
250
+ def __iter__(self) -> Iterator[StreamEvent]:
251
+ return _execute_sync_stream(self._client, self._config, self._interceptors)
252
+
253
+
254
+ class AsyncRelayHandle:
255
+ def __init__(
256
+ self,
257
+ client: Any,
258
+ config: _RelayConfig,
259
+ interceptors: list[AsyncInterceptCallback],
260
+ ) -> None:
261
+ self._client = client
262
+ self._config = config
263
+ self._interceptors = list(interceptors)
264
+ self._memo: RelayResponse | None = None
265
+
266
+ def intercept(self, callback: AsyncInterceptCallback) -> AsyncRelayHandle:
267
+ self._interceptors.append(callback)
268
+ return self
269
+
270
+ async def execute(self) -> RelayResponse:
271
+ if self._memo is not None:
272
+ return self._memo
273
+ try:
274
+ self._memo = await _execute_async(self._client, self._config, self._interceptors)
275
+ except errors.MaxToolIterationsError as e:
276
+ _absorb_into_messages(self._config.messages, e.partial)
277
+ raise
278
+ _absorb_into_messages(self._config.messages, self._memo)
279
+ return self._memo
280
+
281
+ async def get(self) -> RelayResponse:
282
+ return await self.execute()
283
+
284
+ def __await__(self): # type: ignore[no-untyped-def]
285
+ return self.execute().__await__()
286
+
287
+ def __aiter__(self) -> AsyncIterator[StreamEvent]:
288
+ return _execute_async_stream(self._client, self._config, self._interceptors)
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # Public entry points
293
+ # ---------------------------------------------------------------------------
294
+
295
+
296
+ def _gather_intercept(
297
+ on_intercept: InterceptCallback | Sequence[InterceptCallback] | None,
298
+ ) -> list[InterceptCallback]:
299
+ if on_intercept is None:
300
+ return []
301
+ if callable(on_intercept):
302
+ return [on_intercept]
303
+ return list(on_intercept)
304
+
305
+
306
+ def relay(
307
+ *,
308
+ model: str,
309
+ messages: Sequence[Any] | None = None,
310
+ input: Any = None,
311
+ instructions: str | None = None,
312
+ tools: Sequence[Callable[..., Any] | dict[str, Any]] | None = None,
313
+ tool_choice: str | dict[str, Any] | None = None,
314
+ response_format: type[BaseModel] | dict[str, Any] | None = None,
315
+ web_search: bool = False,
316
+ max_tool_iterations: int | None = None,
317
+ stream: bool = False,
318
+ temperature: float | None = None,
319
+ top_p: float | None = None,
320
+ max_tokens: int | None = None,
321
+ max_output_tokens: int | None = None,
322
+ stop: str | list[str] | None = None,
323
+ presence_penalty: float | None = None,
324
+ frequency_penalty: float | None = None,
325
+ user: str | None = None,
326
+ endpoint: Literal["chat", "responses", "auto"] = "auto",
327
+ extra_body: dict[str, Any] | None = None,
328
+ on_intercept: InterceptCallback | Sequence[InterceptCallback] | None = None,
329
+ client: Any = None,
330
+ api_key: str | None = None,
331
+ base_url: str | None = None,
332
+ timeout: float | None = 60.0,
333
+ ) -> RelayHandle:
334
+ """Wrap /v1/chat/completions and /v1/responses with auto tool-call loops.
335
+
336
+ See README for usage. Returns a :class:`RelayHandle`; access ``.response``
337
+ to execute and get the typed :class:`RelayResponse`.
338
+ """
339
+ if response_format is not None and stream:
340
+ raise ValueError("response_format and stream cannot be combined")
341
+
342
+ resolved_endpoint = _resolve_endpoint(
343
+ endpoint, messages=messages, input=input, instructions=instructions
344
+ )
345
+ if client is None:
346
+ from .client import Client, get_default_client
347
+
348
+ if api_key or base_url or timeout != 60.0:
349
+ client = Client(api_key=api_key, base_url=base_url, timeout=timeout)
350
+ else:
351
+ client = get_default_client()
352
+
353
+ config = _RelayConfig(
354
+ model=model,
355
+ messages=messages,
356
+ input=input,
357
+ instructions=instructions,
358
+ tools=tools,
359
+ tool_choice=tool_choice,
360
+ response_format=response_format,
361
+ web_search=web_search,
362
+ max_tool_iterations=max_tool_iterations,
363
+ stream=stream,
364
+ temperature=temperature,
365
+ top_p=top_p,
366
+ max_tokens=max_tokens,
367
+ max_output_tokens=max_output_tokens,
368
+ stop=stop,
369
+ presence_penalty=presence_penalty,
370
+ frequency_penalty=frequency_penalty,
371
+ user=user,
372
+ endpoint=resolved_endpoint,
373
+ extra_body=extra_body or {},
374
+ )
375
+ return RelayHandle(client, config, _gather_intercept(on_intercept))
376
+
377
+
378
+ def relay_async(
379
+ *,
380
+ model: str,
381
+ messages: Sequence[Any] | None = None,
382
+ input: Any = None,
383
+ instructions: str | None = None,
384
+ tools: Sequence[Callable[..., Any] | dict[str, Any]] | None = None,
385
+ tool_choice: str | dict[str, Any] | None = None,
386
+ response_format: type[BaseModel] | dict[str, Any] | None = None,
387
+ web_search: bool = False,
388
+ max_tool_iterations: int | None = None,
389
+ stream: bool = False,
390
+ temperature: float | None = None,
391
+ top_p: float | None = None,
392
+ max_tokens: int | None = None,
393
+ max_output_tokens: int | None = None,
394
+ stop: str | list[str] | None = None,
395
+ presence_penalty: float | None = None,
396
+ frequency_penalty: float | None = None,
397
+ user: str | None = None,
398
+ endpoint: Literal["chat", "responses", "auto"] = "auto",
399
+ extra_body: dict[str, Any] | None = None,
400
+ on_intercept: AsyncInterceptCallback | Sequence[AsyncInterceptCallback] | None = None,
401
+ client: Any = None,
402
+ api_key: str | None = None,
403
+ base_url: str | None = None,
404
+ timeout: float | None = 60.0,
405
+ ) -> AsyncRelayHandle:
406
+ if response_format is not None and stream:
407
+ raise ValueError("response_format and stream cannot be combined")
408
+
409
+ resolved_endpoint = _resolve_endpoint(
410
+ endpoint, messages=messages, input=input, instructions=instructions
411
+ )
412
+ if client is None:
413
+ from .client import AsyncClient, get_default_async_client
414
+
415
+ if api_key or base_url or timeout != 60.0:
416
+ client = AsyncClient(api_key=api_key, base_url=base_url, timeout=timeout)
417
+ else:
418
+ client = get_default_async_client()
419
+
420
+ config = _RelayConfig(
421
+ model=model,
422
+ messages=messages,
423
+ input=input,
424
+ instructions=instructions,
425
+ tools=tools,
426
+ tool_choice=tool_choice,
427
+ response_format=response_format,
428
+ web_search=web_search,
429
+ max_tool_iterations=max_tool_iterations,
430
+ stream=stream,
431
+ temperature=temperature,
432
+ top_p=top_p,
433
+ max_tokens=max_tokens,
434
+ max_output_tokens=max_output_tokens,
435
+ stop=stop,
436
+ presence_penalty=presence_penalty,
437
+ frequency_penalty=frequency_penalty,
438
+ user=user,
439
+ endpoint=resolved_endpoint,
440
+ extra_body=extra_body or {},
441
+ )
442
+ return AsyncRelayHandle(client, config, _gather_intercept(on_intercept))
443
+
444
+
445
+ # ---------------------------------------------------------------------------
446
+ # Sync execution
447
+ # ---------------------------------------------------------------------------
448
+
449
+
450
+ def _common_extras(c: _RelayConfig) -> dict[str, Any]:
451
+ keys = _PASSTHROUGH_CHAT if c.endpoint == "chat" else _PASSTHROUGH_RESPONSES
452
+ extras = {k: getattr(c, k) for k in keys if hasattr(c, k)}
453
+ extras.update(c.extra_body or {})
454
+ return extras
455
+
456
+
457
+ def _fire_intercepts_sync(
458
+ interceptors: Sequence[InterceptCallback], event: InterceptEvent
459
+ ) -> bool:
460
+ for cb in interceptors:
461
+ try:
462
+ result = cb(event)
463
+ if inspect.iscoroutine(result):
464
+ result.close() # don't silently drop user awaitables
465
+ raise TypeError(
466
+ "async interceptor passed to sync relay(); use relay_async() instead"
467
+ )
468
+ except TypeError:
469
+ raise
470
+ except Exception: # noqa: BLE001 - intentional: keep loop running
471
+ import logging
472
+
473
+ logging.getLogger("encode").warning(
474
+ "intercept callback raised; continuing", exc_info=True
475
+ )
476
+ return event.stopped
477
+
478
+
479
+ async def _fire_intercepts_async(
480
+ interceptors: Sequence[AsyncInterceptCallback], event: InterceptEvent
481
+ ) -> bool:
482
+ for cb in interceptors:
483
+ try:
484
+ result = cb(event)
485
+ if inspect.iscoroutine(result):
486
+ await result
487
+ except Exception: # noqa: BLE001
488
+ import logging
489
+
490
+ logging.getLogger("encode").warning(
491
+ "intercept callback raised; continuing", exc_info=True
492
+ )
493
+ return event.stopped
494
+
495
+
496
+ def _execute_sync(
497
+ client: Any, c: _RelayConfig, interceptors: list[InterceptCallback]
498
+ ) -> RelayResponse:
499
+ tool_dicts, tool_index = tools.build_tools(c.tools, web_search=c.web_search)
500
+ response_format_payload, parser_model = _build_response_format(
501
+ c.response_format, c.endpoint
502
+ )
503
+
504
+ if c.endpoint == "chat":
505
+ return _loop_chat_sync(
506
+ client, c, interceptors, tool_dicts, tool_index, response_format_payload, parser_model
507
+ )
508
+ return _loop_responses_sync(
509
+ client, c, interceptors, tool_dicts, tool_index, response_format_payload, parser_model
510
+ )
511
+
512
+
513
+ def _loop_chat_sync(
514
+ client: Any,
515
+ c: _RelayConfig,
516
+ interceptors: list[InterceptCallback],
517
+ tool_dicts: list[dict[str, Any]],
518
+ tool_index: dict[str, Callable[..., Any]],
519
+ response_format_payload: Any,
520
+ parser_model: type[BaseModel] | None,
521
+ ) -> RelayResponse:
522
+ history: list[dict[str, Any]] = list(to_chat_messages(c.messages))
523
+ all_tool_calls: list[ToolCallRecord] = []
524
+ last_raw: Any = None
525
+ finish_reason: str | None = None
526
+ final_content: str | None = None
527
+ iterations = 0
528
+
529
+ for iteration in itertools.count():
530
+ iterations = iteration + 1
531
+ stream_this_iter = c.stream and not tool_dicts
532
+
533
+ payload = _build_chat_payload(
534
+ model=c.model,
535
+ messages=history,
536
+ tool_dicts=tool_dicts or None,
537
+ response_format=response_format_payload,
538
+ stream=stream_this_iter,
539
+ extra=_common_extras(c),
540
+ )
541
+ resp_body = _post_chat(client, payload)
542
+ last_raw = resp_body
543
+ message, finish_reason = _extract_chat_assistant(resp_body)
544
+ history.append(_clone_assistant(message))
545
+ final_content = message.get("content")
546
+
547
+ tcs = message.get("tool_calls") or []
548
+ if not tcs:
549
+ break
550
+
551
+ records: list[ToolCallRecord] = []
552
+ for tc in tcs:
553
+ tc_id = tc.get("id") or ""
554
+ fn_name = (tc.get("function") or {}).get("name") or ""
555
+ args_raw = (tc.get("function") or {}).get("arguments") or "{}"
556
+ args = tools.parse_arguments(args_raw)
557
+ fn = tool_index.get(fn_name)
558
+ err: str | None
559
+ if fn is None:
560
+ result_obj: Any = {"error": f"no Python callable bound for tool '{fn_name}'"}
561
+ err = result_obj["error"]
562
+ duration = 0.0
563
+ else:
564
+ result_obj, err, duration = tools.safe_call(fn, args)
565
+ serialized = tools.serialize_tool_result(result_obj)
566
+ history.append(
567
+ {"role": "tool", "tool_call_id": tc_id, "content": serialized}
568
+ )
569
+ rec = ToolCallRecord(
570
+ id=tc_id,
571
+ name=fn_name,
572
+ arguments=args,
573
+ arguments_raw=args_raw,
574
+ result=result_obj if err is None else None,
575
+ result_serialized=serialized,
576
+ error=err,
577
+ iteration=iteration,
578
+ duration_ms=duration,
579
+ )
580
+ records.append(rec)
581
+ all_tool_calls.append(rec)
582
+
583
+ will_continue = c.max_tool_iterations is None or iterations < c.max_tool_iterations
584
+ event = InterceptEvent(
585
+ iteration=iteration,
586
+ endpoint="chat",
587
+ assistant_turn=AssistantTurn(content=message.get("content"), tool_calls=records),
588
+ tool_calls=records,
589
+ messages_so_far=list(history),
590
+ raw_response=resp_body,
591
+ will_continue=will_continue,
592
+ )
593
+ if _fire_intercepts_sync(interceptors, event):
594
+ break
595
+ if c.max_tool_iterations is not None and iterations >= c.max_tool_iterations:
596
+ partial = _build_response(
597
+ "chat",
598
+ c.model,
599
+ final_content,
600
+ history,
601
+ all_tool_calls,
602
+ iterations,
603
+ finish_reason,
604
+ last_raw,
605
+ resp_body.get("usage"),
606
+ None,
607
+ )
608
+ raise errors.MaxToolIterationsError(
609
+ f"tool loop exceeded max_tool_iterations={c.max_tool_iterations}",
610
+ partial=partial,
611
+ )
612
+
613
+ parsed = _maybe_parse(parser_model, final_content)
614
+ return _build_response(
615
+ "chat",
616
+ c.model,
617
+ final_content,
618
+ history,
619
+ all_tool_calls,
620
+ iterations,
621
+ finish_reason,
622
+ last_raw,
623
+ last_raw.get("usage") if isinstance(last_raw, dict) else None,
624
+ parsed,
625
+ )
626
+
627
+
628
+ def _loop_responses_sync(
629
+ client: Any,
630
+ c: _RelayConfig,
631
+ interceptors: list[InterceptCallback],
632
+ tool_dicts: list[dict[str, Any]],
633
+ tool_index: dict[str, Callable[..., Any]],
634
+ response_format_payload: Any,
635
+ parser_model: type[BaseModel] | None,
636
+ ) -> RelayResponse:
637
+ input_items: list[dict[str, Any]] = list(
638
+ to_responses_input(c.messages, input=c.input)
639
+ )
640
+ history: list[dict[str, Any]] = list(to_chat_messages(c.messages))
641
+ all_tool_calls: list[ToolCallRecord] = []
642
+ last_raw: Any = None
643
+ final_content: str | None = None
644
+ iterations = 0
645
+
646
+ for iteration in itertools.count():
647
+ iterations = iteration + 1
648
+ stream_this_iter = c.stream and not tool_dicts
649
+
650
+ payload = _build_responses_payload(
651
+ model=c.model,
652
+ input_items=input_items,
653
+ tool_dicts=tool_dicts or None,
654
+ response_format=response_format_payload,
655
+ stream=stream_this_iter,
656
+ extra=_common_extras(c),
657
+ )
658
+ resp_body = _post_responses(client, payload)
659
+ last_raw = resp_body
660
+ text, function_calls, output_items = _extract_responses_assistant(resp_body)
661
+ final_content = text
662
+
663
+ # echo assistant message + function_call items into input_items
664
+ for item in output_items:
665
+ input_items.append(item)
666
+ if text is not None:
667
+ history.append({"role": "assistant", "content": text})
668
+
669
+ if not function_calls:
670
+ break
671
+
672
+ records: list[ToolCallRecord] = []
673
+ for fc in function_calls:
674
+ call_id = fc.get("call_id") or fc.get("id") or ""
675
+ fn_name = fc.get("name") or ""
676
+ args_raw = fc.get("arguments") or "{}"
677
+ args = tools.parse_arguments(args_raw)
678
+ fn = tool_index.get(fn_name)
679
+ err: str | None
680
+ if fn is None:
681
+ result_obj: Any = {"error": f"no Python callable bound for tool '{fn_name}'"}
682
+ err = result_obj["error"]
683
+ duration = 0.0
684
+ else:
685
+ result_obj, err, duration = tools.safe_call(fn, args)
686
+ serialized = tools.serialize_tool_result(result_obj)
687
+ input_items.append(
688
+ {
689
+ "type": "function_call_output",
690
+ "call_id": call_id,
691
+ "output": serialized,
692
+ }
693
+ )
694
+ history.append(
695
+ {"role": "tool", "tool_call_id": call_id, "content": serialized}
696
+ )
697
+ rec = ToolCallRecord(
698
+ id=call_id,
699
+ name=fn_name,
700
+ arguments=args,
701
+ arguments_raw=args_raw,
702
+ result=result_obj if err is None else None,
703
+ result_serialized=serialized,
704
+ error=err,
705
+ iteration=iteration,
706
+ duration_ms=duration,
707
+ )
708
+ records.append(rec)
709
+ all_tool_calls.append(rec)
710
+
711
+ will_continue = c.max_tool_iterations is None or iterations < c.max_tool_iterations
712
+ event = InterceptEvent(
713
+ iteration=iteration,
714
+ endpoint="responses",
715
+ assistant_turn=AssistantTurn(content=text, tool_calls=records),
716
+ tool_calls=records,
717
+ messages_so_far=list(history),
718
+ raw_response=resp_body,
719
+ will_continue=will_continue,
720
+ )
721
+ if _fire_intercepts_sync(interceptors, event):
722
+ break
723
+ if c.max_tool_iterations is not None and iterations >= c.max_tool_iterations:
724
+ partial = _build_response(
725
+ "responses",
726
+ c.model,
727
+ final_content,
728
+ history,
729
+ all_tool_calls,
730
+ iterations,
731
+ None,
732
+ last_raw,
733
+ resp_body.get("usage"),
734
+ None,
735
+ )
736
+ raise errors.MaxToolIterationsError(
737
+ f"tool loop exceeded max_tool_iterations={c.max_tool_iterations}",
738
+ partial=partial,
739
+ )
740
+
741
+ parsed = _maybe_parse(parser_model, final_content)
742
+ return _build_response(
743
+ "responses",
744
+ c.model,
745
+ final_content,
746
+ history,
747
+ all_tool_calls,
748
+ iterations,
749
+ None,
750
+ last_raw,
751
+ last_raw.get("usage") if isinstance(last_raw, dict) else None,
752
+ parsed,
753
+ )
754
+
755
+
756
+ # ---------------------------------------------------------------------------
757
+ # Async execution (mirrors sync)
758
+ # ---------------------------------------------------------------------------
759
+
760
+
761
+ async def _execute_async(
762
+ client: Any, c: _RelayConfig, interceptors: list[AsyncInterceptCallback]
763
+ ) -> RelayResponse:
764
+ tool_dicts, tool_index = tools.build_tools(c.tools, web_search=c.web_search)
765
+ response_format_payload, parser_model = _build_response_format(
766
+ c.response_format, c.endpoint
767
+ )
768
+
769
+ if c.endpoint == "chat":
770
+ return await _loop_chat_async(
771
+ client, c, interceptors, tool_dicts, tool_index, response_format_payload, parser_model
772
+ )
773
+ return await _loop_responses_async(
774
+ client, c, interceptors, tool_dicts, tool_index, response_format_payload, parser_model
775
+ )
776
+
777
+
778
+ async def _loop_chat_async(
779
+ client: Any,
780
+ c: _RelayConfig,
781
+ interceptors: list[AsyncInterceptCallback],
782
+ tool_dicts: list[dict[str, Any]],
783
+ tool_index: dict[str, Callable[..., Any]],
784
+ response_format_payload: Any,
785
+ parser_model: type[BaseModel] | None,
786
+ ) -> RelayResponse:
787
+ history: list[dict[str, Any]] = list(to_chat_messages(c.messages))
788
+ all_tool_calls: list[ToolCallRecord] = []
789
+ last_raw: Any = None
790
+ finish_reason: str | None = None
791
+ final_content: str | None = None
792
+ iterations = 0
793
+
794
+ for iteration in itertools.count():
795
+ iterations = iteration + 1
796
+ stream_this_iter = c.stream and not tool_dicts
797
+ payload = _build_chat_payload(
798
+ model=c.model,
799
+ messages=history,
800
+ tool_dicts=tool_dicts or None,
801
+ response_format=response_format_payload,
802
+ stream=stream_this_iter,
803
+ extra=_common_extras(c),
804
+ )
805
+ resp_body = await _post_chat_async(client, payload)
806
+ last_raw = resp_body
807
+ message, finish_reason = _extract_chat_assistant(resp_body)
808
+ history.append(_clone_assistant(message))
809
+ final_content = message.get("content")
810
+
811
+ tcs = message.get("tool_calls") or []
812
+ if not tcs:
813
+ break
814
+
815
+ records: list[ToolCallRecord] = []
816
+ for tc in tcs:
817
+ tc_id = tc.get("id") or ""
818
+ fn_name = (tc.get("function") or {}).get("name") or ""
819
+ args_raw = (tc.get("function") or {}).get("arguments") or "{}"
820
+ args = tools.parse_arguments(args_raw)
821
+ fn = tool_index.get(fn_name)
822
+ err: str | None
823
+ if fn is None:
824
+ result_obj: Any = {"error": f"no Python callable bound for tool '{fn_name}'"}
825
+ err = result_obj["error"]
826
+ duration = 0.0
827
+ else:
828
+ result_obj, err, duration = await tools.safe_call_async(fn, args)
829
+ serialized = tools.serialize_tool_result(result_obj)
830
+ history.append(
831
+ {"role": "tool", "tool_call_id": tc_id, "content": serialized}
832
+ )
833
+ rec = ToolCallRecord(
834
+ id=tc_id,
835
+ name=fn_name,
836
+ arguments=args,
837
+ arguments_raw=args_raw,
838
+ result=result_obj if err is None else None,
839
+ result_serialized=serialized,
840
+ error=err,
841
+ iteration=iteration,
842
+ duration_ms=duration,
843
+ )
844
+ records.append(rec)
845
+ all_tool_calls.append(rec)
846
+
847
+ will_continue = c.max_tool_iterations is None or iterations < c.max_tool_iterations
848
+ event = InterceptEvent(
849
+ iteration=iteration,
850
+ endpoint="chat",
851
+ assistant_turn=AssistantTurn(content=message.get("content"), tool_calls=records),
852
+ tool_calls=records,
853
+ messages_so_far=list(history),
854
+ raw_response=resp_body,
855
+ will_continue=will_continue,
856
+ )
857
+ if await _fire_intercepts_async(interceptors, event):
858
+ break
859
+ if c.max_tool_iterations is not None and iterations >= c.max_tool_iterations:
860
+ partial = _build_response(
861
+ "chat",
862
+ c.model,
863
+ final_content,
864
+ history,
865
+ all_tool_calls,
866
+ iterations,
867
+ finish_reason,
868
+ last_raw,
869
+ resp_body.get("usage"),
870
+ None,
871
+ )
872
+ raise errors.MaxToolIterationsError(
873
+ f"tool loop exceeded max_tool_iterations={c.max_tool_iterations}",
874
+ partial=partial,
875
+ )
876
+
877
+ parsed = _maybe_parse(parser_model, final_content)
878
+ return _build_response(
879
+ "chat",
880
+ c.model,
881
+ final_content,
882
+ history,
883
+ all_tool_calls,
884
+ iterations,
885
+ finish_reason,
886
+ last_raw,
887
+ last_raw.get("usage") if isinstance(last_raw, dict) else None,
888
+ parsed,
889
+ )
890
+
891
+
892
+ async def _loop_responses_async(
893
+ client: Any,
894
+ c: _RelayConfig,
895
+ interceptors: list[AsyncInterceptCallback],
896
+ tool_dicts: list[dict[str, Any]],
897
+ tool_index: dict[str, Callable[..., Any]],
898
+ response_format_payload: Any,
899
+ parser_model: type[BaseModel] | None,
900
+ ) -> RelayResponse:
901
+ input_items: list[dict[str, Any]] = list(
902
+ to_responses_input(c.messages, input=c.input)
903
+ )
904
+ history: list[dict[str, Any]] = list(to_chat_messages(c.messages))
905
+ all_tool_calls: list[ToolCallRecord] = []
906
+ last_raw: Any = None
907
+ final_content: str | None = None
908
+ iterations = 0
909
+
910
+ for iteration in itertools.count():
911
+ iterations = iteration + 1
912
+ stream_this_iter = c.stream and not tool_dicts
913
+ payload = _build_responses_payload(
914
+ model=c.model,
915
+ input_items=input_items,
916
+ tool_dicts=tool_dicts or None,
917
+ response_format=response_format_payload,
918
+ stream=stream_this_iter,
919
+ extra=_common_extras(c),
920
+ )
921
+ resp_body = await _post_responses_async(client, payload)
922
+ last_raw = resp_body
923
+ text, function_calls, output_items = _extract_responses_assistant(resp_body)
924
+ final_content = text
925
+
926
+ for item in output_items:
927
+ input_items.append(item)
928
+ if text is not None:
929
+ history.append({"role": "assistant", "content": text})
930
+
931
+ if not function_calls:
932
+ break
933
+
934
+ records: list[ToolCallRecord] = []
935
+ for fc in function_calls:
936
+ call_id = fc.get("call_id") or fc.get("id") or ""
937
+ fn_name = fc.get("name") or ""
938
+ args_raw = fc.get("arguments") or "{}"
939
+ args = tools.parse_arguments(args_raw)
940
+ fn = tool_index.get(fn_name)
941
+ err: str | None
942
+ if fn is None:
943
+ result_obj: Any = {"error": f"no Python callable bound for tool '{fn_name}'"}
944
+ err = result_obj["error"]
945
+ duration = 0.0
946
+ else:
947
+ result_obj, err, duration = await tools.safe_call_async(fn, args)
948
+ serialized = tools.serialize_tool_result(result_obj)
949
+ input_items.append(
950
+ {
951
+ "type": "function_call_output",
952
+ "call_id": call_id,
953
+ "output": serialized,
954
+ }
955
+ )
956
+ history.append(
957
+ {"role": "tool", "tool_call_id": call_id, "content": serialized}
958
+ )
959
+ rec = ToolCallRecord(
960
+ id=call_id,
961
+ name=fn_name,
962
+ arguments=args,
963
+ arguments_raw=args_raw,
964
+ result=result_obj if err is None else None,
965
+ result_serialized=serialized,
966
+ error=err,
967
+ iteration=iteration,
968
+ duration_ms=duration,
969
+ )
970
+ records.append(rec)
971
+ all_tool_calls.append(rec)
972
+
973
+ will_continue = c.max_tool_iterations is None or iterations < c.max_tool_iterations
974
+ event = InterceptEvent(
975
+ iteration=iteration,
976
+ endpoint="responses",
977
+ assistant_turn=AssistantTurn(content=text, tool_calls=records),
978
+ tool_calls=records,
979
+ messages_so_far=list(history),
980
+ raw_response=resp_body,
981
+ will_continue=will_continue,
982
+ )
983
+ if await _fire_intercepts_async(interceptors, event):
984
+ break
985
+ if c.max_tool_iterations is not None and iterations >= c.max_tool_iterations:
986
+ partial = _build_response(
987
+ "responses",
988
+ c.model,
989
+ final_content,
990
+ history,
991
+ all_tool_calls,
992
+ iterations,
993
+ None,
994
+ last_raw,
995
+ resp_body.get("usage"),
996
+ None,
997
+ )
998
+ raise errors.MaxToolIterationsError(
999
+ f"tool loop exceeded max_tool_iterations={c.max_tool_iterations}",
1000
+ partial=partial,
1001
+ )
1002
+
1003
+ parsed = _maybe_parse(parser_model, final_content)
1004
+ return _build_response(
1005
+ "responses",
1006
+ c.model,
1007
+ final_content,
1008
+ history,
1009
+ all_tool_calls,
1010
+ iterations,
1011
+ None,
1012
+ last_raw,
1013
+ last_raw.get("usage") if isinstance(last_raw, dict) else None,
1014
+ parsed,
1015
+ )
1016
+
1017
+
1018
+ # ---------------------------------------------------------------------------
1019
+ # Streaming entrypoints (no-tools path)
1020
+ # ---------------------------------------------------------------------------
1021
+
1022
+
1023
+ def _execute_sync_stream(
1024
+ client: Any, c: _RelayConfig, interceptors: list[InterceptCallback]
1025
+ ) -> Iterator[StreamEvent]:
1026
+ if c.tools:
1027
+ raise ValueError(
1028
+ "Iterating a streaming RelayHandle with tools is not supported. "
1029
+ "Use stream=False with tools, or call relay() without tools to stream."
1030
+ )
1031
+ tool_dicts, _ = tools.build_tools(None, web_search=c.web_search)
1032
+ if c.endpoint == "chat":
1033
+ history = to_chat_messages(c.messages)
1034
+ payload = _build_chat_payload(
1035
+ model=c.model,
1036
+ messages=history,
1037
+ tool_dicts=tool_dicts or None,
1038
+ response_format=None,
1039
+ stream=True,
1040
+ extra=_common_extras(c),
1041
+ )
1042
+ with client._http.stream(
1043
+ "POST", "/v1/chat/completions", json=payload
1044
+ ) as resp:
1045
+ _http.raise_for_status(resp)
1046
+ yield from iter_chat_completions(resp)
1047
+ else:
1048
+ input_items = to_responses_input(c.messages, input=c.input)
1049
+ payload = _build_responses_payload(
1050
+ model=c.model,
1051
+ input_items=input_items,
1052
+ tool_dicts=tool_dicts or None,
1053
+ response_format=None,
1054
+ stream=True,
1055
+ extra=_common_extras(c),
1056
+ )
1057
+ with client._http.stream("POST", "/v1/responses", json=payload) as resp:
1058
+ _http.raise_for_status(resp)
1059
+ yield from iter_responses(resp)
1060
+
1061
+
1062
+ async def _execute_async_stream(
1063
+ client: Any, c: _RelayConfig, interceptors: list[AsyncInterceptCallback]
1064
+ ) -> AsyncIterator[StreamEvent]:
1065
+ if c.tools:
1066
+ raise ValueError(
1067
+ "Iterating a streaming AsyncRelayHandle with tools is not supported. "
1068
+ "Use stream=False with tools, or call relay_async() without tools to stream."
1069
+ )
1070
+ tool_dicts, _ = tools.build_tools(None, web_search=c.web_search)
1071
+ if c.endpoint == "chat":
1072
+ history = to_chat_messages(c.messages)
1073
+ payload = _build_chat_payload(
1074
+ model=c.model,
1075
+ messages=history,
1076
+ tool_dicts=tool_dicts or None,
1077
+ response_format=None,
1078
+ stream=True,
1079
+ extra=_common_extras(c),
1080
+ )
1081
+ async with client._http.stream(
1082
+ "POST", "/v1/chat/completions", json=payload
1083
+ ) as resp:
1084
+ _http.raise_for_status(resp)
1085
+ async for ev in aiter_chat_completions(resp):
1086
+ yield ev
1087
+ else:
1088
+ input_items = to_responses_input(c.messages, input=c.input)
1089
+ payload = _build_responses_payload(
1090
+ model=c.model,
1091
+ input_items=input_items,
1092
+ tool_dicts=tool_dicts or None,
1093
+ response_format=None,
1094
+ stream=True,
1095
+ extra=_common_extras(c),
1096
+ )
1097
+ async with client._http.stream("POST", "/v1/responses", json=payload) as resp:
1098
+ _http.raise_for_status(resp)
1099
+ async for ev in aiter_responses(resp):
1100
+ yield ev
1101
+
1102
+
1103
+ # ---------------------------------------------------------------------------
1104
+ # HTTP helpers
1105
+ # ---------------------------------------------------------------------------
1106
+
1107
+
1108
+ def _post_chat(client: Any, payload: dict[str, Any]) -> dict[str, Any]:
1109
+ resp = _http.request_sync(
1110
+ client._http,
1111
+ "POST",
1112
+ "/v1/chat/completions",
1113
+ json=payload,
1114
+ max_retries=client.max_retries,
1115
+ )
1116
+ return resp.json()
1117
+
1118
+
1119
+ async def _post_chat_async(client: Any, payload: dict[str, Any]) -> dict[str, Any]:
1120
+ resp = await _http.request_async(
1121
+ client._http,
1122
+ "POST",
1123
+ "/v1/chat/completions",
1124
+ json=payload,
1125
+ max_retries=client.max_retries,
1126
+ )
1127
+ return resp.json()
1128
+
1129
+
1130
+ def _post_responses(client: Any, payload: dict[str, Any]) -> dict[str, Any]:
1131
+ resp = _http.request_sync(
1132
+ client._http,
1133
+ "POST",
1134
+ "/v1/responses",
1135
+ json=payload,
1136
+ max_retries=client.max_retries,
1137
+ )
1138
+ return resp.json()
1139
+
1140
+
1141
+ async def _post_responses_async(client: Any, payload: dict[str, Any]) -> dict[str, Any]:
1142
+ resp = await _http.request_async(
1143
+ client._http,
1144
+ "POST",
1145
+ "/v1/responses",
1146
+ json=payload,
1147
+ max_retries=client.max_retries,
1148
+ )
1149
+ return resp.json()
1150
+
1151
+
1152
+ # ---------------------------------------------------------------------------
1153
+ # Misc helpers
1154
+ # ---------------------------------------------------------------------------
1155
+
1156
+
1157
+ def _has_pending_tool_calls(history: list[dict[str, Any]]) -> bool:
1158
+ if not history:
1159
+ return False
1160
+ last = history[-1]
1161
+ return bool(last.get("tool_calls"))
1162
+
1163
+
1164
+ def _clone_assistant(message: dict[str, Any]) -> dict[str, Any]:
1165
+ """Deep-clone an assistant message for safe append-into-history."""
1166
+ return json.loads(json.dumps(message))
1167
+
1168
+
1169
+ def _absorb_into_messages(messages: Any, response: RelayResponse | None) -> None:
1170
+ """If `messages` is a Messages instance, replace its contents with the response history."""
1171
+ if response is None:
1172
+ return
1173
+ if isinstance(messages, Messages):
1174
+ messages._items = list(response.messages)
1175
+
1176
+
1177
+ def _maybe_parse(model: type[BaseModel] | None, content: str | None) -> Any:
1178
+ if model is None or not content:
1179
+ return None
1180
+ try:
1181
+ return model.model_validate_json(content)
1182
+ except Exception:
1183
+ return None
1184
+
1185
+
1186
+ def _build_response(
1187
+ endpoint: Literal["chat", "responses"],
1188
+ model: str,
1189
+ content: str | None,
1190
+ history: list[dict[str, Any]],
1191
+ tool_calls: list[ToolCallRecord],
1192
+ iterations: int,
1193
+ finish_reason: str | None,
1194
+ raw: Any,
1195
+ usage: Any,
1196
+ parsed: Any,
1197
+ ) -> RelayResponse:
1198
+ return RelayResponse(
1199
+ content=content,
1200
+ parsed=parsed,
1201
+ messages=history,
1202
+ tool_calls=tool_calls,
1203
+ iterations=iterations,
1204
+ finish_reason=finish_reason,
1205
+ endpoint=endpoint,
1206
+ model=model,
1207
+ raw=raw,
1208
+ usage=Usage(**usage) if isinstance(usage, dict) else None,
1209
+ )
1210
+
1211
+
1212
+ __all__ = [
1213
+ "relay",
1214
+ "relay_async",
1215
+ "RelayHandle",
1216
+ "AsyncRelayHandle",
1217
+ "InterceptEvent",
1218
+ "InterceptCallback",
1219
+ "AsyncInterceptCallback",
1220
+ ]
1221
+
1222
+