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.
- courier_encode-0.1.0.dist-info/METADATA +1158 -0
- courier_encode-0.1.0.dist-info/RECORD +19 -0
- courier_encode-0.1.0.dist-info/WHEEL +4 -0
- courier_encode-0.1.0.dist-info/licenses/LICENSE +201 -0
- encode/__init__.py +113 -0
- encode/_config.py +61 -0
- encode/_http.py +150 -0
- encode/_schema.py +135 -0
- encode/_streaming.py +97 -0
- encode/_version.py +1 -0
- encode/client.py +138 -0
- encode/errors.py +169 -0
- encode/messages.py +342 -0
- encode/py.typed +0 -0
- encode/relay.py +1222 -0
- encode/responses.py +67 -0
- encode/session.py +447 -0
- encode/tools.py +107 -0
- encode/whisper.py +200 -0
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
|
+
|