ellements 0.2.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.
Files changed (130) hide show
  1. ellements/__init__.py +57 -0
  2. ellements/agents/__init__.py +45 -0
  3. ellements/agents/backend.py +100 -0
  4. ellements/agents/builder.py +303 -0
  5. ellements/agents/claude_backend.py +200 -0
  6. ellements/agents/controller.py +237 -0
  7. ellements/agents/openai_backend.py +187 -0
  8. ellements/agents/py.typed +0 -0
  9. ellements/agents/runner.py +358 -0
  10. ellements/agents/tools.py +30 -0
  11. ellements/benchmarking/__init__.py +52 -0
  12. ellements/benchmarking/harness.py +342 -0
  13. ellements/benchmarking/py.typed +0 -0
  14. ellements/benchmarking/results.py +173 -0
  15. ellements/cli/__init__.py +80 -0
  16. ellements/cli/adapters.py +73 -0
  17. ellements/cli/agent_tui.py +1178 -0
  18. ellements/cli/components.py +411 -0
  19. ellements/cli/printer.py +229 -0
  20. ellements/cli/py.typed +0 -0
  21. ellements/core/__init__.py +112 -0
  22. ellements/core/async_utils.py +42 -0
  23. ellements/core/budgeting/__init__.py +43 -0
  24. ellements/core/budgeting/client.py +276 -0
  25. ellements/core/budgeting/protocol.py +51 -0
  26. ellements/core/budgeting/trackers.py +177 -0
  27. ellements/core/caching/__init__.py +51 -0
  28. ellements/core/caching/cache.py +73 -0
  29. ellements/core/caching/client.py +300 -0
  30. ellements/core/caching/disk.py +128 -0
  31. ellements/core/caching/keys.py +97 -0
  32. ellements/core/caching/memory.py +104 -0
  33. ellements/core/chunking.py +262 -0
  34. ellements/core/config.py +180 -0
  35. ellements/core/exceptions.py +145 -0
  36. ellements/core/llm/__init__.py +46 -0
  37. ellements/core/llm/client.py +1124 -0
  38. ellements/core/llm/images.py +226 -0
  39. ellements/core/llm/messages.py +202 -0
  40. ellements/core/llm/model_params.py +66 -0
  41. ellements/core/llm/protocol.py +146 -0
  42. ellements/core/llm/requests.py +100 -0
  43. ellements/core/llm/structured.py +91 -0
  44. ellements/core/llm/wrapper.py +79 -0
  45. ellements/core/observability/__init__.py +39 -0
  46. ellements/core/observability/events.py +169 -0
  47. ellements/core/observability/jsonl_logger.py +244 -0
  48. ellements/core/observability/markdown_formatter.py +197 -0
  49. ellements/core/observability/observer.py +56 -0
  50. ellements/core/prompting/__init__.py +14 -0
  51. ellements/core/prompting/context.py +185 -0
  52. ellements/core/prompting/guideline.py +133 -0
  53. ellements/core/prompting/persona.py +267 -0
  54. ellements/core/prompting/sources.py +92 -0
  55. ellements/core/py.typed +0 -0
  56. ellements/core/rate_limit/__init__.py +44 -0
  57. ellements/core/rate_limit/bucket.py +85 -0
  58. ellements/core/rate_limit/client.py +216 -0
  59. ellements/core/rate_limit/protocol.py +27 -0
  60. ellements/core/templating.py +126 -0
  61. ellements/core/tools/__init__.py +51 -0
  62. ellements/core/tools/dialects.py +136 -0
  63. ellements/core/tools/executor.py +48 -0
  64. ellements/core/tools/protocol.py +36 -0
  65. ellements/core/tools/records.py +28 -0
  66. ellements/core/tools/registry.py +205 -0
  67. ellements/core/tools/schemas.py +80 -0
  68. ellements/core/tools/simple.py +119 -0
  69. ellements/core/tools/spec.py +33 -0
  70. ellements/domain_specific/__init__.py +7 -0
  71. ellements/domain_specific/finance/__init__.py +188 -0
  72. ellements/domain_specific/finance/calculations.py +837 -0
  73. ellements/domain_specific/finance/charts.py +426 -0
  74. ellements/domain_specific/finance/portfolio.py +129 -0
  75. ellements/domain_specific/finance/quant_analysis.py +279 -0
  76. ellements/domain_specific/finance/risk.py +362 -0
  77. ellements/domain_specific/finance/technical_indicators.py +241 -0
  78. ellements/domain_specific/finance/tools.py +483 -0
  79. ellements/domain_specific/finance/valuation.py +312 -0
  80. ellements/domain_specific/finance/yahoo_finance.py +1523 -0
  81. ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
  82. ellements/domain_specific/py.typed +0 -0
  83. ellements/execution/__init__.py +56 -0
  84. ellements/execution/callbacks.py +149 -0
  85. ellements/execution/catalog.py +70 -0
  86. ellements/execution/collaborative.py +191 -0
  87. ellements/execution/config.py +135 -0
  88. ellements/execution/py.typed +0 -0
  89. ellements/execution/reflection.py +156 -0
  90. ellements/execution/self_consistency.py +189 -0
  91. ellements/execution/single_call.py +48 -0
  92. ellements/execution/strategies.py +237 -0
  93. ellements/execution/tree_of_thought.py +541 -0
  94. ellements/fslm/__init__.py +108 -0
  95. ellements/fslm/builtins.py +103 -0
  96. ellements/fslm/cli.py +199 -0
  97. ellements/fslm/context.py +50 -0
  98. ellements/fslm/definition.py +163 -0
  99. ellements/fslm/det.py +173 -0
  100. ellements/fslm/dsl.py +458 -0
  101. ellements/fslm/errors.py +38 -0
  102. ellements/fslm/evaluators.py +141 -0
  103. ellements/fslm/kernel.py +603 -0
  104. ellements/fslm/loading.py +123 -0
  105. ellements/fslm/models.py +623 -0
  106. ellements/fslm/nl.py +87 -0
  107. ellements/fslm/observers.py +107 -0
  108. ellements/fslm/persistence.py +72 -0
  109. ellements/fslm/py.typed +0 -0
  110. ellements/fslm/rendering.py +551 -0
  111. ellements/fslm/visualization.py +25 -0
  112. ellements/reporting/__init__.py +12 -0
  113. ellements/reporting/charts.py +364 -0
  114. ellements/reporting/html_generation.py +132 -0
  115. ellements/reporting/py.typed +0 -0
  116. ellements/reporting/visualization.py +73 -0
  117. ellements/standard_tools/__init__.py +22 -0
  118. ellements/standard_tools/py.typed +0 -0
  119. ellements/standard_tools/terminal.py +205 -0
  120. ellements/standard_tools/web/__init__.py +37 -0
  121. ellements/standard_tools/web/browser_viewer.py +214 -0
  122. ellements/standard_tools/web/crawler.py +454 -0
  123. ellements/standard_tools/web/search.py +399 -0
  124. ellements/standard_tools/web/youtube.py +1297 -0
  125. ellements-0.2.0.dist-info/METADATA +368 -0
  126. ellements-0.2.0.dist-info/RECORD +130 -0
  127. ellements-0.2.0.dist-info/WHEEL +5 -0
  128. ellements-0.2.0.dist-info/entry_points.txt +2 -0
  129. ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
  130. ellements-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1124 @@
1
+ """Primary LLM client implementation.
2
+
3
+ The :class:`LLMClient` is the single entry point to every supported
4
+ provider (via LiteLLM). It is async-only — there is no synchronous
5
+ companion API.
6
+
7
+ Key contracts:
8
+
9
+ - ``model`` is required at construction. There is no implicit default.
10
+ - Every method (``complete``, ``complete_structured``, ``complete_with_tools``,
11
+ ``stream``, ``loglikelihood``, ``generate_image``) emits uniform
12
+ :class:`LLMRequestEvent` / :class:`LLMResponseEvent` / :class:`LLMErrorEvent`
13
+ events to all registered :class:`LLMObserver` instances.
14
+ - Transient failures are retried with exponential backoff plus full
15
+ jitter, classified by litellm exception type. Strategies must not
16
+ add their own retry layer.
17
+ - :meth:`complete_with_tools` raises :class:`MaxToolIterationsError`
18
+ carrying the partial conversation and unresolved tool calls when
19
+ the loop exceeds its iteration cap.
20
+ - :meth:`complete_structured` uses litellm's native
21
+ ``response_format=PydanticModel`` and raises
22
+ :class:`StructuredOutputUnsupportedError` for models that don't
23
+ support JSON-mode.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import copy
30
+ import json
31
+ import logging
32
+ import random
33
+ import time
34
+ from collections.abc import (
35
+ AsyncIterator,
36
+ Awaitable,
37
+ Callable,
38
+ Iterable,
39
+ Mapping,
40
+ Sequence,
41
+ )
42
+ from dataclasses import dataclass, field
43
+ from pathlib import Path
44
+ from typing import Any, TypeVar
45
+ from uuid import uuid4
46
+
47
+ import litellm
48
+ from pydantic import BaseModel
49
+
50
+ from ..exceptions import (
51
+ ConversationError,
52
+ LLMError,
53
+ LogprobsUnsupportedError,
54
+ MaxToolIterationsError,
55
+ StructuredOutputUnsupportedError,
56
+ )
57
+ from ..observability import (
58
+ JsonlPromptLogger,
59
+ LLMErrorEvent,
60
+ LLMObserver,
61
+ LLMRequestEvent,
62
+ LLMResponseEvent,
63
+ )
64
+ from ..tools import (
65
+ ToolCallRecord,
66
+ ToolCallResponse,
67
+ ToolDialect,
68
+ ToolExecutor,
69
+ ToolRegistry,
70
+ default_dialect_for_model,
71
+ )
72
+ from .images import (
73
+ ImageGenerationResponse,
74
+ build_image_edit_request,
75
+ build_image_generation_request,
76
+ parse_image_generation_response,
77
+ )
78
+ from .messages import Conversation, MessageInput, normalize_message_input
79
+ from .requests import (
80
+ configure_litellm_globals,
81
+ extract_response_text,
82
+ prepare_completion_request,
83
+ )
84
+ from .structured import (
85
+ ensure_structured_support,
86
+ parse_structured_content,
87
+ )
88
+
89
+ T = TypeVar("T", bound=BaseModel)
90
+
91
+ _logger = logging.getLogger(__name__)
92
+
93
+
94
+ def _is_raw_tool_definition(item: Any) -> bool:
95
+ """Return whether *item* already matches an OpenAI Chat function-tool dict."""
96
+ if not isinstance(item, Mapping):
97
+ return False
98
+ function = item.get("function")
99
+ return (
100
+ item.get("type") == "function"
101
+ and isinstance(function, Mapping)
102
+ and isinstance(function.get("name"), str)
103
+ )
104
+
105
+
106
+ def _coerce_to_registry_and_extras(
107
+ tools: ToolRegistry | Iterable[Any] | Mapping[str, Any],
108
+ ) -> tuple[ToolRegistry, list[dict[str, Any]]]:
109
+ """Split *tools* into a managed registry and pass-through raw definitions."""
110
+ if isinstance(tools, ToolRegistry):
111
+ return tools, []
112
+ registry = ToolRegistry()
113
+ raw_extras: list[dict[str, Any]] = []
114
+ items: Iterable[tuple[str | None, Any]]
115
+ if isinstance(tools, Mapping):
116
+ items = list(tools.items())
117
+ else:
118
+ items = [(None, item) for item in tools]
119
+ for name_hint, item in items:
120
+ if _is_raw_tool_definition(item):
121
+ raw_extras.append(copy.deepcopy(dict(item)))
122
+ continue
123
+ registry.register(item, name=name_hint)
124
+ return registry, raw_extras
125
+
126
+
127
+ def _classify_retryable(exc: BaseException) -> bool:
128
+ """Return whether *exc* is a transient litellm error worth retrying.
129
+
130
+ Classification is type-based using litellm's exception hierarchy.
131
+ No string matching, no opaque heuristics.
132
+ """
133
+ transient_types: tuple[type[BaseException], ...] = (
134
+ litellm.RateLimitError,
135
+ litellm.ServiceUnavailableError,
136
+ litellm.APIConnectionError,
137
+ litellm.Timeout,
138
+ litellm.InternalServerError,
139
+ )
140
+ return isinstance(exc, transient_types)
141
+
142
+
143
+ def _full_jitter_backoff(
144
+ attempt: int, *, base: float = 0.5, cap: float = 30.0
145
+ ) -> float:
146
+ """Return an AWS-style full-jitter backoff delay for *attempt* (0-indexed)."""
147
+ return random.uniform(0.0, min(cap, base * (2**attempt)))
148
+
149
+
150
+ @dataclass
151
+ class _ToolLoopState:
152
+ """Mutable per-iteration state for :meth:`LLMClient.complete_with_tools`.
153
+
154
+ Bundling the loop's three "growing" pieces — the running message
155
+ list, the per-call audit log, and the most recent assistant
156
+ message — keeps the loop body small and makes it obvious which
157
+ pieces escape into the final response or error.
158
+ """
159
+
160
+ llm_messages: list[dict[str, Any]]
161
+ tool_log: list[ToolCallRecord] = field(default_factory=list)
162
+ assistant_msg: Any = None
163
+
164
+ async def run_tool_calls(
165
+ self,
166
+ tool_calls: Iterable[Any],
167
+ executor: ToolExecutor,
168
+ ) -> None:
169
+ """Invoke every requested tool, recording results in-place.
170
+
171
+ Appends one ``role=tool`` message per call so the next LLM
172
+ turn sees the resolved outputs in order.
173
+ """
174
+ for tool_call in tool_calls:
175
+ fn_name = tool_call.function.name
176
+ fn_args = json.loads(tool_call.function.arguments)
177
+ _logger.debug("Tool call: %s(%s)", fn_name, fn_args)
178
+ result_str = await executor(fn_name, fn_args)
179
+ self.tool_log.append(
180
+ ToolCallRecord(
181
+ name=fn_name,
182
+ arguments=fn_args,
183
+ result=result_str,
184
+ )
185
+ )
186
+ self.llm_messages.append(
187
+ {
188
+ "role": "tool",
189
+ "tool_call_id": tool_call.id,
190
+ "content": result_str,
191
+ }
192
+ )
193
+
194
+
195
+ class LLMClient:
196
+ """Generic LLM client supporting multiple providers via LiteLLM.
197
+
198
+ Args:
199
+ model: Required default model identifier (litellm format, e.g.
200
+ ``"openai/gpt-4o"`` or ``"anthropic/claude-3-5-sonnet-20241022"``).
201
+ use_responses_api: Use OpenAI's Responses API (only relevant for
202
+ ``openai/gpt-5*``).
203
+ observers: Sequence of :class:`LLMObserver` implementations to
204
+ notify on every request, response, and error.
205
+ log_dir: Convenience shorthand — when provided, a
206
+ :class:`JsonlPromptLogger` is created at this path and added
207
+ to the observer chain.
208
+ max_retries: Maximum number of retries on transient errors. The
209
+ initial attempt is *not* counted. Default 3.
210
+ retry_base_delay: Base delay (seconds) for exponential backoff
211
+ with full jitter. Default 0.5.
212
+ retry_max_delay: Cap on individual retry delays. Default 30.0.
213
+ **kwargs: Provider configuration forwarded to LiteLLM
214
+ (e.g. ``api_key``, ``base_url``).
215
+ """
216
+
217
+ def __init__(
218
+ self,
219
+ *,
220
+ model: str,
221
+ use_responses_api: bool = False,
222
+ observers: Iterable[LLMObserver] | None = None,
223
+ log_dir: str | Path | None = None,
224
+ max_retries: int = 3,
225
+ retry_base_delay: float = 0.5,
226
+ retry_max_delay: float = 30.0,
227
+ **kwargs: Any,
228
+ ) -> None:
229
+ if not model:
230
+ raise ValueError("LLMClient(model=...) is required and must be non-empty.")
231
+
232
+ self.model = model
233
+ self.use_responses_api = use_responses_api
234
+ self.max_retries = max_retries
235
+ self.retry_base_delay = retry_base_delay
236
+ self.retry_max_delay = retry_max_delay
237
+ self.config = kwargs
238
+
239
+ observers_list: list[LLMObserver] = list(observers or [])
240
+ if log_dir is not None:
241
+ observers_list.append(JsonlPromptLogger(log_dir))
242
+ self._observers: list[LLMObserver] = observers_list
243
+
244
+ configure_litellm_globals(self.config)
245
+
246
+ # ── Observers ────────────────────────────────────────────────────
247
+
248
+ @property
249
+ def observers(self) -> list[LLMObserver]:
250
+ """Mutable list of registered observers."""
251
+ return self._observers
252
+
253
+ def add_observer(self, observer: LLMObserver) -> None:
254
+ """Register a new observer to receive future LLM events."""
255
+ self._observers.append(observer)
256
+
257
+ async def _emit_request(self, event: LLMRequestEvent) -> None:
258
+ for observer in self._observers:
259
+ try:
260
+ await observer.on_request(event)
261
+ except Exception as exc:
262
+ _logger.warning("Observer on_request failed: %s", exc)
263
+
264
+ async def _emit_response(self, event: LLMResponseEvent) -> None:
265
+ for observer in self._observers:
266
+ try:
267
+ await observer.on_response(event)
268
+ except Exception as exc:
269
+ _logger.warning("Observer on_response failed: %s", exc)
270
+
271
+ async def _emit_error(self, event: LLMErrorEvent) -> None:
272
+ for observer in self._observers:
273
+ try:
274
+ await observer.on_error(event)
275
+ except Exception as exc:
276
+ _logger.warning("Observer on_error failed: %s", exc)
277
+
278
+ # ── Retry wrapper ────────────────────────────────────────────────
279
+
280
+ async def _call_with_retry(
281
+ self, fn: Callable[[], Awaitable[Any]], *, what: str
282
+ ) -> Any:
283
+ """Run *fn* with retry+backoff on transient litellm errors."""
284
+ last_exc: BaseException | None = None
285
+ for attempt in range(self.max_retries + 1):
286
+ try:
287
+ return await fn()
288
+ except Exception as exc:
289
+ last_exc = exc
290
+ if attempt >= self.max_retries or not _classify_retryable(exc):
291
+ raise
292
+ delay = _full_jitter_backoff(
293
+ attempt,
294
+ base=self.retry_base_delay,
295
+ cap=self.retry_max_delay,
296
+ )
297
+ _logger.warning(
298
+ "%s failed (attempt %d/%d, %s); retrying in %.2fs",
299
+ what,
300
+ attempt + 1,
301
+ self.max_retries + 1,
302
+ type(exc).__name__,
303
+ delay,
304
+ )
305
+ await asyncio.sleep(delay)
306
+ assert last_exc is not None
307
+ raise last_exc
308
+
309
+ # ── Shared call wrapper ──────────────────────────────────────────
310
+
311
+ async def _invoke_litellm(
312
+ self,
313
+ *,
314
+ call_id: str,
315
+ method: str,
316
+ model: str,
317
+ messages: list[dict[str, Any]],
318
+ request_params: dict[str, Any],
319
+ tools: list[dict[str, Any]] | None = None,
320
+ start: float,
321
+ fail_label: str,
322
+ extra_passthrough: tuple[type[BaseException], ...] = (),
323
+ ) -> Any:
324
+ """Call ``litellm.acompletion`` with uniform retry + error handling.
325
+
326
+ On success, returns the raw litellm response. On error, emits
327
+ :class:`LLMErrorEvent` and either re-raises (for
328
+ :class:`LLMError` or any type in ``extra_passthrough``) or
329
+ wraps the exception in :class:`LLMError` with a ``fail_label``
330
+ prefix. Used by ``complete``, ``complete_structured``,
331
+ ``stream``, ``complete_with_tools``, and ``loglikelihood`` so
332
+ every method emits identical telemetry on failure.
333
+ """
334
+ try:
335
+ if tools is None:
336
+ return await self._call_with_retry(
337
+ lambda: litellm.acompletion(
338
+ model=model, messages=messages, **request_params
339
+ ),
340
+ what=method,
341
+ )
342
+ return await self._call_with_retry(
343
+ lambda: litellm.acompletion(
344
+ model=model, messages=messages, tools=tools, **request_params
345
+ ),
346
+ what=method,
347
+ )
348
+ except Exception as exc:
349
+ await self._emit_error(
350
+ LLMErrorEvent(
351
+ call_id=call_id,
352
+ method=method,
353
+ model=model,
354
+ error=exc,
355
+ duration_ms=int((time.monotonic() - start) * 1000),
356
+ )
357
+ )
358
+ if isinstance(exc, (LLMError, *extra_passthrough)):
359
+ raise
360
+ raise LLMError(f"{fail_label} failed: {exc}") from exc
361
+
362
+ # ── Conversation helpers ─────────────────────────────────────────
363
+
364
+ def create_conversation(
365
+ self,
366
+ model: str | None = None,
367
+ system_prompt: str | None = None,
368
+ ) -> Conversation:
369
+ """Create a new :class:`Conversation` bound to this client's model."""
370
+ return Conversation(model=model or self.model, system_prompt=system_prompt)
371
+
372
+ # ── complete ─────────────────────────────────────────────────────
373
+
374
+ async def complete(
375
+ self,
376
+ messages: MessageInput,
377
+ *,
378
+ model: str | None = None,
379
+ temperature: float = 0.7,
380
+ max_tokens: int | None = None,
381
+ **kwargs: Any,
382
+ ) -> str:
383
+ """Generate a single completion for *messages*."""
384
+ call_id = str(uuid4())
385
+ start = time.monotonic()
386
+ llm_messages = normalize_message_input(messages)
387
+ request = prepare_completion_request(
388
+ model=model or self.model,
389
+ temperature=temperature,
390
+ max_tokens=max_tokens,
391
+ use_responses_api=self.use_responses_api,
392
+ extra_params=kwargs,
393
+ )
394
+ await self._emit_request(
395
+ LLMRequestEvent(
396
+ call_id=call_id,
397
+ method="complete",
398
+ model=request.model,
399
+ messages=llm_messages,
400
+ temperature=temperature,
401
+ max_tokens=request.logged_max_tokens,
402
+ extra_params=dict(kwargs),
403
+ )
404
+ )
405
+
406
+ response = await self._invoke_litellm(
407
+ call_id=call_id,
408
+ method="complete",
409
+ model=request.model,
410
+ messages=llm_messages,
411
+ request_params=request.params,
412
+ start=start,
413
+ fail_label="Completion",
414
+ )
415
+ content = extract_response_text(response)
416
+ await self._emit_response(
417
+ LLMResponseEvent(
418
+ call_id=call_id,
419
+ method="complete",
420
+ model=request.model,
421
+ response=content,
422
+ duration_ms=int((time.monotonic() - start) * 1000),
423
+ usage=_usage_dict(response),
424
+ )
425
+ )
426
+ return content
427
+
428
+ # ── complete_structured ─────────────────────────────────────────
429
+
430
+ async def complete_structured(
431
+ self,
432
+ messages: MessageInput,
433
+ response_model: type[T],
434
+ *,
435
+ model: str | None = None,
436
+ temperature: float = 0.7,
437
+ max_tokens: int | None = None,
438
+ **kwargs: Any,
439
+ ) -> T:
440
+ """Generate a structured Pydantic completion via litellm's native JSON mode.
441
+
442
+ Raises:
443
+ StructuredOutputUnsupportedError: If the model does not
444
+ support native structured outputs.
445
+ LLMError: If the model returns content that cannot be parsed
446
+ into *response_model*.
447
+ """
448
+ call_id = str(uuid4())
449
+ start = time.monotonic()
450
+ target_model = model or self.model
451
+ ensure_structured_support(target_model)
452
+
453
+ llm_messages = normalize_message_input(messages)
454
+ request = prepare_completion_request(
455
+ model=target_model,
456
+ temperature=temperature,
457
+ max_tokens=max_tokens,
458
+ use_responses_api=self.use_responses_api,
459
+ extra_params=kwargs,
460
+ )
461
+ request_params = dict(request.params)
462
+ request_params["response_format"] = response_model
463
+
464
+ await self._emit_request(
465
+ LLMRequestEvent(
466
+ call_id=call_id,
467
+ method="complete_structured",
468
+ model=request.model,
469
+ messages=llm_messages,
470
+ temperature=temperature,
471
+ max_tokens=request.logged_max_tokens,
472
+ extra_params={**kwargs, "response_format": response_model.__name__},
473
+ )
474
+ )
475
+
476
+ response = await self._invoke_litellm(
477
+ call_id=call_id,
478
+ method="complete_structured",
479
+ model=request.model,
480
+ messages=llm_messages,
481
+ request_params=request_params,
482
+ start=start,
483
+ fail_label="Structured completion",
484
+ extra_passthrough=(StructuredOutputUnsupportedError,),
485
+ )
486
+ content = extract_response_text(response)
487
+ parsed = parse_structured_content(
488
+ content,
489
+ response_model,
490
+ error_context="Failed to parse structured response",
491
+ )
492
+ await self._emit_response(
493
+ LLMResponseEvent(
494
+ call_id=call_id,
495
+ method="complete_structured",
496
+ model=request.model,
497
+ response=content,
498
+ duration_ms=int((time.monotonic() - start) * 1000),
499
+ usage=_usage_dict(response),
500
+ )
501
+ )
502
+ return parsed
503
+
504
+ # ── stream ───────────────────────────────────────────────────────
505
+
506
+ async def stream(
507
+ self,
508
+ messages: MessageInput,
509
+ *,
510
+ model: str | None = None,
511
+ temperature: float = 0.7,
512
+ max_tokens: int | None = None,
513
+ **kwargs: Any,
514
+ ) -> AsyncIterator[str]:
515
+ """Stream completion tokens as they are produced."""
516
+ call_id = str(uuid4())
517
+ start = time.monotonic()
518
+ llm_messages = normalize_message_input(messages)
519
+ request = prepare_completion_request(
520
+ model=model or self.model,
521
+ temperature=temperature,
522
+ max_tokens=max_tokens,
523
+ use_responses_api=self.use_responses_api,
524
+ extra_params={"stream": True, **kwargs},
525
+ )
526
+ await self._emit_request(
527
+ LLMRequestEvent(
528
+ call_id=call_id,
529
+ method="stream",
530
+ model=request.model,
531
+ messages=llm_messages,
532
+ temperature=temperature,
533
+ max_tokens=request.logged_max_tokens,
534
+ extra_params=dict(kwargs),
535
+ )
536
+ )
537
+
538
+ collected: list[str] = []
539
+ try:
540
+ response = await self._call_with_retry(
541
+ lambda: litellm.acompletion(
542
+ model=request.model,
543
+ messages=llm_messages,
544
+ **request.params,
545
+ ),
546
+ what="stream",
547
+ )
548
+ async for chunk in response:
549
+ content = chunk.choices[0].delta.content
550
+ if content:
551
+ collected.append(content)
552
+ yield content
553
+ except Exception as exc:
554
+ await self._emit_error(
555
+ LLMErrorEvent(
556
+ call_id=call_id,
557
+ method="stream",
558
+ model=request.model,
559
+ error=exc,
560
+ duration_ms=int((time.monotonic() - start) * 1000),
561
+ )
562
+ )
563
+ if isinstance(exc, LLMError):
564
+ raise
565
+ raise LLMError(f"Streaming failed: {exc}") from exc
566
+ else:
567
+ duration_ms = int((time.monotonic() - start) * 1000)
568
+ await self._emit_response(
569
+ LLMResponseEvent(
570
+ call_id=call_id,
571
+ method="stream",
572
+ model=request.model,
573
+ response="".join(collected),
574
+ duration_ms=duration_ms,
575
+ )
576
+ )
577
+
578
+ # ── continue_conversation ────────────────────────────────────────
579
+
580
+ async def continue_conversation(
581
+ self,
582
+ conversation: Conversation,
583
+ user_message: str,
584
+ *,
585
+ temperature: float = 0.7,
586
+ max_tokens: int | None = None,
587
+ **kwargs: Any,
588
+ ) -> str:
589
+ """Append *user_message* and an assistant response to *conversation*."""
590
+ if not isinstance(conversation, Conversation):
591
+ raise ConversationError("Expected Conversation object")
592
+
593
+ conversation.user(user_message)
594
+ response = await self.complete(
595
+ conversation,
596
+ model=conversation.model,
597
+ temperature=temperature,
598
+ max_tokens=max_tokens,
599
+ **kwargs,
600
+ )
601
+ conversation.assistant(response)
602
+ return response
603
+
604
+ # ── complete_with_tools ──────────────────────────────────────────
605
+
606
+ async def complete_with_tools(
607
+ self,
608
+ messages: MessageInput,
609
+ tools: ToolRegistry | Mapping[str, Any] | Iterable[Any],
610
+ *,
611
+ tool_executor: ToolExecutor | None = None,
612
+ dialect: ToolDialect | None = None,
613
+ model: str | None = None,
614
+ temperature: float = 0.7,
615
+ max_tokens: int | None = None,
616
+ max_iterations: int = 10,
617
+ **kwargs: Any,
618
+ ) -> ToolCallResponse:
619
+ """Run a multi-turn completion loop with tool/function calling.
620
+
621
+ Args:
622
+ messages: Conversation input (string, list of messages, or
623
+ :class:`Conversation`).
624
+ tools: A :class:`ToolRegistry`, an iterable of tools, or a
625
+ ``{name: tool}`` mapping. Items may be :class:`Tool` /
626
+ :class:`ToolSpec` / :class:`SimpleTool` / plain
627
+ callables, or raw provider-format dicts (in which case
628
+ *tool_executor* is required).
629
+ tool_executor: Optional override executor. Required if any
630
+ raw provider-format dicts are present in *tools*.
631
+ dialect: Optional :class:`ToolDialect` override. Defaults to
632
+ :func:`default_dialect_for_model` keyed off the model.
633
+ model: Optional model override.
634
+ temperature: Sampling temperature.
635
+ max_tokens: Maximum tokens to generate per turn.
636
+ max_iterations: Cap on tool-calling turns.
637
+
638
+ Raises:
639
+ MaxToolIterationsError: If the loop exceeds *max_iterations*
640
+ without the model returning a final (tool-call-free)
641
+ response. The error carries the partial conversation
642
+ and unresolved tool calls.
643
+ """
644
+ call_id = str(uuid4())
645
+ start = time.monotonic()
646
+ llm_messages = normalize_message_input(messages)
647
+ input_messages = [dict(message) for message in llm_messages]
648
+
649
+ effective_executor, tool_definitions = self._prepare_tool_loop(
650
+ tools=tools,
651
+ tool_executor=tool_executor,
652
+ dialect=dialect,
653
+ model_override=model,
654
+ )
655
+
656
+ request = prepare_completion_request(
657
+ model=model or self.model,
658
+ temperature=temperature,
659
+ max_tokens=max_tokens,
660
+ use_responses_api=self.use_responses_api,
661
+ extra_params=kwargs,
662
+ )
663
+
664
+ await self._emit_request(
665
+ LLMRequestEvent(
666
+ call_id=call_id,
667
+ method="complete_with_tools",
668
+ model=request.model,
669
+ messages=input_messages,
670
+ temperature=temperature,
671
+ max_tokens=request.logged_max_tokens,
672
+ tools=tool_definitions,
673
+ extra_params=dict(kwargs),
674
+ )
675
+ )
676
+
677
+ state = _ToolLoopState(llm_messages=llm_messages)
678
+ aggregate_usage: dict[str, Any] = {}
679
+ for _ in range(max_iterations):
680
+ response = await self._invoke_litellm(
681
+ call_id=call_id,
682
+ method="complete_with_tools",
683
+ model=request.model,
684
+ messages=state.llm_messages,
685
+ request_params=request.params,
686
+ tools=tool_definitions,
687
+ start=start,
688
+ fail_label="Tool-calling completion",
689
+ )
690
+ aggregate_usage = _merge_usage(
691
+ aggregate_usage,
692
+ _usage_dict(response),
693
+ )
694
+ state.assistant_msg = response.choices[0].message
695
+ tool_calls = getattr(state.assistant_msg, "tool_calls", None)
696
+
697
+ if not tool_calls:
698
+ result = ToolCallResponse(
699
+ content=state.assistant_msg.content or "",
700
+ tool_calls=list(state.tool_log),
701
+ )
702
+ await self._emit_response(
703
+ LLMResponseEvent(
704
+ call_id=call_id,
705
+ method="complete_with_tools",
706
+ model=request.model,
707
+ response=result.content,
708
+ duration_ms=int((time.monotonic() - start) * 1000),
709
+ tool_calls=[r.model_dump() for r in state.tool_log],
710
+ usage=aggregate_usage or None,
711
+ )
712
+ )
713
+ return result
714
+
715
+ state.llm_messages.append(
716
+ _assistant_tool_call_message(state.assistant_msg, tool_calls)
717
+ )
718
+ await state.run_tool_calls(tool_calls, effective_executor)
719
+
720
+ # Loop ended without a tool-call-free response.
721
+ unresolved = _serialize_tool_calls(
722
+ getattr(state.assistant_msg, "tool_calls", None)
723
+ if state.assistant_msg
724
+ else None
725
+ )
726
+ error = MaxToolIterationsError(
727
+ f"complete_with_tools hit max_iterations={max_iterations} "
728
+ "without converging on a final response.",
729
+ max_iterations=max_iterations,
730
+ messages=list(state.llm_messages),
731
+ tool_calls_made=list(state.tool_log),
732
+ unresolved_tool_calls=unresolved,
733
+ )
734
+ await self._emit_error(
735
+ LLMErrorEvent(
736
+ call_id=call_id,
737
+ method="complete_with_tools",
738
+ model=request.model,
739
+ error=error,
740
+ duration_ms=int((time.monotonic() - start) * 1000),
741
+ metadata={"unresolved_tool_calls": len(unresolved)},
742
+ )
743
+ )
744
+ raise error
745
+
746
+ def _prepare_tool_loop(
747
+ self,
748
+ *,
749
+ tools: ToolRegistry | Mapping[str, Any] | Iterable[Any],
750
+ tool_executor: ToolExecutor | None,
751
+ dialect: ToolDialect | None,
752
+ model_override: str | None,
753
+ ) -> tuple[ToolExecutor, list[dict[str, Any]]]:
754
+ """Resolve the executor + dialect + serialized tool definitions.
755
+
756
+ Extracted from :meth:`complete_with_tools` so the loop body
757
+ reads as a state machine instead of mixing setup with control
758
+ flow.
759
+ """
760
+ registry, raw_extras = _coerce_to_registry_and_extras(tools)
761
+ effective_executor = tool_executor
762
+ if effective_executor is None:
763
+ if len(registry) == 0:
764
+ raise ValueError(
765
+ "tool_executor is required when tools do not include "
766
+ "executable Tool/callable entries."
767
+ )
768
+ effective_executor = registry.executor()
769
+
770
+ effective_dialect = dialect or default_dialect_for_model(
771
+ model_override or self.model,
772
+ use_responses_api=self.use_responses_api,
773
+ )
774
+ return effective_executor, registry.to_dialect(effective_dialect) + raw_extras
775
+
776
+ # ── loglikelihood ────────────────────────────────────────────────
777
+
778
+ async def loglikelihood(
779
+ self,
780
+ context: str,
781
+ continuation: str,
782
+ *,
783
+ model: str | None = None,
784
+ max_tokens: int | None = None,
785
+ **kwargs: Any,
786
+ ) -> tuple[float, bool]:
787
+ """Compute the log-likelihood of *continuation* given *context*.
788
+
789
+ Raises:
790
+ LogprobsUnsupportedError: If the provider does not expose
791
+ token log-probabilities or returns malformed data.
792
+ """
793
+ call_id = str(uuid4())
794
+ start = time.monotonic()
795
+ target_max_tokens = max_tokens
796
+ if target_max_tokens is None:
797
+ target_max_tokens = max(1, len(continuation.split()) * 3)
798
+
799
+ llm_messages = [{"role": "user", "content": context}]
800
+ request = prepare_completion_request(
801
+ model=model or self.model,
802
+ temperature=0.0,
803
+ max_tokens=target_max_tokens,
804
+ use_responses_api=self.use_responses_api,
805
+ extra_params={"logprobs": True, **kwargs},
806
+ )
807
+ await self._emit_request(
808
+ LLMRequestEvent(
809
+ call_id=call_id,
810
+ method="loglikelihood",
811
+ model=request.model,
812
+ messages=llm_messages,
813
+ temperature=0.0,
814
+ max_tokens=request.logged_max_tokens,
815
+ extra_params={"logprobs": True, **kwargs},
816
+ )
817
+ )
818
+
819
+ try:
820
+ response = await self._call_with_retry(
821
+ lambda: litellm.acompletion(
822
+ model=request.model,
823
+ messages=llm_messages,
824
+ **request.params,
825
+ ),
826
+ what="loglikelihood",
827
+ )
828
+ choice = response.choices[0]
829
+ generated = extract_response_text(response)
830
+ raw_logprobs = getattr(choice, "logprobs", None)
831
+
832
+ token_logprobs: Any
833
+ if isinstance(raw_logprobs, dict):
834
+ token_logprobs = raw_logprobs.get("token_logprobs")
835
+ else:
836
+ token_logprobs = getattr(raw_logprobs, "token_logprobs", None)
837
+
838
+ if not isinstance(token_logprobs, list):
839
+ raise LogprobsUnsupportedError(
840
+ f"Model {request.model!r} did not return token log-probabilities."
841
+ )
842
+
843
+ total_ll = float(
844
+ sum(
845
+ value for value in token_logprobs if isinstance(value, (int, float))
846
+ )
847
+ )
848
+ is_greedy = generated.strip().startswith(continuation.strip())
849
+ await self._emit_response(
850
+ LLMResponseEvent(
851
+ call_id=call_id,
852
+ method="loglikelihood",
853
+ model=request.model,
854
+ response=generated,
855
+ duration_ms=int((time.monotonic() - start) * 1000),
856
+ metadata={"total_logprob": total_ll, "is_greedy": is_greedy},
857
+ )
858
+ )
859
+ return total_ll, is_greedy
860
+ except Exception as exc:
861
+ await self._emit_error(
862
+ LLMErrorEvent(
863
+ call_id=call_id,
864
+ method="loglikelihood",
865
+ model=request.model,
866
+ error=exc,
867
+ duration_ms=int((time.monotonic() - start) * 1000),
868
+ )
869
+ )
870
+ if isinstance(exc, (LLMError, LogprobsUnsupportedError)):
871
+ raise
872
+ raise LLMError(f"Loglikelihood failed: {exc}") from exc
873
+
874
+ # ── generate_image ───────────────────────────────────────────────
875
+
876
+ async def generate_image(
877
+ self,
878
+ prompt: str,
879
+ *,
880
+ model: str | None = None,
881
+ n: int = 1,
882
+ size: str | None = None,
883
+ quality: str | None = None,
884
+ style: str | None = None,
885
+ response_format: str = "url",
886
+ **kwargs: Any,
887
+ ) -> ImageGenerationResponse:
888
+ """Generate images from a text prompt via LiteLLM image APIs."""
889
+ call_id = str(uuid4())
890
+ start = time.monotonic()
891
+ target_model, params = build_image_generation_request(
892
+ prompt=prompt,
893
+ model=model,
894
+ n=n,
895
+ size=size,
896
+ quality=quality,
897
+ style=style,
898
+ response_format=response_format, # type: ignore[arg-type]
899
+ extra_params=dict(kwargs),
900
+ )
901
+ await self._emit_request(
902
+ LLMRequestEvent(
903
+ call_id=call_id,
904
+ method="generate_image",
905
+ model=target_model,
906
+ messages=[{"role": "user", "content": prompt}],
907
+ temperature=None,
908
+ max_tokens=None,
909
+ extra_params={k: v for k, v in params.items() if k != "prompt"},
910
+ )
911
+ )
912
+
913
+ try:
914
+ response = await self._call_with_retry(
915
+ lambda: litellm.aimage_generation(**params),
916
+ what="generate_image",
917
+ )
918
+ parsed = parse_image_generation_response(
919
+ response, target_model=target_model
920
+ )
921
+ await self._emit_response(
922
+ LLMResponseEvent(
923
+ call_id=call_id,
924
+ method="generate_image",
925
+ model=target_model,
926
+ response=json.dumps(parsed.model_dump(), default=str),
927
+ duration_ms=int((time.monotonic() - start) * 1000),
928
+ )
929
+ )
930
+ return parsed
931
+ except Exception as exc:
932
+ await self._emit_error(
933
+ LLMErrorEvent(
934
+ call_id=call_id,
935
+ method="generate_image",
936
+ model=target_model,
937
+ error=exc,
938
+ duration_ms=int((time.monotonic() - start) * 1000),
939
+ )
940
+ )
941
+ if isinstance(exc, LLMError):
942
+ raise
943
+ raise LLMError(f"Image generation failed: {exc}") from exc
944
+
945
+ # ── edit_image ────────────────────────────────────────────────────
946
+
947
+ async def edit_image(
948
+ self,
949
+ prompt: str,
950
+ images: Sequence[tuple[str, bytes] | bytes],
951
+ *,
952
+ model: str | None = None,
953
+ n: int = 1,
954
+ size: str | None = None,
955
+ quality: str | None = None,
956
+ **kwargs: Any,
957
+ ) -> ImageGenerationResponse:
958
+ """Edit/compose images from reference *images* and a text *prompt*.
959
+
960
+ Each entry in *images* is either raw ``bytes`` or a ``(filename, bytes)``
961
+ tuple. The references are uploaded to the provider's image-edit endpoint
962
+ (e.g. ``gpt-image-1``) so the generated image is conditioned on them.
963
+ """
964
+ import io
965
+
966
+ call_id = str(uuid4())
967
+ start = time.monotonic()
968
+ target_model, params = build_image_edit_request(
969
+ prompt=prompt,
970
+ model=model,
971
+ n=n,
972
+ size=size,
973
+ quality=quality,
974
+ extra_params=dict(kwargs),
975
+ )
976
+
977
+ references: list[tuple[str, bytes]] = []
978
+ for index, item in enumerate(images):
979
+ if isinstance(item, tuple):
980
+ name, payload = item
981
+ else:
982
+ name, payload = f"reference_{index}.png", item
983
+ references.append((name, payload))
984
+
985
+ await self._emit_request(
986
+ LLMRequestEvent(
987
+ call_id=call_id,
988
+ method="edit_image",
989
+ model=target_model,
990
+ messages=[{"role": "user", "content": prompt}],
991
+ temperature=None,
992
+ max_tokens=None,
993
+ extra_params={
994
+ **{k: v for k, v in params.items() if k != "prompt"},
995
+ "reference_images": len(references),
996
+ },
997
+ )
998
+ )
999
+
1000
+ async def _edit_attempt() -> Any:
1001
+ files: list[io.BytesIO] = []
1002
+ for name, payload in references:
1003
+ handle = io.BytesIO(payload)
1004
+ handle.name = name
1005
+ files.append(handle)
1006
+ try:
1007
+ return await litellm.aimage_edit(image=files, **params)
1008
+ finally:
1009
+ for handle in files:
1010
+ handle.close()
1011
+
1012
+ try:
1013
+ response = await self._call_with_retry(
1014
+ _edit_attempt,
1015
+ what="edit_image",
1016
+ )
1017
+ parsed = parse_image_generation_response(
1018
+ response, target_model=target_model
1019
+ )
1020
+ await self._emit_response(
1021
+ LLMResponseEvent(
1022
+ call_id=call_id,
1023
+ method="edit_image",
1024
+ model=target_model,
1025
+ response=json.dumps(parsed.model_dump(), default=str),
1026
+ duration_ms=int((time.monotonic() - start) * 1000),
1027
+ )
1028
+ )
1029
+ return parsed
1030
+ except Exception as exc:
1031
+ await self._emit_error(
1032
+ LLMErrorEvent(
1033
+ call_id=call_id,
1034
+ method="edit_image",
1035
+ model=target_model,
1036
+ error=exc,
1037
+ duration_ms=int((time.monotonic() - start) * 1000),
1038
+ )
1039
+ )
1040
+ if isinstance(exc, LLMError):
1041
+ raise
1042
+ raise LLMError(f"Image edit failed: {exc}") from exc
1043
+
1044
+
1045
+ # ---------------------------------------------------------------------------
1046
+ # Local helpers
1047
+ # ---------------------------------------------------------------------------
1048
+
1049
+
1050
+ def _usage_dict(response: Any) -> dict[str, Any] | None:
1051
+ usage = getattr(response, "usage", None)
1052
+ if usage is None:
1053
+ data: dict[str, Any] = {}
1054
+ elif hasattr(usage, "model_dump"):
1055
+ data = usage.model_dump()
1056
+ elif hasattr(usage, "dict"):
1057
+ data = usage.dict()
1058
+ elif isinstance(usage, dict):
1059
+ data = dict(usage)
1060
+ else:
1061
+ data = {}
1062
+
1063
+ hidden = getattr(response, "_hidden_params", None)
1064
+ if isinstance(hidden, Mapping):
1065
+ response_cost = hidden.get("response_cost")
1066
+ if isinstance(response_cost, int | float):
1067
+ data["response_cost"] = float(response_cost)
1068
+ return data or None
1069
+
1070
+
1071
+ def _merge_usage(
1072
+ aggregate: Mapping[str, Any],
1073
+ current: Mapping[str, Any] | None,
1074
+ ) -> dict[str, Any]:
1075
+ """Add provider usage from one turn into a multi-turn total."""
1076
+
1077
+ merged = dict(aggregate)
1078
+ if current is None:
1079
+ return merged
1080
+ for key, value in current.items():
1081
+ previous = merged.get(key)
1082
+ if isinstance(previous, Mapping) and isinstance(value, Mapping):
1083
+ merged[key] = _merge_usage(previous, value)
1084
+ elif (
1085
+ isinstance(previous, int | float)
1086
+ and not isinstance(previous, bool)
1087
+ and isinstance(value, int | float)
1088
+ and not isinstance(value, bool)
1089
+ ):
1090
+ merged[key] = previous + value
1091
+ else:
1092
+ merged[key] = value
1093
+ return merged
1094
+
1095
+
1096
+ def _serialize_tool_calls(tool_calls: Any) -> list[dict[str, Any]]:
1097
+ if not tool_calls:
1098
+ return []
1099
+ serialized: list[dict[str, Any]] = []
1100
+ for tool_call in tool_calls:
1101
+ serialized.append(
1102
+ {
1103
+ "id": getattr(tool_call, "id", None),
1104
+ "type": "function",
1105
+ "function": {
1106
+ "name": tool_call.function.name,
1107
+ "arguments": tool_call.function.arguments,
1108
+ },
1109
+ }
1110
+ )
1111
+ return serialized
1112
+
1113
+
1114
+ def _assistant_tool_call_message(assistant_msg: Any, tool_calls: Any) -> dict[str, Any]:
1115
+ return {
1116
+ "role": "assistant",
1117
+ "content": assistant_msg.content or "",
1118
+ "tool_calls": _serialize_tool_calls(tool_calls),
1119
+ }
1120
+
1121
+
1122
+ __all__ = [
1123
+ "LLMClient",
1124
+ ]