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
+ ]