llm-sdk-py 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.
llm_sdk.py
ADDED
|
@@ -0,0 +1,1866 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Universal LLM API Wrapper with OpenAI-compatible API support.
|
|
3
|
+
|
|
4
|
+
Features:
|
|
5
|
+
- Structured outputs with automatic schema generation
|
|
6
|
+
- Vision model support
|
|
7
|
+
- Tool definitions with automatic function introspection
|
|
8
|
+
- Streaming with thinking token handling
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import base64
|
|
15
|
+
import re
|
|
16
|
+
import io
|
|
17
|
+
import time
|
|
18
|
+
import sys
|
|
19
|
+
import inspect
|
|
20
|
+
import logging
|
|
21
|
+
import copy
|
|
22
|
+
import asyncio
|
|
23
|
+
import threading
|
|
24
|
+
import functools
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from enum import Enum
|
|
28
|
+
from typing import (
|
|
29
|
+
Any, AsyncGenerator, Dict, Optional, List, Generator,
|
|
30
|
+
Callable, Union, get_type_hints, get_origin, get_args,
|
|
31
|
+
Literal, TypedDict, TypeVar, Final, ClassVar,
|
|
32
|
+
TYPE_CHECKING
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from openai import OpenAI, AsyncOpenAI
|
|
36
|
+
|
|
37
|
+
if TYPE_CHECKING:
|
|
38
|
+
from PIL.Image import Image as PILImage
|
|
39
|
+
|
|
40
|
+
# ============================================================================
|
|
41
|
+
# Version Compatibility
|
|
42
|
+
# ============================================================================
|
|
43
|
+
|
|
44
|
+
if sys.version_info < (3, 10):
|
|
45
|
+
_DEFAULT = object()
|
|
46
|
+
|
|
47
|
+
async def anext(async_iterator, default=_DEFAULT):
|
|
48
|
+
"""Polyfill for anext() in Python < 3.10."""
|
|
49
|
+
try:
|
|
50
|
+
return await async_iterator.__anext__()
|
|
51
|
+
except StopAsyncIteration:
|
|
52
|
+
if default is _DEFAULT:
|
|
53
|
+
raise
|
|
54
|
+
return default
|
|
55
|
+
|
|
56
|
+
# ============================================================================
|
|
57
|
+
# Logging Configuration
|
|
58
|
+
# ============================================================================
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
for _logger_name in ("httpx", "openai", "httpcore"):
|
|
63
|
+
logging.getLogger(_logger_name).setLevel(logging.WARNING)
|
|
64
|
+
|
|
65
|
+
# ============================================================================
|
|
66
|
+
# Constants
|
|
67
|
+
# ============================================================================
|
|
68
|
+
|
|
69
|
+
DEFAULT_API_KEY: Final[str] = "lm-studio"
|
|
70
|
+
DEFAULT_BASE_URL: Final[str] = "http://localhost:1234/v1"
|
|
71
|
+
DEFAULT_TIMEOUT: Final[float] = 300.0
|
|
72
|
+
|
|
73
|
+
MessageList = List[Dict[str, Any]]
|
|
74
|
+
InputValue = Union[str, MessageList]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _close_async_resource(resource: Any) -> None:
|
|
78
|
+
if not hasattr(resource, "close"):
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
async def _close() -> None:
|
|
82
|
+
close_result = resource.close()
|
|
83
|
+
if inspect.isawaitable(close_result):
|
|
84
|
+
await close_result
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
asyncio.get_running_loop()
|
|
88
|
+
except RuntimeError:
|
|
89
|
+
asyncio.run(_close())
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
error_box: List[BaseException] = []
|
|
93
|
+
|
|
94
|
+
def _runner() -> None:
|
|
95
|
+
try:
|
|
96
|
+
asyncio.run(_close())
|
|
97
|
+
except BaseException as exc: # pragma: no cover - defensive cleanup path
|
|
98
|
+
error_box.append(exc)
|
|
99
|
+
|
|
100
|
+
worker = threading.Thread(target=_runner, daemon=True, name="llm-close")
|
|
101
|
+
worker.start()
|
|
102
|
+
worker.join()
|
|
103
|
+
if error_box:
|
|
104
|
+
raise error_box[0]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _deep_merge_dicts(
|
|
108
|
+
base: Optional[Dict[str, Any]],
|
|
109
|
+
override: Optional[Dict[str, Any]],
|
|
110
|
+
) -> Optional[Dict[str, Any]]:
|
|
111
|
+
if not base and not override:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
merged = copy.deepcopy(base or {})
|
|
115
|
+
for key, value in (override or {}).items():
|
|
116
|
+
current = merged.get(key)
|
|
117
|
+
if isinstance(current, dict) and isinstance(value, Mapping):
|
|
118
|
+
merged[key] = _deep_merge_dicts(current, dict(value))
|
|
119
|
+
else:
|
|
120
|
+
merged[key] = copy.deepcopy(value)
|
|
121
|
+
return merged
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _resolve_messages(
|
|
125
|
+
messages: Optional[InputValue] = None,
|
|
126
|
+
input: Optional[InputValue] = None,
|
|
127
|
+
) -> MessageList:
|
|
128
|
+
provided = [value is not None for value in (messages, input)].count(True)
|
|
129
|
+
if provided != 1:
|
|
130
|
+
raise ValueError("Provide exactly one of messages or input")
|
|
131
|
+
|
|
132
|
+
value = input if input is not None else messages
|
|
133
|
+
if isinstance(value, str):
|
|
134
|
+
return [{"role": "user", "content": value}]
|
|
135
|
+
if isinstance(value, list):
|
|
136
|
+
for idx, message in enumerate(value):
|
|
137
|
+
if not isinstance(message, dict):
|
|
138
|
+
raise ValueError(f"Message at index {idx} must be a dict")
|
|
139
|
+
return copy.deepcopy(value)
|
|
140
|
+
raise ValueError("input must be a string or a list of messages")
|
|
141
|
+
|
|
142
|
+
# ============================================================================
|
|
143
|
+
# Enums
|
|
144
|
+
# ============================================================================
|
|
145
|
+
|
|
146
|
+
class EventType(str, Enum):
|
|
147
|
+
"""Event types emitted during streaming."""
|
|
148
|
+
ANSWER = "answer"
|
|
149
|
+
REASONING = "reasoning"
|
|
150
|
+
TOOL_CALL = "tool_call"
|
|
151
|
+
VERBOSE = "verbose"
|
|
152
|
+
FINAL = "final"
|
|
153
|
+
DONE = "done"
|
|
154
|
+
|
|
155
|
+
def __str__(self) -> str:
|
|
156
|
+
return self.value
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class SchemaType(str, Enum):
|
|
160
|
+
"""JSON Schema type mappings."""
|
|
161
|
+
STRING = "string"
|
|
162
|
+
INTEGER = "integer"
|
|
163
|
+
NUMBER = "number"
|
|
164
|
+
BOOLEAN = "boolean"
|
|
165
|
+
ARRAY = "array"
|
|
166
|
+
OBJECT = "object"
|
|
167
|
+
NULL = "null"
|
|
168
|
+
|
|
169
|
+
# ============================================================================
|
|
170
|
+
# Type Definitions
|
|
171
|
+
# ============================================================================
|
|
172
|
+
|
|
173
|
+
T = TypeVar('T')
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class StreamEvent(TypedDict, total=False):
|
|
177
|
+
"""Unified event format for all stream events."""
|
|
178
|
+
type: str
|
|
179
|
+
content: Any
|
|
180
|
+
source: Optional[str]
|
|
181
|
+
tool_id: Optional[str]
|
|
182
|
+
job: Optional[int]
|
|
183
|
+
depth: int
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class ToolCall(TypedDict):
|
|
187
|
+
"""Typed dictionary for tool calls."""
|
|
188
|
+
id: str
|
|
189
|
+
name: str
|
|
190
|
+
arguments: Dict[str, Any]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class VerboseInfo(TypedDict):
|
|
194
|
+
"""Typed dictionary for verbose information."""
|
|
195
|
+
tokens: int
|
|
196
|
+
tokens_per_second: float
|
|
197
|
+
latency: Optional[float]
|
|
198
|
+
prompt_tokens: Optional[int]
|
|
199
|
+
completion_tokens: Optional[int]
|
|
200
|
+
total_tokens: Optional[int]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class FinalResponse(TypedDict, total=False):
|
|
204
|
+
"""Typed dictionary for final response."""
|
|
205
|
+
answer: Any
|
|
206
|
+
reasoning: str
|
|
207
|
+
tool_calls: List[ToolCall]
|
|
208
|
+
verbose: VerboseInfo
|
|
209
|
+
|
|
210
|
+
# ============================================================================
|
|
211
|
+
# Exceptions
|
|
212
|
+
# ============================================================================
|
|
213
|
+
|
|
214
|
+
class LLMError(Exception):
|
|
215
|
+
"""Base exception for LLM errors."""
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class ConfigurationError(LLMError):
|
|
220
|
+
"""Raised when configuration is invalid."""
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class SchemaConversionError(LLMError):
|
|
225
|
+
"""Raised when schema conversion fails."""
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class ModelRequestError(LLMError):
|
|
230
|
+
"""Raised when model request fails."""
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
# ============================================================================
|
|
234
|
+
# Data Classes
|
|
235
|
+
# ============================================================================
|
|
236
|
+
|
|
237
|
+
@dataclass(frozen=True)
|
|
238
|
+
class CustomThinkingToken:
|
|
239
|
+
"""Configuration for custom thinking token patterns.
|
|
240
|
+
|
|
241
|
+
Attributes:
|
|
242
|
+
from_beginning: Whether content starts inside thinking mode.
|
|
243
|
+
start_token: Custom start token pattern (regex escaped internally).
|
|
244
|
+
end_token: Custom end token pattern (regex escaped internally).
|
|
245
|
+
"""
|
|
246
|
+
from_beginning: bool = False
|
|
247
|
+
start_token: Optional[str] = None
|
|
248
|
+
end_token: Optional[str] = None
|
|
249
|
+
|
|
250
|
+
def __post_init__(self):
|
|
251
|
+
if self.start_token and not self.end_token:
|
|
252
|
+
raise ConfigurationError("end_token required when start_token is specified")
|
|
253
|
+
if self.end_token and not self.start_token:
|
|
254
|
+
raise ConfigurationError("start_token required when end_token is specified")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass
|
|
258
|
+
class LLMConfig:
|
|
259
|
+
"""Configuration for LLM instance."""
|
|
260
|
+
model: str
|
|
261
|
+
api_key: str = DEFAULT_API_KEY
|
|
262
|
+
base_url: str = DEFAULT_BASE_URL
|
|
263
|
+
custom_thinking_token: Optional[CustomThinkingToken] = None
|
|
264
|
+
default_stop_sequences: Optional[List[str]] = None
|
|
265
|
+
timeout: float = DEFAULT_TIMEOUT
|
|
266
|
+
extra_body: Optional[Dict[str, Any]] = None
|
|
267
|
+
use_responses_api: bool = False
|
|
268
|
+
default_headers: Optional[Dict[str, str]] = None
|
|
269
|
+
|
|
270
|
+
# ============================================================================
|
|
271
|
+
# Thinking Parser
|
|
272
|
+
# ============================================================================
|
|
273
|
+
|
|
274
|
+
class ThinkingParser:
|
|
275
|
+
"""Parses thinking tokens from streamed content.
|
|
276
|
+
|
|
277
|
+
Supports multiple thinking tag formats:
|
|
278
|
+
- XML-style: <think>, <thinking>
|
|
279
|
+
- Bracket-style: [THINK]
|
|
280
|
+
- Custom patterns via CustomThinkingToken
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
_BASE_START_PATTERNS: ClassVar[tuple[str, ...]] = (
|
|
284
|
+
r'<think>', r'<thinking>', r'\[THINK\]', r'<thought>'
|
|
285
|
+
)
|
|
286
|
+
_BASE_END_PATTERNS: ClassVar[tuple[str, ...]] = (
|
|
287
|
+
r'</think>', r'</thinking>', r'\[/THINK\]', r'</thought>'
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def __init__(self, custom_token: Optional[CustomThinkingToken] = None):
|
|
291
|
+
self._custom_token = custom_token
|
|
292
|
+
self._start_pattern = self._build_pattern(
|
|
293
|
+
self._BASE_START_PATTERNS,
|
|
294
|
+
custom_token.start_token if custom_token else None
|
|
295
|
+
)
|
|
296
|
+
self._end_pattern = self._build_pattern(
|
|
297
|
+
self._BASE_END_PATTERNS,
|
|
298
|
+
custom_token.end_token if custom_token else None
|
|
299
|
+
)
|
|
300
|
+
self._inside_think = custom_token.from_beginning if custom_token else False
|
|
301
|
+
|
|
302
|
+
@staticmethod
|
|
303
|
+
def _build_pattern(base_patterns: tuple[str, ...], custom: Optional[str]) -> re.Pattern:
|
|
304
|
+
"""Build compiled regex pattern from base patterns and optional custom pattern."""
|
|
305
|
+
patterns = list(base_patterns)
|
|
306
|
+
if custom:
|
|
307
|
+
patterns.append(re.escape(custom))
|
|
308
|
+
return re.compile('|'.join(patterns), flags=re.IGNORECASE)
|
|
309
|
+
|
|
310
|
+
def reset(self, inside_think: Optional[bool] = None) -> None:
|
|
311
|
+
"""Reset parser state."""
|
|
312
|
+
if inside_think is not None:
|
|
313
|
+
self._inside_think = inside_think
|
|
314
|
+
elif self._custom_token:
|
|
315
|
+
self._inside_think = self._custom_token.from_beginning
|
|
316
|
+
else:
|
|
317
|
+
self._inside_think = False
|
|
318
|
+
|
|
319
|
+
def parse(self, content: str) -> tuple[str, str]:
|
|
320
|
+
"""Parse content and separate thinking from answer.
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Tuple of (thinking_part, answer_part).
|
|
324
|
+
"""
|
|
325
|
+
thinking_part = ""
|
|
326
|
+
answer_part = ""
|
|
327
|
+
remaining = content
|
|
328
|
+
|
|
329
|
+
while remaining:
|
|
330
|
+
if self._inside_think:
|
|
331
|
+
match = self._end_pattern.search(remaining)
|
|
332
|
+
if match:
|
|
333
|
+
thinking_part += remaining[:match.start()]
|
|
334
|
+
self._inside_think = False
|
|
335
|
+
remaining = remaining[match.end():]
|
|
336
|
+
else:
|
|
337
|
+
thinking_part += remaining
|
|
338
|
+
remaining = ""
|
|
339
|
+
else:
|
|
340
|
+
match = self._start_pattern.search(remaining)
|
|
341
|
+
if match:
|
|
342
|
+
answer_part += remaining[:match.start()]
|
|
343
|
+
self._inside_think = True
|
|
344
|
+
remaining = remaining[match.end():]
|
|
345
|
+
else:
|
|
346
|
+
answer_part += remaining
|
|
347
|
+
remaining = ""
|
|
348
|
+
|
|
349
|
+
return thinking_part, answer_part
|
|
350
|
+
|
|
351
|
+
@property
|
|
352
|
+
def is_inside_thinking(self) -> bool:
|
|
353
|
+
"""Whether parser is currently inside a thinking block."""
|
|
354
|
+
return self._inside_think
|
|
355
|
+
|
|
356
|
+
# ============================================================================
|
|
357
|
+
# Schema Converter
|
|
358
|
+
# ============================================================================
|
|
359
|
+
|
|
360
|
+
class SchemaConverter:
|
|
361
|
+
"""Converts Python types and classes to JSON Schema format."""
|
|
362
|
+
|
|
363
|
+
_TYPE_MAP: ClassVar[Dict[str, SchemaType]] = {
|
|
364
|
+
"str": SchemaType.STRING,
|
|
365
|
+
"int": SchemaType.INTEGER,
|
|
366
|
+
"float": SchemaType.NUMBER,
|
|
367
|
+
"bool": SchemaType.BOOLEAN,
|
|
368
|
+
"list": SchemaType.ARRAY,
|
|
369
|
+
"dict": SchemaType.OBJECT,
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
_LLM_SUPPORTED_TYPES: ClassVar[frozenset] = frozenset({str, int, float, bool, list, dict})
|
|
373
|
+
|
|
374
|
+
@staticmethod
|
|
375
|
+
def _ordered_object_schema(
|
|
376
|
+
required: Optional[List[str]] = None,
|
|
377
|
+
properties: Optional[Dict[str, Any]] = None,
|
|
378
|
+
additional_properties: Any = False,
|
|
379
|
+
) -> Dict[str, Any]:
|
|
380
|
+
schema: Dict[str, Any] = {"type": SchemaType.OBJECT.value}
|
|
381
|
+
if required:
|
|
382
|
+
schema["required"] = required
|
|
383
|
+
if properties is not None:
|
|
384
|
+
schema["properties"] = properties
|
|
385
|
+
if additional_properties is not None:
|
|
386
|
+
schema["additionalProperties"] = additional_properties
|
|
387
|
+
return schema
|
|
388
|
+
|
|
389
|
+
def python_type_to_json_schema(
|
|
390
|
+
self,
|
|
391
|
+
python_type: Any,
|
|
392
|
+
seen_models: Optional[set] = None
|
|
393
|
+
) -> Dict[str, Any]:
|
|
394
|
+
"""Convert Python type annotation to JSON Schema."""
|
|
395
|
+
seen_models = seen_models or set()
|
|
396
|
+
|
|
397
|
+
if python_type is type(None):
|
|
398
|
+
return {"type": SchemaType.NULL.value}
|
|
399
|
+
|
|
400
|
+
origin = get_origin(python_type)
|
|
401
|
+
args = get_args(python_type)
|
|
402
|
+
|
|
403
|
+
if origin is list:
|
|
404
|
+
schema: Dict[str, Any] = {"type": SchemaType.ARRAY.value}
|
|
405
|
+
if args:
|
|
406
|
+
schema["items"] = self.python_type_to_json_schema(args[0], seen_models)
|
|
407
|
+
return schema
|
|
408
|
+
|
|
409
|
+
if origin is dict:
|
|
410
|
+
schema = self._ordered_object_schema(required=None, properties=None, additional_properties=None)
|
|
411
|
+
if len(args) == 2:
|
|
412
|
+
schema["additionalProperties"] = self.python_type_to_json_schema(args[1], seen_models)
|
|
413
|
+
return schema
|
|
414
|
+
|
|
415
|
+
if origin is Union:
|
|
416
|
+
non_none_types = [t for t in args if t is not type(None)]
|
|
417
|
+
if len(non_none_types) == 1:
|
|
418
|
+
return {
|
|
419
|
+
"anyOf": [
|
|
420
|
+
self.python_type_to_json_schema(non_none_types[0], seen_models),
|
|
421
|
+
{"type": SchemaType.NULL.value}
|
|
422
|
+
]
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
"anyOf": [self.python_type_to_json_schema(t, seen_models) for t in args]
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if origin is Literal:
|
|
429
|
+
return {"enum": list(args)}
|
|
430
|
+
|
|
431
|
+
if self._is_annotated_class(python_type):
|
|
432
|
+
if python_type in seen_models:
|
|
433
|
+
raise SchemaConversionError(
|
|
434
|
+
f"Circular dependency detected for class {python_type.__name__}. "
|
|
435
|
+
"Recursive schemas are not supported."
|
|
436
|
+
)
|
|
437
|
+
nested_schema = self.convert_class_to_schema(python_type, seen_models=seen_models)
|
|
438
|
+
return nested_schema["json_schema"]["schema"]
|
|
439
|
+
|
|
440
|
+
return {"type": self._get_json_type(python_type).value}
|
|
441
|
+
|
|
442
|
+
def _is_annotated_class(self, python_type: Any) -> bool:
|
|
443
|
+
return (
|
|
444
|
+
hasattr(python_type, "__annotations__")
|
|
445
|
+
and python_type.__annotations__
|
|
446
|
+
and isinstance(python_type, type)
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def _get_json_type(self, python_type: Any) -> SchemaType:
|
|
450
|
+
type_name = getattr(python_type, "__name__", str(python_type)).lower()
|
|
451
|
+
return self._TYPE_MAP.get(type_name, SchemaType.STRING)
|
|
452
|
+
|
|
453
|
+
def is_llm_supported_type(self, python_type: Any) -> bool:
|
|
454
|
+
"""Check if a Python type can be meaningfully provided by an LLM."""
|
|
455
|
+
if python_type is None or python_type is type(None):
|
|
456
|
+
return True
|
|
457
|
+
|
|
458
|
+
origin = get_origin(python_type)
|
|
459
|
+
args = get_args(python_type)
|
|
460
|
+
|
|
461
|
+
if origin is list:
|
|
462
|
+
return not args or self.is_llm_supported_type(args[0])
|
|
463
|
+
if origin is dict:
|
|
464
|
+
return len(args) != 2 or self.is_llm_supported_type(args[1])
|
|
465
|
+
if origin is Union:
|
|
466
|
+
non_none = [t for t in args if t is not type(None)]
|
|
467
|
+
return all(self.is_llm_supported_type(t) for t in non_none)
|
|
468
|
+
if origin is Literal:
|
|
469
|
+
return True
|
|
470
|
+
|
|
471
|
+
return python_type in self._LLM_SUPPORTED_TYPES
|
|
472
|
+
|
|
473
|
+
def convert_class_to_schema(
|
|
474
|
+
self,
|
|
475
|
+
schema_class: type,
|
|
476
|
+
name: Optional[str] = None,
|
|
477
|
+
seen_models: Optional[set] = None
|
|
478
|
+
) -> Dict[str, Any]:
|
|
479
|
+
"""Convert plain class with __annotations__ to OpenAI JSON schema."""
|
|
480
|
+
seen_models = seen_models or set()
|
|
481
|
+
|
|
482
|
+
if not hasattr(schema_class, "__annotations__") or not schema_class.__annotations__:
|
|
483
|
+
raise SchemaConversionError(
|
|
484
|
+
f"Class {schema_class.__name__} has no type annotations."
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
seen_models.add(schema_class)
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
hints = get_type_hints(schema_class)
|
|
491
|
+
properties = {}
|
|
492
|
+
required = []
|
|
493
|
+
|
|
494
|
+
class_defaults = {
|
|
495
|
+
k: v for k, v in schema_class.__dict__.items()
|
|
496
|
+
if not k.startswith("_") and not callable(v)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
for field_name, field_type in hints.items():
|
|
500
|
+
properties[field_name] = self.python_type_to_json_schema(field_type, seen_models)
|
|
501
|
+
|
|
502
|
+
is_optional = (
|
|
503
|
+
get_origin(field_type) is Union
|
|
504
|
+
and type(None) in get_args(field_type)
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
if field_name not in class_defaults and not is_optional:
|
|
508
|
+
required.append(field_name)
|
|
509
|
+
|
|
510
|
+
schema = self._ordered_object_schema(
|
|
511
|
+
required=required,
|
|
512
|
+
properties=properties,
|
|
513
|
+
additional_properties=False,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
if doc := inspect.getdoc(schema_class):
|
|
517
|
+
schema["description"] = doc
|
|
518
|
+
|
|
519
|
+
return {
|
|
520
|
+
"type": "json_schema",
|
|
521
|
+
"json_schema": {
|
|
522
|
+
"name": name or schema_class.__name__,
|
|
523
|
+
"strict": True,
|
|
524
|
+
"schema": schema
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
finally:
|
|
528
|
+
seen_models.discard(schema_class)
|
|
529
|
+
|
|
530
|
+
# ============================================================================
|
|
531
|
+
# Tool Preparator
|
|
532
|
+
# ============================================================================
|
|
533
|
+
|
|
534
|
+
@dataclass
|
|
535
|
+
class PreparedTools:
|
|
536
|
+
"""Result of tool preparation."""
|
|
537
|
+
definitions: List[Dict[str, Any]]
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
class ToolPreparator:
|
|
541
|
+
"""Prepares tools for LLM consumption."""
|
|
542
|
+
|
|
543
|
+
def __init__(self, schema_converter: SchemaConverter):
|
|
544
|
+
self._converter = schema_converter
|
|
545
|
+
|
|
546
|
+
def prepare(self, tools: Optional[List[Any]]) -> PreparedTools:
|
|
547
|
+
"""Convert callable functions to OpenAI tool format."""
|
|
548
|
+
if not tools:
|
|
549
|
+
return PreparedTools([])
|
|
550
|
+
|
|
551
|
+
definitions = []
|
|
552
|
+
|
|
553
|
+
for idx, tool in enumerate(tools):
|
|
554
|
+
if callable(tool):
|
|
555
|
+
definitions.append(self._prepare_callable(tool))
|
|
556
|
+
elif isinstance(tool, dict):
|
|
557
|
+
self._validate_tool_dict(tool, idx)
|
|
558
|
+
definitions.append(tool)
|
|
559
|
+
else:
|
|
560
|
+
raise ConfigurationError(
|
|
561
|
+
f"Tool at index {idx} must be callable or dict, got {type(tool).__name__}"
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
return PreparedTools(definitions)
|
|
565
|
+
|
|
566
|
+
def _prepare_callable(self, func: Callable) -> Dict:
|
|
567
|
+
"""Prepare a callable for LLM consumption."""
|
|
568
|
+
underlying = self._unwrap_callable(func)
|
|
569
|
+
|
|
570
|
+
name = (getattr(func, '__name__', None) or underlying.__name__).strip()
|
|
571
|
+
doc = (getattr(underlying, '__doc__', None) or getattr(func, '__doc__', None) or "").strip()
|
|
572
|
+
|
|
573
|
+
try:
|
|
574
|
+
annotations = get_type_hints(underlying)
|
|
575
|
+
except Exception:
|
|
576
|
+
annotations = getattr(underlying, "__annotations__", {})
|
|
577
|
+
|
|
578
|
+
sig = inspect.signature(func)
|
|
579
|
+
|
|
580
|
+
parameters = {}
|
|
581
|
+
required = []
|
|
582
|
+
|
|
583
|
+
for param_name, param in sig.parameters.items():
|
|
584
|
+
if param_name == "return":
|
|
585
|
+
continue
|
|
586
|
+
|
|
587
|
+
param_type = annotations.get(param_name)
|
|
588
|
+
|
|
589
|
+
if param_type is not None and not self._converter.is_llm_supported_type(param_type):
|
|
590
|
+
continue
|
|
591
|
+
|
|
592
|
+
param_schema = (
|
|
593
|
+
self._converter.python_type_to_json_schema(param_type)
|
|
594
|
+
if param_type else {"type": SchemaType.STRING.value}
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
if param.default != inspect.Parameter.empty:
|
|
598
|
+
default_repr = self._format_default(param.default)
|
|
599
|
+
existing = param_schema.get("description", "")
|
|
600
|
+
param_schema["description"] = (
|
|
601
|
+
f"{existing} (Default: {default_repr})" if existing
|
|
602
|
+
else f"Default: {default_repr}"
|
|
603
|
+
)
|
|
604
|
+
else:
|
|
605
|
+
required.append(param_name)
|
|
606
|
+
|
|
607
|
+
parameters[param_name] = param_schema
|
|
608
|
+
|
|
609
|
+
return {
|
|
610
|
+
"type": "function",
|
|
611
|
+
"function": {
|
|
612
|
+
"name": name,
|
|
613
|
+
"description": doc,
|
|
614
|
+
"parameters": self._converter._ordered_object_schema(
|
|
615
|
+
required=required,
|
|
616
|
+
properties=parameters,
|
|
617
|
+
additional_properties=False,
|
|
618
|
+
)
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
@staticmethod
|
|
623
|
+
def _unwrap_callable(func: Callable) -> Callable:
|
|
624
|
+
underlying = inspect.unwrap(func)
|
|
625
|
+
while isinstance(underlying, functools.partial):
|
|
626
|
+
underlying = inspect.unwrap(underlying.func)
|
|
627
|
+
return underlying
|
|
628
|
+
|
|
629
|
+
@staticmethod
|
|
630
|
+
def _format_default(value: Any) -> str:
|
|
631
|
+
if isinstance(value, str):
|
|
632
|
+
return f'"{value}"'
|
|
633
|
+
if value is None:
|
|
634
|
+
return "null"
|
|
635
|
+
return repr(value)
|
|
636
|
+
|
|
637
|
+
@staticmethod
|
|
638
|
+
def _validate_tool_dict(tool: Dict, index: int) -> None:
|
|
639
|
+
if "type" not in tool or "function" not in tool:
|
|
640
|
+
raise ConfigurationError(
|
|
641
|
+
f"Tool at index {index} must have 'type' and 'function' keys"
|
|
642
|
+
)
|
|
643
|
+
if "name" not in tool.get("function", {}):
|
|
644
|
+
raise ConfigurationError(
|
|
645
|
+
f"Tool at index {index} missing 'name' in function definition"
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
class RequestTransformer:
|
|
650
|
+
"""Provider/model-specific request normalizer."""
|
|
651
|
+
|
|
652
|
+
def __init__(self, model: str, api_base: str):
|
|
653
|
+
self._model = model
|
|
654
|
+
self._api_base = api_base.lower()
|
|
655
|
+
|
|
656
|
+
def transform(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
657
|
+
transformed = copy.deepcopy(kwargs)
|
|
658
|
+
transformed = self._normalize_extra_body(transformed)
|
|
659
|
+
transformed = self._normalize_reasoning(transformed)
|
|
660
|
+
transformed = self._normalize_parallel_tool_calls(transformed)
|
|
661
|
+
return transformed
|
|
662
|
+
|
|
663
|
+
def _normalize_extra_body(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
664
|
+
extra_body = kwargs.get("extra_body")
|
|
665
|
+
if extra_body is None:
|
|
666
|
+
return kwargs
|
|
667
|
+
if not isinstance(extra_body, dict):
|
|
668
|
+
kwargs["extra_body"] = {"value": extra_body}
|
|
669
|
+
return kwargs
|
|
670
|
+
if not extra_body:
|
|
671
|
+
kwargs.pop("extra_body", None)
|
|
672
|
+
return kwargs
|
|
673
|
+
|
|
674
|
+
def _normalize_reasoning(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
675
|
+
effort = kwargs.pop("reasoning_effort", None)
|
|
676
|
+
if effort is None:
|
|
677
|
+
return kwargs
|
|
678
|
+
|
|
679
|
+
# OpenRouter-style providers usually accept reasoning controls inside extra_body.
|
|
680
|
+
if "openrouter" in self._api_base:
|
|
681
|
+
extra_body = kwargs.setdefault("extra_body", {})
|
|
682
|
+
reasoning = extra_body.get("reasoning")
|
|
683
|
+
if isinstance(reasoning, dict):
|
|
684
|
+
reasoning.setdefault("effort", effort)
|
|
685
|
+
else:
|
|
686
|
+
extra_body["reasoning"] = {"effort": effort}
|
|
687
|
+
return kwargs
|
|
688
|
+
|
|
689
|
+
kwargs["reasoning_effort"] = effort
|
|
690
|
+
return kwargs
|
|
691
|
+
|
|
692
|
+
def _normalize_parallel_tool_calls(self, kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
693
|
+
if not kwargs.get("tools"):
|
|
694
|
+
return kwargs
|
|
695
|
+
model_name = self._model.lower()
|
|
696
|
+
if "gpt-5" in model_name or "gpt-4.1" in model_name:
|
|
697
|
+
kwargs.setdefault("parallel_tool_calls", True)
|
|
698
|
+
return kwargs
|
|
699
|
+
|
|
700
|
+
# ============================================================================
|
|
701
|
+
# Image Processor
|
|
702
|
+
# ============================================================================
|
|
703
|
+
|
|
704
|
+
class ImageProcessor:
|
|
705
|
+
"""Processes images in messages for API consumption."""
|
|
706
|
+
|
|
707
|
+
_pil_image = None
|
|
708
|
+
|
|
709
|
+
@classmethod
|
|
710
|
+
def _get_pil(cls):
|
|
711
|
+
if cls._pil_image is None:
|
|
712
|
+
try:
|
|
713
|
+
from PIL import Image
|
|
714
|
+
cls._pil_image = Image
|
|
715
|
+
except ImportError:
|
|
716
|
+
raise ImportError("PIL/Pillow required. Install with: pip install Pillow")
|
|
717
|
+
return cls._pil_image
|
|
718
|
+
|
|
719
|
+
@staticmethod
|
|
720
|
+
def process_messages(messages: List[Dict]) -> None:
|
|
721
|
+
for msg in messages:
|
|
722
|
+
content = msg.get("content")
|
|
723
|
+
if not isinstance(content, list):
|
|
724
|
+
continue
|
|
725
|
+
for i, item in enumerate(content):
|
|
726
|
+
if not isinstance(item, dict) or item.get("type") != "image":
|
|
727
|
+
continue
|
|
728
|
+
msg["content"][i] = ImageProcessor._convert_image_item(item)
|
|
729
|
+
|
|
730
|
+
@staticmethod
|
|
731
|
+
def _convert_image_item(item: Dict) -> Dict:
|
|
732
|
+
if "image_path" in item:
|
|
733
|
+
return ImageProcessor._from_path(item["image_path"])
|
|
734
|
+
if "image_pil" in item:
|
|
735
|
+
return ImageProcessor._from_pil(item["image_pil"])
|
|
736
|
+
if "image_url" in item:
|
|
737
|
+
return ImageProcessor._from_url(item["image_url"])
|
|
738
|
+
if "image_base64" in item:
|
|
739
|
+
return ImageProcessor._from_base64(item["image_base64"])
|
|
740
|
+
return item
|
|
741
|
+
|
|
742
|
+
@staticmethod
|
|
743
|
+
def _from_path(path: str) -> Dict:
|
|
744
|
+
Image = ImageProcessor._get_pil()
|
|
745
|
+
try:
|
|
746
|
+
with Image.open(path) as img:
|
|
747
|
+
return ImageProcessor._encode_pil_image(img)
|
|
748
|
+
except Exception as e:
|
|
749
|
+
raise ValueError(f"Failed to process image from path '{path}': {e}")
|
|
750
|
+
|
|
751
|
+
@staticmethod
|
|
752
|
+
def _from_pil(img: "PILImage") -> Dict:
|
|
753
|
+
return ImageProcessor._encode_pil_image(img)
|
|
754
|
+
|
|
755
|
+
@staticmethod
|
|
756
|
+
def _from_url(url_data: Union[str, Dict]) -> Dict:
|
|
757
|
+
if isinstance(url_data, str):
|
|
758
|
+
url_data = {"url": url_data}
|
|
759
|
+
return {"type": "image_url", "image_url": url_data}
|
|
760
|
+
|
|
761
|
+
@staticmethod
|
|
762
|
+
def _from_base64(data: str) -> Dict:
|
|
763
|
+
return {
|
|
764
|
+
"type": "image_url",
|
|
765
|
+
"image_url": {"url": f"data:image/png;base64,{data}"}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
@staticmethod
|
|
769
|
+
def _encode_pil_image(img: "PILImage") -> Dict:
|
|
770
|
+
buffer = io.BytesIO()
|
|
771
|
+
img.save(buffer, format="PNG")
|
|
772
|
+
buffer.seek(0)
|
|
773
|
+
b64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
|
774
|
+
return {
|
|
775
|
+
"type": "image_url",
|
|
776
|
+
"image_url": {"url": f"data:image/png;base64,{b64}"}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
# ============================================================================
|
|
780
|
+
# Event Builder
|
|
781
|
+
# ============================================================================
|
|
782
|
+
|
|
783
|
+
class EventBuilder:
|
|
784
|
+
"""Builds standardized stream events."""
|
|
785
|
+
|
|
786
|
+
@staticmethod
|
|
787
|
+
def _build(
|
|
788
|
+
event_type: EventType,
|
|
789
|
+
content: Any,
|
|
790
|
+
source: Optional[str] = None,
|
|
791
|
+
tool_id: Optional[str] = None,
|
|
792
|
+
job: Optional[int] = None,
|
|
793
|
+
depth: int = 0
|
|
794
|
+
) -> StreamEvent:
|
|
795
|
+
event: StreamEvent = {
|
|
796
|
+
"type": event_type.value,
|
|
797
|
+
"content": content,
|
|
798
|
+
"source": source,
|
|
799
|
+
"depth": depth,
|
|
800
|
+
}
|
|
801
|
+
if tool_id is not None:
|
|
802
|
+
event["tool_id"] = tool_id
|
|
803
|
+
if job is not None:
|
|
804
|
+
event["job"] = job
|
|
805
|
+
return event
|
|
806
|
+
|
|
807
|
+
@staticmethod
|
|
808
|
+
def answer(content: Any, depth: int = 0) -> StreamEvent:
|
|
809
|
+
return EventBuilder._build(EventType.ANSWER, content, depth=depth)
|
|
810
|
+
|
|
811
|
+
@staticmethod
|
|
812
|
+
def reasoning(content: str, depth: int = 0) -> StreamEvent:
|
|
813
|
+
return EventBuilder._build(EventType.REASONING, content, depth=depth)
|
|
814
|
+
|
|
815
|
+
@staticmethod
|
|
816
|
+
def tool_call(content: ToolCall, source: Optional[str] = None, job: Optional[int] = None, depth: int = 0) -> StreamEvent:
|
|
817
|
+
return EventBuilder._build(EventType.TOOL_CALL, content, source, content.get("id"), job, depth)
|
|
818
|
+
|
|
819
|
+
@staticmethod
|
|
820
|
+
def verbose(content: VerboseInfo, depth: int = 0) -> StreamEvent:
|
|
821
|
+
return EventBuilder._build(EventType.VERBOSE, content, depth=depth)
|
|
822
|
+
|
|
823
|
+
@staticmethod
|
|
824
|
+
def final(content: FinalResponse, depth: int = 0) -> StreamEvent:
|
|
825
|
+
return EventBuilder._build(EventType.FINAL, content, depth=depth)
|
|
826
|
+
|
|
827
|
+
@staticmethod
|
|
828
|
+
def done(depth: int = 0) -> StreamEvent:
|
|
829
|
+
return EventBuilder._build(EventType.DONE, None, depth=depth)
|
|
830
|
+
|
|
831
|
+
# ============================================================================
|
|
832
|
+
# Tool Call Accumulator
|
|
833
|
+
# ============================================================================
|
|
834
|
+
|
|
835
|
+
class ToolCallAccumulator:
|
|
836
|
+
"""Accumulates streaming tool call chunks into complete calls."""
|
|
837
|
+
|
|
838
|
+
def __init__(self):
|
|
839
|
+
self._calls: Dict[str, Dict[str, str]] = {}
|
|
840
|
+
self._index_to_id: Dict[int, str] = {}
|
|
841
|
+
|
|
842
|
+
def add_chunk(self, tool_call: Any) -> None:
|
|
843
|
+
idx = getattr(tool_call, "index", 0)
|
|
844
|
+
|
|
845
|
+
if tool_id := getattr(tool_call, "id", None):
|
|
846
|
+
self._index_to_id[idx] = tool_id
|
|
847
|
+
|
|
848
|
+
tool_id = self._index_to_id.get(idx, f"_idx_{idx}")
|
|
849
|
+
|
|
850
|
+
if tool_id not in self._calls:
|
|
851
|
+
self._calls[tool_id] = {"name": "", "arguments": ""}
|
|
852
|
+
|
|
853
|
+
func = tool_call.function
|
|
854
|
+
if func.name:
|
|
855
|
+
self._calls[tool_id]["name"] = func.name
|
|
856
|
+
if func.arguments:
|
|
857
|
+
args = func.arguments
|
|
858
|
+
if isinstance(args, dict):
|
|
859
|
+
args = json.dumps(args)
|
|
860
|
+
self._calls[tool_id]["arguments"] += args or ""
|
|
861
|
+
|
|
862
|
+
def get_completed_calls(self) -> List[ToolCall]:
|
|
863
|
+
result: List[ToolCall] = []
|
|
864
|
+
for tool_id, data in self._calls.items():
|
|
865
|
+
try:
|
|
866
|
+
args = json.loads(data["arguments"] or "{}")
|
|
867
|
+
except json.JSONDecodeError:
|
|
868
|
+
args = {"_raw": data["arguments"] or ""}
|
|
869
|
+
result.append({
|
|
870
|
+
"id": tool_id,
|
|
871
|
+
"name": data["name"],
|
|
872
|
+
"arguments": args
|
|
873
|
+
})
|
|
874
|
+
return result
|
|
875
|
+
|
|
876
|
+
def clear(self) -> None:
|
|
877
|
+
self._calls.clear()
|
|
878
|
+
self._index_to_id.clear()
|
|
879
|
+
|
|
880
|
+
# ============================================================================
|
|
881
|
+
# Main LLM Class
|
|
882
|
+
# ============================================================================
|
|
883
|
+
|
|
884
|
+
class LLM:
|
|
885
|
+
"""Universal API wrapper for LLM models with OpenAI-compatible API.
|
|
886
|
+
|
|
887
|
+
Example:
|
|
888
|
+
>>> llm = LLM("qwen2.5-coder-7b")
|
|
889
|
+
>>> response = llm.response([{"role": "user", "content": "Hello!"}])
|
|
890
|
+
>>> print(response["answer"])
|
|
891
|
+
"""
|
|
892
|
+
|
|
893
|
+
def __init__(
|
|
894
|
+
self,
|
|
895
|
+
model: str,
|
|
896
|
+
api_key: str = DEFAULT_API_KEY,
|
|
897
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
898
|
+
custom_thinking_token: Optional[CustomThinkingToken] = None,
|
|
899
|
+
default_stop_sequences: Optional[List[str]] = None,
|
|
900
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
901
|
+
extra_body: Optional[Dict[str, Any]] = None,
|
|
902
|
+
use_responses_api: bool = False,
|
|
903
|
+
default_headers: Optional[Dict[str, str]] = None,
|
|
904
|
+
):
|
|
905
|
+
self._config = LLMConfig(
|
|
906
|
+
model=model,
|
|
907
|
+
api_key=api_key,
|
|
908
|
+
base_url=base_url.rstrip("/"),
|
|
909
|
+
custom_thinking_token=custom_thinking_token,
|
|
910
|
+
default_stop_sequences=default_stop_sequences,
|
|
911
|
+
timeout=timeout,
|
|
912
|
+
extra_body=extra_body,
|
|
913
|
+
use_responses_api=use_responses_api,
|
|
914
|
+
default_headers=default_headers,
|
|
915
|
+
)
|
|
916
|
+
|
|
917
|
+
self._api_base = self._compute_api_base()
|
|
918
|
+
|
|
919
|
+
self._client = OpenAI(
|
|
920
|
+
base_url=self._api_base,
|
|
921
|
+
api_key=api_key,
|
|
922
|
+
timeout=self._config.timeout,
|
|
923
|
+
default_headers=default_headers,
|
|
924
|
+
)
|
|
925
|
+
self._async_client = AsyncOpenAI(
|
|
926
|
+
base_url=self._api_base,
|
|
927
|
+
api_key=api_key,
|
|
928
|
+
timeout=self._config.timeout,
|
|
929
|
+
default_headers=default_headers,
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
self._schema_converter = SchemaConverter()
|
|
933
|
+
self._tool_preparator = ToolPreparator(self._schema_converter)
|
|
934
|
+
self._event_builder = EventBuilder()
|
|
935
|
+
self._request_transformer = RequestTransformer(model, self._api_base)
|
|
936
|
+
|
|
937
|
+
logger.debug(f"LLM initialized: model={model}, base_url={self._api_base}")
|
|
938
|
+
|
|
939
|
+
def _compute_api_base(self) -> str:
|
|
940
|
+
base = self._config.base_url.rstrip("/")
|
|
941
|
+
if re.search(r"/v\d+$", base):
|
|
942
|
+
return base
|
|
943
|
+
return f"{base}/v1"
|
|
944
|
+
|
|
945
|
+
@property
|
|
946
|
+
def model(self) -> str:
|
|
947
|
+
return self._config.model
|
|
948
|
+
|
|
949
|
+
@property
|
|
950
|
+
def base_url(self) -> str:
|
|
951
|
+
return self._config.base_url
|
|
952
|
+
|
|
953
|
+
def list_models(self, fallback: Optional[List[str]] = None) -> List[str]:
|
|
954
|
+
"""Return model IDs from the configured API, or fallback/[] on failure."""
|
|
955
|
+
try:
|
|
956
|
+
return sorted({model.id for model in self._client.models.list().data})
|
|
957
|
+
except Exception:
|
|
958
|
+
return list(fallback or [])
|
|
959
|
+
|
|
960
|
+
# ========================================================================
|
|
961
|
+
# Output Format Handling
|
|
962
|
+
# ========================================================================
|
|
963
|
+
|
|
964
|
+
def _prepare_output_format(self, output_format: Union[Dict, type, None]) -> Optional[Dict]:
|
|
965
|
+
if output_format is None:
|
|
966
|
+
return None
|
|
967
|
+
if isinstance(output_format, dict):
|
|
968
|
+
return output_format
|
|
969
|
+
if isinstance(output_format, type):
|
|
970
|
+
return self._schema_converter.convert_class_to_schema(output_format)
|
|
971
|
+
raise ConfigurationError(
|
|
972
|
+
f"output_format must be dict, type, or None, got {type(output_format).__name__}"
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
# ========================================================================
|
|
976
|
+
# Request Builder
|
|
977
|
+
# ========================================================================
|
|
978
|
+
|
|
979
|
+
def _build_request(
|
|
980
|
+
self,
|
|
981
|
+
messages: List[Dict],
|
|
982
|
+
output_format: Optional[Dict],
|
|
983
|
+
tools: Optional[List],
|
|
984
|
+
reasoning_effort: Optional[str],
|
|
985
|
+
max_tokens: Optional[int],
|
|
986
|
+
extra_body: Optional[Dict],
|
|
987
|
+
) -> tuple[Dict[str, Any], PreparedTools, bool]:
|
|
988
|
+
"""Build API request kwargs. Returns (kwargs, prepared_tools, structured_output)."""
|
|
989
|
+
request_messages = copy.deepcopy(messages)
|
|
990
|
+
prepared_tools = self._tool_preparator.prepare(tools)
|
|
991
|
+
ImageProcessor.process_messages(request_messages)
|
|
992
|
+
structured_output = output_format is not None
|
|
993
|
+
|
|
994
|
+
kwargs: Dict[str, Any] = {
|
|
995
|
+
"model": self._config.model,
|
|
996
|
+
"messages": request_messages,
|
|
997
|
+
"stream": True,
|
|
998
|
+
"stream_options": {"include_usage": True},
|
|
999
|
+
}
|
|
1000
|
+
if prepared_tools.definitions:
|
|
1001
|
+
kwargs["tools"] = prepared_tools.definitions
|
|
1002
|
+
merged_extra_body = _deep_merge_dicts(self._config.extra_body, extra_body)
|
|
1003
|
+
if merged_extra_body:
|
|
1004
|
+
kwargs["extra_body"] = merged_extra_body
|
|
1005
|
+
if reasoning_effort:
|
|
1006
|
+
kwargs["reasoning_effort"] = reasoning_effort
|
|
1007
|
+
if max_tokens is not None:
|
|
1008
|
+
kwargs["max_tokens"] = max_tokens
|
|
1009
|
+
if self._config.default_stop_sequences:
|
|
1010
|
+
kwargs["stop"] = list(self._config.default_stop_sequences)
|
|
1011
|
+
if structured_output:
|
|
1012
|
+
kwargs["response_format"] = output_format
|
|
1013
|
+
|
|
1014
|
+
return self._request_transformer.transform(kwargs), prepared_tools, structured_output
|
|
1015
|
+
|
|
1016
|
+
@staticmethod
|
|
1017
|
+
def _extract_reasoning(delta: Any) -> str:
|
|
1018
|
+
"""Return streamed reasoning content from supported delta fields."""
|
|
1019
|
+
reasoning_content = getattr(delta, "reasoning_content", None)
|
|
1020
|
+
if reasoning_content:
|
|
1021
|
+
return str(reasoning_content)
|
|
1022
|
+
reasoning = getattr(delta, "reasoning", None)
|
|
1023
|
+
return str(reasoning) if reasoning else ""
|
|
1024
|
+
|
|
1025
|
+
# ========================================================================
|
|
1026
|
+
# Responses API – Request Builder & Helpers
|
|
1027
|
+
# ========================================================================
|
|
1028
|
+
|
|
1029
|
+
@staticmethod
|
|
1030
|
+
def _translate_content_for_responses_api(content: Any) -> Any:
|
|
1031
|
+
"""
|
|
1032
|
+
Translate a message content value from Chat Completions format
|
|
1033
|
+
to Responses API format.
|
|
1034
|
+
|
|
1035
|
+
- String content: unchanged (compatible with both APIs)
|
|
1036
|
+
- Array items:
|
|
1037
|
+
{"type": "text", "text": "..."}
|
|
1038
|
+
→ {"type": "input_text", "text": "..."}
|
|
1039
|
+
{"type": "image_url", "image_url": {"url": "..."}}
|
|
1040
|
+
→ {"type": "input_image", "image_url": "<url-string>"}
|
|
1041
|
+
"""
|
|
1042
|
+
if not isinstance(content, list):
|
|
1043
|
+
return content
|
|
1044
|
+
|
|
1045
|
+
translated: List[Dict] = []
|
|
1046
|
+
for item in content:
|
|
1047
|
+
if not isinstance(item, dict):
|
|
1048
|
+
translated.append(item)
|
|
1049
|
+
continue
|
|
1050
|
+
t = item.get("type", "")
|
|
1051
|
+
if t == "text":
|
|
1052
|
+
translated.append({"type": "input_text", "text": item.get("text", "")})
|
|
1053
|
+
elif t == "image_url":
|
|
1054
|
+
url_data = item.get("image_url", {})
|
|
1055
|
+
url = url_data.get("url", "") if isinstance(url_data, dict) else str(url_data)
|
|
1056
|
+
translated.append({"type": "input_image", "image_url": url})
|
|
1057
|
+
elif t == "image_base64":
|
|
1058
|
+
translated.append({
|
|
1059
|
+
"type": "input_image",
|
|
1060
|
+
"image_url": f"data:image/png;base64,{item.get('image_base64', '')}",
|
|
1061
|
+
})
|
|
1062
|
+
else:
|
|
1063
|
+
translated.append(item)
|
|
1064
|
+
return translated
|
|
1065
|
+
|
|
1066
|
+
@staticmethod
|
|
1067
|
+
def _convert_output_format_for_responses_api(output_format: Dict) -> Dict:
|
|
1068
|
+
"""
|
|
1069
|
+
Convert from Chat Completions response_format to Responses API text.format.
|
|
1070
|
+
|
|
1071
|
+
Input: {"type": "json_schema", "json_schema": {"name": "...", "strict": True, "schema": {...}}}
|
|
1072
|
+
Output: {"type": "json_schema", "name": "...", "strict": True, "schema": {...}}
|
|
1073
|
+
"""
|
|
1074
|
+
if output_format.get("type") == "json_schema":
|
|
1075
|
+
js = output_format.get("json_schema", {})
|
|
1076
|
+
return {
|
|
1077
|
+
"type": "json_schema",
|
|
1078
|
+
"name": js.get("name", "response"),
|
|
1079
|
+
"strict": js.get("strict", True),
|
|
1080
|
+
"schema": js.get("schema", {}),
|
|
1081
|
+
}
|
|
1082
|
+
return output_format
|
|
1083
|
+
|
|
1084
|
+
@staticmethod
|
|
1085
|
+
def _convert_tools_for_responses_api(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
1086
|
+
"""Convert Chat Completions function tools to Responses API function tools."""
|
|
1087
|
+
converted: List[Dict[str, Any]] = []
|
|
1088
|
+
for tool in tools:
|
|
1089
|
+
if tool.get("type") != "function" or "function" not in tool:
|
|
1090
|
+
converted.append(tool)
|
|
1091
|
+
continue
|
|
1092
|
+
|
|
1093
|
+
function = tool.get("function") or {}
|
|
1094
|
+
parameters = copy.deepcopy(function.get("parameters") or {})
|
|
1095
|
+
properties = parameters.get("properties")
|
|
1096
|
+
if isinstance(properties, dict):
|
|
1097
|
+
parameters.setdefault("additionalProperties", False)
|
|
1098
|
+
converted.append({
|
|
1099
|
+
"type": "function",
|
|
1100
|
+
"name": function.get("name", ""),
|
|
1101
|
+
"description": function.get("description") or "",
|
|
1102
|
+
"parameters": parameters,
|
|
1103
|
+
"strict": function.get("strict", True),
|
|
1104
|
+
})
|
|
1105
|
+
return converted
|
|
1106
|
+
|
|
1107
|
+
@staticmethod
|
|
1108
|
+
def _read_usage(usage: Any) -> Dict[str, Optional[int]]:
|
|
1109
|
+
if not usage:
|
|
1110
|
+
return {}
|
|
1111
|
+
|
|
1112
|
+
def first_present(*names: str) -> Optional[int]:
|
|
1113
|
+
for name in names:
|
|
1114
|
+
value = usage.get(name) if isinstance(usage, dict) else getattr(usage, name, None)
|
|
1115
|
+
if value is not None:
|
|
1116
|
+
return int(value)
|
|
1117
|
+
return None
|
|
1118
|
+
|
|
1119
|
+
return {
|
|
1120
|
+
"prompt_tokens": first_present("input_tokens", "prompt_tokens"),
|
|
1121
|
+
"completion_tokens": first_present("output_tokens", "completion_tokens"),
|
|
1122
|
+
"total_tokens": first_present("total_tokens"),
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
@staticmethod
|
|
1126
|
+
def _new_responses_state() -> Dict[str, Any]:
|
|
1127
|
+
return {
|
|
1128
|
+
"thinking": "",
|
|
1129
|
+
"answer": "",
|
|
1130
|
+
"tokens": 0,
|
|
1131
|
+
"api_usage": {},
|
|
1132
|
+
"pending_tool_items": {},
|
|
1133
|
+
"pending_tool_indexes": {},
|
|
1134
|
+
"completed_tool_calls": [],
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
def _handle_responses_event(
|
|
1138
|
+
self,
|
|
1139
|
+
event: Any,
|
|
1140
|
+
state: Dict[str, Any],
|
|
1141
|
+
structured_output: bool,
|
|
1142
|
+
hide_thinking: bool,
|
|
1143
|
+
) -> List[StreamEvent]:
|
|
1144
|
+
state["tokens"] += 1
|
|
1145
|
+
emitted: List[StreamEvent] = []
|
|
1146
|
+
etype = getattr(event, "type", "")
|
|
1147
|
+
|
|
1148
|
+
if etype == "response.completed":
|
|
1149
|
+
response = getattr(event, "response", None)
|
|
1150
|
+
state["api_usage"] = self._read_usage(getattr(response, "usage", None))
|
|
1151
|
+
return emitted
|
|
1152
|
+
|
|
1153
|
+
if etype == "response.output_text.delta":
|
|
1154
|
+
chunk = getattr(event, "delta", "") or ""
|
|
1155
|
+
if chunk:
|
|
1156
|
+
state["answer"] += chunk
|
|
1157
|
+
if not structured_output:
|
|
1158
|
+
emitted.append(self._event_builder.answer(chunk))
|
|
1159
|
+
return emitted
|
|
1160
|
+
|
|
1161
|
+
if etype in ("response.reasoning_summary_text.delta", "response.reasoning_text.delta"):
|
|
1162
|
+
chunk = getattr(event, "delta", "") or ""
|
|
1163
|
+
if chunk:
|
|
1164
|
+
state["thinking"] += chunk
|
|
1165
|
+
if not hide_thinking:
|
|
1166
|
+
emitted.append(self._event_builder.reasoning(chunk))
|
|
1167
|
+
return emitted
|
|
1168
|
+
|
|
1169
|
+
if etype == "response.output_item.added":
|
|
1170
|
+
item = getattr(event, "item", None)
|
|
1171
|
+
if item and getattr(item, "type", "") == "function_call":
|
|
1172
|
+
output_index = getattr(event, "output_index", None)
|
|
1173
|
+
pending = {
|
|
1174
|
+
"call_id": getattr(item, "call_id", None) or getattr(item, "id", ""),
|
|
1175
|
+
"name": getattr(item, "name", "") or "",
|
|
1176
|
+
}
|
|
1177
|
+
state["pending_tool_items"][getattr(item, "id", "")] = pending
|
|
1178
|
+
if output_index is not None:
|
|
1179
|
+
state["pending_tool_indexes"][int(output_index)] = pending
|
|
1180
|
+
return emitted
|
|
1181
|
+
|
|
1182
|
+
if etype == "response.output_item.done":
|
|
1183
|
+
item = getattr(event, "item", None)
|
|
1184
|
+
if item and getattr(item, "type", "") == "function_call":
|
|
1185
|
+
output_index = getattr(event, "output_index", None)
|
|
1186
|
+
pending = state["pending_tool_items"].setdefault(getattr(item, "id", ""), {})
|
|
1187
|
+
pending["call_id"] = (
|
|
1188
|
+
getattr(item, "call_id", None)
|
|
1189
|
+
or pending.get("call_id", "")
|
|
1190
|
+
or getattr(item, "id", "")
|
|
1191
|
+
)
|
|
1192
|
+
pending["name"] = getattr(item, "name", "") or pending.get("name", "")
|
|
1193
|
+
if output_index is not None:
|
|
1194
|
+
state["pending_tool_indexes"][int(output_index)] = pending
|
|
1195
|
+
return emitted
|
|
1196
|
+
|
|
1197
|
+
if etype == "response.function_call_arguments.done":
|
|
1198
|
+
item_id = getattr(event, "item_id", None)
|
|
1199
|
+
output_index = getattr(event, "output_index", None)
|
|
1200
|
+
args_str = getattr(event, "arguments", "{}") or "{}"
|
|
1201
|
+
fn_name = getattr(event, "name", "")
|
|
1202
|
+
|
|
1203
|
+
pending = state["pending_tool_items"].pop(item_id, {}) if item_id else {}
|
|
1204
|
+
if not pending and output_index is not None:
|
|
1205
|
+
pending = state["pending_tool_indexes"].get(int(output_index), {})
|
|
1206
|
+
|
|
1207
|
+
try:
|
|
1208
|
+
args = json.loads(args_str)
|
|
1209
|
+
except json.JSONDecodeError:
|
|
1210
|
+
args = {"_raw": args_str}
|
|
1211
|
+
|
|
1212
|
+
state["completed_tool_calls"].append({
|
|
1213
|
+
"id": pending.get("call_id", item_id or ""),
|
|
1214
|
+
"name": fn_name or pending.get("name", ""),
|
|
1215
|
+
"arguments": args,
|
|
1216
|
+
})
|
|
1217
|
+
return emitted
|
|
1218
|
+
|
|
1219
|
+
return emitted
|
|
1220
|
+
|
|
1221
|
+
def _finalize_responses_stream(
|
|
1222
|
+
self,
|
|
1223
|
+
state: Dict[str, Any],
|
|
1224
|
+
structured_output: bool,
|
|
1225
|
+
verbose: bool,
|
|
1226
|
+
final: bool,
|
|
1227
|
+
hide_thinking: bool,
|
|
1228
|
+
latency: Optional[float],
|
|
1229
|
+
elapsed: float,
|
|
1230
|
+
) -> Generator[StreamEvent, None, None]:
|
|
1231
|
+
answer = state["answer"]
|
|
1232
|
+
if structured_output:
|
|
1233
|
+
try:
|
|
1234
|
+
answer = json.loads(answer)
|
|
1235
|
+
except json.JSONDecodeError:
|
|
1236
|
+
pass
|
|
1237
|
+
yield self._event_builder.answer(answer)
|
|
1238
|
+
|
|
1239
|
+
completed_tool_calls = state["completed_tool_calls"]
|
|
1240
|
+
for idx, tc in enumerate(completed_tool_calls):
|
|
1241
|
+
yield self._event_builder.tool_call(tc, source=tc["name"], job=idx + 1)
|
|
1242
|
+
|
|
1243
|
+
api_usage = state["api_usage"]
|
|
1244
|
+
stream_tokens = int(state["tokens"])
|
|
1245
|
+
completion_tokens = api_usage.get("completion_tokens")
|
|
1246
|
+
tokens = int(completion_tokens) if completion_tokens is not None else stream_tokens
|
|
1247
|
+
total_tokens = api_usage.get("total_tokens")
|
|
1248
|
+
prompt_tokens = api_usage.get("prompt_tokens")
|
|
1249
|
+
if total_tokens is None and prompt_tokens is not None:
|
|
1250
|
+
total_tokens = int(prompt_tokens) + tokens
|
|
1251
|
+
|
|
1252
|
+
verbose_info: VerboseInfo = {
|
|
1253
|
+
"tokens": tokens,
|
|
1254
|
+
"tokens_per_second": tokens / elapsed if elapsed > 0 else 0,
|
|
1255
|
+
"latency": latency,
|
|
1256
|
+
"prompt_tokens": prompt_tokens,
|
|
1257
|
+
"completion_tokens": completion_tokens if completion_tokens is not None else stream_tokens,
|
|
1258
|
+
"total_tokens": total_tokens,
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
if verbose:
|
|
1262
|
+
yield self._event_builder.verbose(verbose_info)
|
|
1263
|
+
|
|
1264
|
+
if final:
|
|
1265
|
+
final_response: FinalResponse = {
|
|
1266
|
+
"answer": answer.strip() if isinstance(answer, str) else answer
|
|
1267
|
+
}
|
|
1268
|
+
thinking = state["thinking"]
|
|
1269
|
+
if not hide_thinking and thinking.strip():
|
|
1270
|
+
final_response["reasoning"] = thinking.strip()
|
|
1271
|
+
if completed_tool_calls:
|
|
1272
|
+
final_response["tool_calls"] = completed_tool_calls
|
|
1273
|
+
if verbose:
|
|
1274
|
+
final_response["verbose"] = verbose_info
|
|
1275
|
+
yield self._event_builder.final(final_response)
|
|
1276
|
+
|
|
1277
|
+
yield self._event_builder.done()
|
|
1278
|
+
|
|
1279
|
+
def _build_responses_request(
|
|
1280
|
+
self,
|
|
1281
|
+
messages: List[Dict],
|
|
1282
|
+
output_format: Optional[Dict],
|
|
1283
|
+
tools: Optional[List],
|
|
1284
|
+
reasoning_effort: Optional[str],
|
|
1285
|
+
max_tokens: Optional[int],
|
|
1286
|
+
extra_body: Optional[Dict],
|
|
1287
|
+
) -> tuple[Dict[str, Any], PreparedTools, bool]:
|
|
1288
|
+
"""
|
|
1289
|
+
Build a request payload for the Responses API (/v1/responses).
|
|
1290
|
+
Returns (kwargs, prepared_tools, structured_output).
|
|
1291
|
+
"""
|
|
1292
|
+
# Deep-copy and run image processing (same as Chat Completions path)
|
|
1293
|
+
raw_messages = copy.deepcopy(messages)
|
|
1294
|
+
ImageProcessor.process_messages(raw_messages)
|
|
1295
|
+
|
|
1296
|
+
# Translate message content format
|
|
1297
|
+
input_messages: List[Dict] = []
|
|
1298
|
+
for msg in raw_messages:
|
|
1299
|
+
translated = dict(msg)
|
|
1300
|
+
translated["content"] = self._translate_content_for_responses_api(msg.get("content"))
|
|
1301
|
+
input_messages.append(translated)
|
|
1302
|
+
|
|
1303
|
+
prepared_tools = self._tool_preparator.prepare(tools)
|
|
1304
|
+
structured_output = output_format is not None
|
|
1305
|
+
|
|
1306
|
+
kwargs: Dict[str, Any] = {
|
|
1307
|
+
"model": self._config.model,
|
|
1308
|
+
"input": input_messages,
|
|
1309
|
+
"stream": True,
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
if prepared_tools.definitions:
|
|
1313
|
+
kwargs["tools"] = self._convert_tools_for_responses_api(prepared_tools.definitions)
|
|
1314
|
+
model_name = self._config.model.lower()
|
|
1315
|
+
if "gpt-5" in model_name or "gpt-4.1" in model_name:
|
|
1316
|
+
kwargs.setdefault("parallel_tool_calls", True)
|
|
1317
|
+
|
|
1318
|
+
# Reasoning effort has a different key in the Responses API
|
|
1319
|
+
if reasoning_effort:
|
|
1320
|
+
kwargs["reasoning"] = {"effort": reasoning_effort}
|
|
1321
|
+
|
|
1322
|
+
# max_output_tokens replaces max_tokens
|
|
1323
|
+
if max_tokens is not None:
|
|
1324
|
+
kwargs["max_output_tokens"] = max_tokens
|
|
1325
|
+
|
|
1326
|
+
# Structured output via text.format instead of response_format
|
|
1327
|
+
if structured_output:
|
|
1328
|
+
kwargs["text"] = {"format": self._convert_output_format_for_responses_api(output_format)}
|
|
1329
|
+
|
|
1330
|
+
merged_extra_body = _deep_merge_dicts(self._config.extra_body, extra_body)
|
|
1331
|
+
if merged_extra_body:
|
|
1332
|
+
kwargs["extra_body"] = merged_extra_body
|
|
1333
|
+
|
|
1334
|
+
return kwargs, prepared_tools, structured_output
|
|
1335
|
+
|
|
1336
|
+
def _stream_responses_sync(
|
|
1337
|
+
self,
|
|
1338
|
+
messages: List[Dict],
|
|
1339
|
+
output_format: Optional[Dict],
|
|
1340
|
+
tools: Optional[List],
|
|
1341
|
+
reasoning_effort: Optional[str],
|
|
1342
|
+
max_tokens: Optional[int],
|
|
1343
|
+
verbose: bool,
|
|
1344
|
+
hide_thinking: bool,
|
|
1345
|
+
final: bool,
|
|
1346
|
+
extra_body: Optional[Dict],
|
|
1347
|
+
) -> Generator[StreamEvent, None, None]:
|
|
1348
|
+
"""
|
|
1349
|
+
Sync streaming via the Responses API (/v1/responses).
|
|
1350
|
+
Yields the same StreamEvent format as the Chat Completions path.
|
|
1351
|
+
"""
|
|
1352
|
+
kwargs, _, structured_output = self._build_responses_request(
|
|
1353
|
+
messages, output_format, tools, reasoning_effort, max_tokens, extra_body
|
|
1354
|
+
)
|
|
1355
|
+
|
|
1356
|
+
state = self._new_responses_state()
|
|
1357
|
+
start_time = time.perf_counter()
|
|
1358
|
+
latency: Optional[float] = None
|
|
1359
|
+
|
|
1360
|
+
try:
|
|
1361
|
+
stream = self._client.responses.create(**kwargs)
|
|
1362
|
+
except Exception as e:
|
|
1363
|
+
raise ModelRequestError(f"Responses API request failed: {e}")
|
|
1364
|
+
|
|
1365
|
+
try:
|
|
1366
|
+
for event in stream:
|
|
1367
|
+
if latency is None:
|
|
1368
|
+
latency = time.perf_counter() - start_time
|
|
1369
|
+
for emitted in self._handle_responses_event(
|
|
1370
|
+
event, state, structured_output, hide_thinking
|
|
1371
|
+
):
|
|
1372
|
+
yield emitted
|
|
1373
|
+
|
|
1374
|
+
except Exception as e:
|
|
1375
|
+
raise ModelRequestError(f"Responses API stream failed: {e}") from e
|
|
1376
|
+
|
|
1377
|
+
elapsed = time.perf_counter() - start_time
|
|
1378
|
+
yield from self._finalize_responses_stream(
|
|
1379
|
+
state, structured_output, verbose, final, hide_thinking, latency, elapsed
|
|
1380
|
+
)
|
|
1381
|
+
|
|
1382
|
+
async def _stream_responses_async(
|
|
1383
|
+
self,
|
|
1384
|
+
messages: List[Dict],
|
|
1385
|
+
output_format: Optional[Dict],
|
|
1386
|
+
tools: Optional[List],
|
|
1387
|
+
reasoning_effort: Optional[str],
|
|
1388
|
+
max_tokens: Optional[int],
|
|
1389
|
+
verbose: bool,
|
|
1390
|
+
hide_thinking: bool,
|
|
1391
|
+
final: bool,
|
|
1392
|
+
extra_body: Optional[Dict],
|
|
1393
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
1394
|
+
"""
|
|
1395
|
+
Async streaming via the Responses API (/v1/responses).
|
|
1396
|
+
Yields the same StreamEvent format as the Chat Completions path.
|
|
1397
|
+
"""
|
|
1398
|
+
kwargs, _, structured_output = self._build_responses_request(
|
|
1399
|
+
messages, output_format, tools, reasoning_effort, max_tokens, extra_body
|
|
1400
|
+
)
|
|
1401
|
+
|
|
1402
|
+
state = self._new_responses_state()
|
|
1403
|
+
start_time = time.perf_counter()
|
|
1404
|
+
latency: Optional[float] = None
|
|
1405
|
+
|
|
1406
|
+
try:
|
|
1407
|
+
create_call = self._async_client.responses.create(**kwargs)
|
|
1408
|
+
stream = await create_call if asyncio.iscoroutine(create_call) else create_call
|
|
1409
|
+
except Exception as e:
|
|
1410
|
+
raise ModelRequestError(f"Async Responses API request failed: {e}")
|
|
1411
|
+
|
|
1412
|
+
try:
|
|
1413
|
+
async for event in stream:
|
|
1414
|
+
if latency is None:
|
|
1415
|
+
latency = time.perf_counter() - start_time
|
|
1416
|
+
for emitted in self._handle_responses_event(
|
|
1417
|
+
event, state, structured_output, hide_thinking
|
|
1418
|
+
):
|
|
1419
|
+
yield emitted
|
|
1420
|
+
|
|
1421
|
+
except Exception as e:
|
|
1422
|
+
raise ModelRequestError(f"Async Responses API stream failed: {e}") from e
|
|
1423
|
+
|
|
1424
|
+
elapsed = time.perf_counter() - start_time
|
|
1425
|
+
for emitted in self._finalize_responses_stream(
|
|
1426
|
+
state, structured_output, verbose, final, hide_thinking, latency, elapsed
|
|
1427
|
+
):
|
|
1428
|
+
yield emitted
|
|
1429
|
+
|
|
1430
|
+
def response(
|
|
1431
|
+
self,
|
|
1432
|
+
messages: Optional[InputValue] = None,
|
|
1433
|
+
output_format: Union[Dict, type, None] = None,
|
|
1434
|
+
tools: Optional[List] = None,
|
|
1435
|
+
verbose: bool = False,
|
|
1436
|
+
hide_thinking: bool = True,
|
|
1437
|
+
reasoning_effort: Optional[str] = None,
|
|
1438
|
+
max_tokens: Optional[int] = None,
|
|
1439
|
+
extra_body: Optional[Dict] = None,
|
|
1440
|
+
input: Optional[InputValue] = None,
|
|
1441
|
+
) -> FinalResponse:
|
|
1442
|
+
"""Request model inference (non-streaming)."""
|
|
1443
|
+
resolved_messages = _resolve_messages(messages, input)
|
|
1444
|
+
output_format = self._prepare_output_format(output_format)
|
|
1445
|
+
|
|
1446
|
+
final_content = None
|
|
1447
|
+
last_answer = ""
|
|
1448
|
+
|
|
1449
|
+
for event in self.stream_response(
|
|
1450
|
+
messages=resolved_messages,
|
|
1451
|
+
output_format=output_format,
|
|
1452
|
+
final=True,
|
|
1453
|
+
tools=tools,
|
|
1454
|
+
hide_thinking=hide_thinking,
|
|
1455
|
+
reasoning_effort=reasoning_effort,
|
|
1456
|
+
max_tokens=max_tokens,
|
|
1457
|
+
verbose=verbose,
|
|
1458
|
+
extra_body=extra_body,
|
|
1459
|
+
):
|
|
1460
|
+
if event.get("type") == EventType.ANSWER.value:
|
|
1461
|
+
content = event.get("content")
|
|
1462
|
+
if isinstance(content, str):
|
|
1463
|
+
last_answer += content
|
|
1464
|
+
elif event.get("type") == EventType.FINAL.value:
|
|
1465
|
+
final_content = event.get("content")
|
|
1466
|
+
break
|
|
1467
|
+
|
|
1468
|
+
if final_content is None:
|
|
1469
|
+
return {"answer": last_answer}
|
|
1470
|
+
|
|
1471
|
+
return final_content
|
|
1472
|
+
|
|
1473
|
+
def stream_response(
|
|
1474
|
+
self,
|
|
1475
|
+
messages: Optional[InputValue] = None,
|
|
1476
|
+
output_format: Union[Dict, type, None] = None,
|
|
1477
|
+
final: bool = False,
|
|
1478
|
+
tools: Optional[List] = None,
|
|
1479
|
+
hide_thinking: bool = True,
|
|
1480
|
+
reasoning_effort: Optional[str] = None,
|
|
1481
|
+
max_tokens: Optional[int] = None,
|
|
1482
|
+
verbose: bool = False,
|
|
1483
|
+
extra_body: Optional[Dict] = None,
|
|
1484
|
+
input: Optional[InputValue] = None,
|
|
1485
|
+
) -> Generator[StreamEvent, None, None]:
|
|
1486
|
+
"""Request model inference with streaming."""
|
|
1487
|
+
resolved_messages = _resolve_messages(messages, input)
|
|
1488
|
+
output_format = self._prepare_output_format(output_format)
|
|
1489
|
+
if self._config.use_responses_api:
|
|
1490
|
+
yield from self._stream_responses_sync(
|
|
1491
|
+
messages=resolved_messages,
|
|
1492
|
+
output_format=output_format,
|
|
1493
|
+
tools=tools,
|
|
1494
|
+
reasoning_effort=reasoning_effort,
|
|
1495
|
+
max_tokens=max_tokens,
|
|
1496
|
+
verbose=verbose,
|
|
1497
|
+
hide_thinking=hide_thinking,
|
|
1498
|
+
final=final,
|
|
1499
|
+
extra_body=extra_body,
|
|
1500
|
+
)
|
|
1501
|
+
return
|
|
1502
|
+
|
|
1503
|
+
kwargs, _, structured_output = self._build_request(
|
|
1504
|
+
resolved_messages, output_format, tools, reasoning_effort, max_tokens, extra_body
|
|
1505
|
+
)
|
|
1506
|
+
|
|
1507
|
+
thinking_parser = ThinkingParser(self._config.custom_thinking_token)
|
|
1508
|
+
tool_accumulator = ToolCallAccumulator()
|
|
1509
|
+
|
|
1510
|
+
thinking = ""
|
|
1511
|
+
answer = ""
|
|
1512
|
+
start_time = time.perf_counter()
|
|
1513
|
+
latency: Optional[float] = None
|
|
1514
|
+
tokens = 0
|
|
1515
|
+
|
|
1516
|
+
try:
|
|
1517
|
+
completion = self._client.chat.completions.create(**kwargs)
|
|
1518
|
+
except Exception as e:
|
|
1519
|
+
if "stream_options" in kwargs:
|
|
1520
|
+
kwargs_copy = dict(kwargs)
|
|
1521
|
+
kwargs_copy.pop("stream_options", None)
|
|
1522
|
+
try:
|
|
1523
|
+
completion = self._client.chat.completions.create(**kwargs_copy)
|
|
1524
|
+
except Exception as inner_e:
|
|
1525
|
+
raise ModelRequestError(f"Model request failed: {inner_e}")
|
|
1526
|
+
else:
|
|
1527
|
+
raise ModelRequestError(f"Model request failed: {e}")
|
|
1528
|
+
|
|
1529
|
+
prompt_tokens = 0
|
|
1530
|
+
completion_tokens = 0
|
|
1531
|
+
total_tokens = 0
|
|
1532
|
+
|
|
1533
|
+
try:
|
|
1534
|
+
for chunk in completion:
|
|
1535
|
+
if latency is None:
|
|
1536
|
+
latency = time.perf_counter() - start_time
|
|
1537
|
+
|
|
1538
|
+
usage = getattr(chunk, "usage", None)
|
|
1539
|
+
if usage is not None:
|
|
1540
|
+
if getattr(usage, "prompt_tokens", None) is not None:
|
|
1541
|
+
prompt_tokens = int(usage.prompt_tokens)
|
|
1542
|
+
if getattr(usage, "completion_tokens", None) is not None:
|
|
1543
|
+
completion_tokens = int(usage.completion_tokens)
|
|
1544
|
+
if getattr(usage, "total_tokens", None) is not None:
|
|
1545
|
+
total_tokens = int(usage.total_tokens)
|
|
1546
|
+
|
|
1547
|
+
if not chunk.choices:
|
|
1548
|
+
continue
|
|
1549
|
+
|
|
1550
|
+
delta = chunk.choices[0].delta
|
|
1551
|
+
if not delta:
|
|
1552
|
+
continue
|
|
1553
|
+
|
|
1554
|
+
tokens += 1
|
|
1555
|
+
|
|
1556
|
+
if reasoning := self._extract_reasoning(delta):
|
|
1557
|
+
thinking += reasoning
|
|
1558
|
+
if not hide_thinking:
|
|
1559
|
+
yield self._event_builder.reasoning(reasoning)
|
|
1560
|
+
|
|
1561
|
+
if content := getattr(delta, "content", None):
|
|
1562
|
+
thinking_part, answer_part = thinking_parser.parse(str(content))
|
|
1563
|
+
|
|
1564
|
+
if thinking_part:
|
|
1565
|
+
thinking += thinking_part
|
|
1566
|
+
if not hide_thinking:
|
|
1567
|
+
yield self._event_builder.reasoning(thinking_part)
|
|
1568
|
+
|
|
1569
|
+
if answer_part:
|
|
1570
|
+
answer += answer_part
|
|
1571
|
+
if not structured_output:
|
|
1572
|
+
yield self._event_builder.answer(answer_part)
|
|
1573
|
+
|
|
1574
|
+
if tool_calls := getattr(delta, "tool_calls", None):
|
|
1575
|
+
for tc in tool_calls:
|
|
1576
|
+
tool_accumulator.add_chunk(tc)
|
|
1577
|
+
except Exception as e:
|
|
1578
|
+
raise ModelRequestError(f"Model stream failed: {e}") from e
|
|
1579
|
+
|
|
1580
|
+
elapsed = time.perf_counter() - start_time
|
|
1581
|
+
if completion_tokens > 0:
|
|
1582
|
+
tokens = completion_tokens
|
|
1583
|
+
tokens_per_second = tokens / elapsed if elapsed > 0 else 0
|
|
1584
|
+
|
|
1585
|
+
if structured_output:
|
|
1586
|
+
try:
|
|
1587
|
+
answer = json.loads(answer)
|
|
1588
|
+
except json.JSONDecodeError:
|
|
1589
|
+
pass
|
|
1590
|
+
yield self._event_builder.answer(answer)
|
|
1591
|
+
|
|
1592
|
+
final_tool_calls = tool_accumulator.get_completed_calls()
|
|
1593
|
+
for idx, tc in enumerate(final_tool_calls):
|
|
1594
|
+
yield self._event_builder.tool_call(tc, source=tc["name"], job=idx + 1)
|
|
1595
|
+
|
|
1596
|
+
verbose_info: VerboseInfo = {
|
|
1597
|
+
"tokens": tokens,
|
|
1598
|
+
"tokens_per_second": tokens_per_second,
|
|
1599
|
+
"latency": latency,
|
|
1600
|
+
"prompt_tokens": prompt_tokens if prompt_tokens > 0 else None,
|
|
1601
|
+
"completion_tokens": completion_tokens if completion_tokens > 0 else None,
|
|
1602
|
+
"total_tokens": total_tokens if total_tokens > 0 else None,
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
if verbose:
|
|
1606
|
+
yield self._event_builder.verbose(verbose_info)
|
|
1607
|
+
|
|
1608
|
+
if final:
|
|
1609
|
+
final_response: FinalResponse = {
|
|
1610
|
+
"answer": answer.strip() if isinstance(answer, str) else answer
|
|
1611
|
+
}
|
|
1612
|
+
if not hide_thinking and thinking.strip():
|
|
1613
|
+
final_response["reasoning"] = thinking.strip()
|
|
1614
|
+
if final_tool_calls:
|
|
1615
|
+
final_response["tool_calls"] = final_tool_calls
|
|
1616
|
+
if verbose:
|
|
1617
|
+
final_response["verbose"] = verbose_info
|
|
1618
|
+
|
|
1619
|
+
yield self._event_builder.final(final_response)
|
|
1620
|
+
|
|
1621
|
+
yield self._event_builder.done()
|
|
1622
|
+
|
|
1623
|
+
# ========================================================================
|
|
1624
|
+
# Asynchronous Methods
|
|
1625
|
+
# ========================================================================
|
|
1626
|
+
|
|
1627
|
+
async def async_response(
|
|
1628
|
+
self,
|
|
1629
|
+
messages: Optional[InputValue] = None,
|
|
1630
|
+
output_format: Union[Dict, type, None] = None,
|
|
1631
|
+
tools: Optional[List] = None,
|
|
1632
|
+
verbose: bool = False,
|
|
1633
|
+
hide_thinking: bool = True,
|
|
1634
|
+
reasoning_effort: Optional[str] = None,
|
|
1635
|
+
max_tokens: Optional[int] = None,
|
|
1636
|
+
extra_body: Optional[Dict] = None,
|
|
1637
|
+
input: Optional[InputValue] = None,
|
|
1638
|
+
) -> FinalResponse:
|
|
1639
|
+
"""Async request for model inference."""
|
|
1640
|
+
resolved_messages = _resolve_messages(messages, input)
|
|
1641
|
+
output_format = self._prepare_output_format(output_format)
|
|
1642
|
+
|
|
1643
|
+
final_content = None
|
|
1644
|
+
async for event in self.async_stream_response(
|
|
1645
|
+
messages=resolved_messages,
|
|
1646
|
+
output_format=output_format,
|
|
1647
|
+
final=True,
|
|
1648
|
+
tools=tools,
|
|
1649
|
+
hide_thinking=hide_thinking,
|
|
1650
|
+
reasoning_effort=reasoning_effort,
|
|
1651
|
+
max_tokens=max_tokens,
|
|
1652
|
+
verbose=verbose,
|
|
1653
|
+
extra_body=extra_body,
|
|
1654
|
+
):
|
|
1655
|
+
if event.get("type") == EventType.FINAL.value:
|
|
1656
|
+
final_content = event.get("content")
|
|
1657
|
+
break
|
|
1658
|
+
|
|
1659
|
+
if final_content is None:
|
|
1660
|
+
raise RuntimeError("No final response received")
|
|
1661
|
+
|
|
1662
|
+
return final_content
|
|
1663
|
+
|
|
1664
|
+
async def async_stream_response(
|
|
1665
|
+
self,
|
|
1666
|
+
messages: Optional[InputValue] = None,
|
|
1667
|
+
output_format: Union[Dict, type, None] = None,
|
|
1668
|
+
final: bool = False,
|
|
1669
|
+
tools: Optional[List] = None,
|
|
1670
|
+
verbose: bool = False,
|
|
1671
|
+
hide_thinking: bool = True,
|
|
1672
|
+
reasoning_effort: Optional[str] = None,
|
|
1673
|
+
max_tokens: Optional[int] = None,
|
|
1674
|
+
extra_body: Optional[Dict] = None,
|
|
1675
|
+
input: Optional[InputValue] = None,
|
|
1676
|
+
) -> AsyncGenerator[StreamEvent, None]:
|
|
1677
|
+
"""Async streaming model inference."""
|
|
1678
|
+
import asyncio
|
|
1679
|
+
|
|
1680
|
+
resolved_messages = _resolve_messages(messages, input)
|
|
1681
|
+
output_format = self._prepare_output_format(output_format)
|
|
1682
|
+
if self._config.use_responses_api:
|
|
1683
|
+
async for event in self._stream_responses_async(
|
|
1684
|
+
messages=resolved_messages,
|
|
1685
|
+
output_format=output_format,
|
|
1686
|
+
tools=tools,
|
|
1687
|
+
reasoning_effort=reasoning_effort,
|
|
1688
|
+
max_tokens=max_tokens,
|
|
1689
|
+
verbose=verbose,
|
|
1690
|
+
hide_thinking=hide_thinking,
|
|
1691
|
+
final=final,
|
|
1692
|
+
extra_body=extra_body,
|
|
1693
|
+
):
|
|
1694
|
+
yield event
|
|
1695
|
+
return
|
|
1696
|
+
|
|
1697
|
+
kwargs, _, structured_output = self._build_request(
|
|
1698
|
+
resolved_messages, output_format, tools, reasoning_effort, max_tokens, extra_body
|
|
1699
|
+
)
|
|
1700
|
+
|
|
1701
|
+
thinking_parser = ThinkingParser(self._config.custom_thinking_token)
|
|
1702
|
+
tool_accumulator = ToolCallAccumulator()
|
|
1703
|
+
|
|
1704
|
+
thinking = ""
|
|
1705
|
+
answer = ""
|
|
1706
|
+
start_time = time.perf_counter()
|
|
1707
|
+
latency: Optional[float] = None
|
|
1708
|
+
tokens = 0
|
|
1709
|
+
|
|
1710
|
+
try:
|
|
1711
|
+
create_call = self._async_client.chat.completions.create(**kwargs)
|
|
1712
|
+
completion = await create_call if asyncio.iscoroutine(create_call) else create_call
|
|
1713
|
+
except Exception as e:
|
|
1714
|
+
if "stream_options" in kwargs:
|
|
1715
|
+
kwargs_copy = dict(kwargs)
|
|
1716
|
+
kwargs_copy.pop("stream_options", None)
|
|
1717
|
+
try:
|
|
1718
|
+
create_call = self._async_client.chat.completions.create(**kwargs_copy)
|
|
1719
|
+
completion = await create_call if asyncio.iscoroutine(create_call) else create_call
|
|
1720
|
+
except Exception as inner_e:
|
|
1721
|
+
raise ModelRequestError(f"Async model request failed: {inner_e}")
|
|
1722
|
+
else:
|
|
1723
|
+
raise ModelRequestError(f"Async model request failed: {e}")
|
|
1724
|
+
|
|
1725
|
+
prompt_tokens = 0
|
|
1726
|
+
completion_tokens = 0
|
|
1727
|
+
total_tokens = 0
|
|
1728
|
+
|
|
1729
|
+
try:
|
|
1730
|
+
async for chunk in completion:
|
|
1731
|
+
if latency is None:
|
|
1732
|
+
latency = time.perf_counter() - start_time
|
|
1733
|
+
|
|
1734
|
+
usage = getattr(chunk, "usage", None)
|
|
1735
|
+
if usage is not None:
|
|
1736
|
+
if getattr(usage, "prompt_tokens", None) is not None:
|
|
1737
|
+
prompt_tokens = int(usage.prompt_tokens)
|
|
1738
|
+
if getattr(usage, "completion_tokens", None) is not None:
|
|
1739
|
+
completion_tokens = int(usage.completion_tokens)
|
|
1740
|
+
if getattr(usage, "total_tokens", None) is not None:
|
|
1741
|
+
total_tokens = int(usage.total_tokens)
|
|
1742
|
+
|
|
1743
|
+
if not chunk.choices:
|
|
1744
|
+
continue
|
|
1745
|
+
|
|
1746
|
+
delta = chunk.choices[0].delta
|
|
1747
|
+
if not delta:
|
|
1748
|
+
continue
|
|
1749
|
+
|
|
1750
|
+
tokens += 1
|
|
1751
|
+
|
|
1752
|
+
if reasoning := self._extract_reasoning(delta):
|
|
1753
|
+
thinking += reasoning
|
|
1754
|
+
if not hide_thinking:
|
|
1755
|
+
yield self._event_builder.reasoning(reasoning)
|
|
1756
|
+
|
|
1757
|
+
if content := getattr(delta, "content", None):
|
|
1758
|
+
thinking_part, answer_part = thinking_parser.parse(str(content))
|
|
1759
|
+
|
|
1760
|
+
if thinking_part:
|
|
1761
|
+
thinking += thinking_part
|
|
1762
|
+
if not hide_thinking:
|
|
1763
|
+
yield self._event_builder.reasoning(thinking_part)
|
|
1764
|
+
|
|
1765
|
+
if answer_part:
|
|
1766
|
+
answer += answer_part
|
|
1767
|
+
if not structured_output:
|
|
1768
|
+
yield self._event_builder.answer(answer_part)
|
|
1769
|
+
|
|
1770
|
+
if tool_calls := getattr(delta, "tool_calls", None):
|
|
1771
|
+
for tc in tool_calls:
|
|
1772
|
+
tool_accumulator.add_chunk(tc)
|
|
1773
|
+
except Exception as e:
|
|
1774
|
+
raise ModelRequestError(f"Async model stream failed: {e}") from e
|
|
1775
|
+
|
|
1776
|
+
elapsed = time.perf_counter() - start_time
|
|
1777
|
+
if completion_tokens > 0:
|
|
1778
|
+
tokens = completion_tokens
|
|
1779
|
+
tokens_per_second = tokens / elapsed if elapsed > 0 else 0
|
|
1780
|
+
|
|
1781
|
+
if structured_output:
|
|
1782
|
+
try:
|
|
1783
|
+
answer = json.loads(answer)
|
|
1784
|
+
except json.JSONDecodeError:
|
|
1785
|
+
pass
|
|
1786
|
+
yield self._event_builder.answer(answer)
|
|
1787
|
+
|
|
1788
|
+
final_tool_calls = tool_accumulator.get_completed_calls()
|
|
1789
|
+
for idx, tc in enumerate(final_tool_calls):
|
|
1790
|
+
yield self._event_builder.tool_call(tc, source=tc["name"], job=idx + 1)
|
|
1791
|
+
|
|
1792
|
+
verbose_info: VerboseInfo = {
|
|
1793
|
+
"tokens": tokens,
|
|
1794
|
+
"tokens_per_second": tokens_per_second,
|
|
1795
|
+
"latency": latency,
|
|
1796
|
+
"prompt_tokens": prompt_tokens if prompt_tokens > 0 else None,
|
|
1797
|
+
"completion_tokens": completion_tokens if completion_tokens > 0 else None,
|
|
1798
|
+
"total_tokens": total_tokens if total_tokens > 0 else None,
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
if verbose:
|
|
1802
|
+
yield self._event_builder.verbose(verbose_info)
|
|
1803
|
+
|
|
1804
|
+
if final:
|
|
1805
|
+
final_response: FinalResponse = {
|
|
1806
|
+
"answer": answer.strip() if isinstance(answer, str) else answer
|
|
1807
|
+
}
|
|
1808
|
+
if not hide_thinking and thinking.strip():
|
|
1809
|
+
final_response["reasoning"] = thinking.strip()
|
|
1810
|
+
if final_tool_calls:
|
|
1811
|
+
final_response["tool_calls"] = final_tool_calls
|
|
1812
|
+
if verbose:
|
|
1813
|
+
final_response["verbose"] = verbose_info
|
|
1814
|
+
|
|
1815
|
+
yield self._event_builder.final(final_response)
|
|
1816
|
+
|
|
1817
|
+
yield self._event_builder.done()
|
|
1818
|
+
|
|
1819
|
+
# ========================================================================
|
|
1820
|
+
# Context Manager
|
|
1821
|
+
# ========================================================================
|
|
1822
|
+
|
|
1823
|
+
def close(self) -> None:
|
|
1824
|
+
if hasattr(self._client, "close"):
|
|
1825
|
+
self._client.close()
|
|
1826
|
+
_close_async_resource(self._async_client)
|
|
1827
|
+
|
|
1828
|
+
async def aclose(self) -> None:
|
|
1829
|
+
if hasattr(self._client, "close"):
|
|
1830
|
+
self._client.close()
|
|
1831
|
+
close_result = self._async_client.close()
|
|
1832
|
+
if inspect.isawaitable(close_result):
|
|
1833
|
+
await close_result
|
|
1834
|
+
|
|
1835
|
+
def __enter__(self):
|
|
1836
|
+
return self
|
|
1837
|
+
|
|
1838
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
1839
|
+
self.close()
|
|
1840
|
+
return False
|
|
1841
|
+
|
|
1842
|
+
async def __aenter__(self):
|
|
1843
|
+
return self
|
|
1844
|
+
|
|
1845
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
1846
|
+
await self.aclose()
|
|
1847
|
+
return False
|
|
1848
|
+
|
|
1849
|
+
# ============================================================================
|
|
1850
|
+
# Public API
|
|
1851
|
+
# ============================================================================
|
|
1852
|
+
|
|
1853
|
+
__all__ = [
|
|
1854
|
+
"LLM",
|
|
1855
|
+
"LLMConfig",
|
|
1856
|
+
"CustomThinkingToken",
|
|
1857
|
+
"StreamEvent",
|
|
1858
|
+
"ToolCall",
|
|
1859
|
+
"FinalResponse",
|
|
1860
|
+
"VerboseInfo",
|
|
1861
|
+
"EventType",
|
|
1862
|
+
"LLMError",
|
|
1863
|
+
"ConfigurationError",
|
|
1864
|
+
"SchemaConversionError",
|
|
1865
|
+
"ModelRequestError",
|
|
1866
|
+
]
|