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.
- ellements/__init__.py +57 -0
- ellements/agents/__init__.py +45 -0
- ellements/agents/backend.py +100 -0
- ellements/agents/builder.py +303 -0
- ellements/agents/claude_backend.py +200 -0
- ellements/agents/controller.py +237 -0
- ellements/agents/openai_backend.py +187 -0
- ellements/agents/py.typed +0 -0
- ellements/agents/runner.py +358 -0
- ellements/agents/tools.py +30 -0
- ellements/benchmarking/__init__.py +52 -0
- ellements/benchmarking/harness.py +342 -0
- ellements/benchmarking/py.typed +0 -0
- ellements/benchmarking/results.py +173 -0
- ellements/cli/__init__.py +80 -0
- ellements/cli/adapters.py +73 -0
- ellements/cli/agent_tui.py +1178 -0
- ellements/cli/components.py +411 -0
- ellements/cli/printer.py +229 -0
- ellements/cli/py.typed +0 -0
- ellements/core/__init__.py +112 -0
- ellements/core/async_utils.py +42 -0
- ellements/core/budgeting/__init__.py +43 -0
- ellements/core/budgeting/client.py +276 -0
- ellements/core/budgeting/protocol.py +51 -0
- ellements/core/budgeting/trackers.py +177 -0
- ellements/core/caching/__init__.py +51 -0
- ellements/core/caching/cache.py +73 -0
- ellements/core/caching/client.py +300 -0
- ellements/core/caching/disk.py +128 -0
- ellements/core/caching/keys.py +97 -0
- ellements/core/caching/memory.py +104 -0
- ellements/core/chunking.py +262 -0
- ellements/core/config.py +180 -0
- ellements/core/exceptions.py +145 -0
- ellements/core/llm/__init__.py +46 -0
- ellements/core/llm/client.py +1124 -0
- ellements/core/llm/images.py +226 -0
- ellements/core/llm/messages.py +202 -0
- ellements/core/llm/model_params.py +66 -0
- ellements/core/llm/protocol.py +146 -0
- ellements/core/llm/requests.py +100 -0
- ellements/core/llm/structured.py +91 -0
- ellements/core/llm/wrapper.py +79 -0
- ellements/core/observability/__init__.py +39 -0
- ellements/core/observability/events.py +169 -0
- ellements/core/observability/jsonl_logger.py +244 -0
- ellements/core/observability/markdown_formatter.py +197 -0
- ellements/core/observability/observer.py +56 -0
- ellements/core/prompting/__init__.py +14 -0
- ellements/core/prompting/context.py +185 -0
- ellements/core/prompting/guideline.py +133 -0
- ellements/core/prompting/persona.py +267 -0
- ellements/core/prompting/sources.py +92 -0
- ellements/core/py.typed +0 -0
- ellements/core/rate_limit/__init__.py +44 -0
- ellements/core/rate_limit/bucket.py +85 -0
- ellements/core/rate_limit/client.py +216 -0
- ellements/core/rate_limit/protocol.py +27 -0
- ellements/core/templating.py +126 -0
- ellements/core/tools/__init__.py +51 -0
- ellements/core/tools/dialects.py +136 -0
- ellements/core/tools/executor.py +48 -0
- ellements/core/tools/protocol.py +36 -0
- ellements/core/tools/records.py +28 -0
- ellements/core/tools/registry.py +205 -0
- ellements/core/tools/schemas.py +80 -0
- ellements/core/tools/simple.py +119 -0
- ellements/core/tools/spec.py +33 -0
- ellements/domain_specific/__init__.py +7 -0
- ellements/domain_specific/finance/__init__.py +188 -0
- ellements/domain_specific/finance/calculations.py +837 -0
- ellements/domain_specific/finance/charts.py +426 -0
- ellements/domain_specific/finance/portfolio.py +129 -0
- ellements/domain_specific/finance/quant_analysis.py +279 -0
- ellements/domain_specific/finance/risk.py +362 -0
- ellements/domain_specific/finance/technical_indicators.py +241 -0
- ellements/domain_specific/finance/tools.py +483 -0
- ellements/domain_specific/finance/valuation.py +312 -0
- ellements/domain_specific/finance/yahoo_finance.py +1523 -0
- ellements/domain_specific/finance/yahoo_finance_models.py +321 -0
- ellements/domain_specific/py.typed +0 -0
- ellements/execution/__init__.py +56 -0
- ellements/execution/callbacks.py +149 -0
- ellements/execution/catalog.py +70 -0
- ellements/execution/collaborative.py +191 -0
- ellements/execution/config.py +135 -0
- ellements/execution/py.typed +0 -0
- ellements/execution/reflection.py +156 -0
- ellements/execution/self_consistency.py +189 -0
- ellements/execution/single_call.py +48 -0
- ellements/execution/strategies.py +237 -0
- ellements/execution/tree_of_thought.py +541 -0
- ellements/fslm/__init__.py +108 -0
- ellements/fslm/builtins.py +103 -0
- ellements/fslm/cli.py +199 -0
- ellements/fslm/context.py +50 -0
- ellements/fslm/definition.py +163 -0
- ellements/fslm/det.py +173 -0
- ellements/fslm/dsl.py +458 -0
- ellements/fslm/errors.py +38 -0
- ellements/fslm/evaluators.py +141 -0
- ellements/fslm/kernel.py +603 -0
- ellements/fslm/loading.py +123 -0
- ellements/fslm/models.py +623 -0
- ellements/fslm/nl.py +87 -0
- ellements/fslm/observers.py +107 -0
- ellements/fslm/persistence.py +72 -0
- ellements/fslm/py.typed +0 -0
- ellements/fslm/rendering.py +551 -0
- ellements/fslm/visualization.py +25 -0
- ellements/reporting/__init__.py +12 -0
- ellements/reporting/charts.py +364 -0
- ellements/reporting/html_generation.py +132 -0
- ellements/reporting/py.typed +0 -0
- ellements/reporting/visualization.py +73 -0
- ellements/standard_tools/__init__.py +22 -0
- ellements/standard_tools/py.typed +0 -0
- ellements/standard_tools/terminal.py +205 -0
- ellements/standard_tools/web/__init__.py +37 -0
- ellements/standard_tools/web/browser_viewer.py +214 -0
- ellements/standard_tools/web/crawler.py +454 -0
- ellements/standard_tools/web/search.py +399 -0
- ellements/standard_tools/web/youtube.py +1297 -0
- ellements-0.2.0.dist-info/METADATA +368 -0
- ellements-0.2.0.dist-info/RECORD +130 -0
- ellements-0.2.0.dist-info/WHEEL +5 -0
- ellements-0.2.0.dist-info/entry_points.txt +2 -0
- ellements-0.2.0.dist-info/licenses/LICENSE +42 -0
- 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
|
+
]
|