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,226 @@
|
|
|
1
|
+
"""Image input + image-generation response models and conversion helpers.
|
|
2
|
+
|
|
3
|
+
This module owns the multimodal *input* type :class:`ImageInput` (URLs,
|
|
4
|
+
data URIs, local files) and the *output* types
|
|
5
|
+
:class:`GeneratedImage` / :class:`ImageGenerationResponse` for image-generation
|
|
6
|
+
APIs. There is no separate vision client — callers compose ``ImageInput``
|
|
7
|
+
content parts directly with :meth:`LLMClient.complete` for analysis tasks.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, Field, model_validator
|
|
17
|
+
|
|
18
|
+
from .messages import ImageDetail, MessageContent
|
|
19
|
+
|
|
20
|
+
_MEDIA_TYPE_BY_EXT: dict[str, str] = {
|
|
21
|
+
".png": "image/png",
|
|
22
|
+
".jpg": "image/jpeg",
|
|
23
|
+
".jpeg": "image/jpeg",
|
|
24
|
+
".gif": "image/gif",
|
|
25
|
+
".webp": "image/webp",
|
|
26
|
+
".bmp": "image/bmp",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ImageInput(BaseModel):
|
|
31
|
+
"""A multimodal image input for vision-capable LLMs.
|
|
32
|
+
|
|
33
|
+
Exactly one source must be provided: ``url`` (HTTP/HTTPS) or ``base64``.
|
|
34
|
+
Use the factory classmethods :meth:`from_url`, :meth:`from_base64`,
|
|
35
|
+
or :meth:`from_path` for convenience.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
url: str | None = Field(default=None, description="HTTP(S) URL to image")
|
|
39
|
+
base64: str | None = Field(default=None, description="Base64-encoded image data")
|
|
40
|
+
media_type: str | None = Field(
|
|
41
|
+
default=None,
|
|
42
|
+
description="MIME type for base64 inputs (e.g. 'image/png')",
|
|
43
|
+
)
|
|
44
|
+
detail: ImageDetail = Field(default="auto", description="Vision detail level")
|
|
45
|
+
|
|
46
|
+
@model_validator(mode="after")
|
|
47
|
+
def _exactly_one_source(self) -> ImageInput:
|
|
48
|
+
if self.url is None and self.base64 is None:
|
|
49
|
+
raise ValueError("Either url or base64 must be provided")
|
|
50
|
+
if self.url is not None and self.base64 is not None:
|
|
51
|
+
raise ValueError("Only one of url or base64 should be provided")
|
|
52
|
+
if self.base64 is not None and not self.media_type:
|
|
53
|
+
raise ValueError("media_type is required when providing base64 data")
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_url(cls, url: str, detail: ImageDetail = "auto") -> ImageInput:
|
|
58
|
+
"""Build an :class:`ImageInput` from an HTTP(S) URL."""
|
|
59
|
+
return cls(url=url, detail=detail)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_base64(
|
|
63
|
+
cls,
|
|
64
|
+
data: str,
|
|
65
|
+
media_type: str = "image/png",
|
|
66
|
+
detail: ImageDetail = "auto",
|
|
67
|
+
) -> ImageInput:
|
|
68
|
+
"""Build an :class:`ImageInput` from a base64-encoded image payload."""
|
|
69
|
+
return cls(base64=data, media_type=media_type, detail=detail)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_path(
|
|
73
|
+
cls,
|
|
74
|
+
path: str | Path,
|
|
75
|
+
media_type: str | None = None,
|
|
76
|
+
detail: ImageDetail = "auto",
|
|
77
|
+
) -> ImageInput:
|
|
78
|
+
"""Build an :class:`ImageInput` from a local file path."""
|
|
79
|
+
path_obj = Path(path)
|
|
80
|
+
if not path_obj.exists():
|
|
81
|
+
raise FileNotFoundError(f"Image file not found: {path}")
|
|
82
|
+
|
|
83
|
+
resolved_media_type = media_type
|
|
84
|
+
if resolved_media_type is None:
|
|
85
|
+
resolved_media_type = _MEDIA_TYPE_BY_EXT.get(path_obj.suffix.lower())
|
|
86
|
+
if resolved_media_type is None:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"Cannot determine media type for extension: {path_obj.suffix}. "
|
|
89
|
+
"Please provide media_type explicitly."
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
encoded = base64.b64encode(path_obj.read_bytes()).decode("utf-8")
|
|
93
|
+
return cls(base64=encoded, media_type=resolved_media_type, detail=detail)
|
|
94
|
+
|
|
95
|
+
def to_message_part(self) -> MessageContent:
|
|
96
|
+
"""Convert to the shared multimodal content representation."""
|
|
97
|
+
if self.url:
|
|
98
|
+
return MessageContent.image_part(url=self.url, detail=self.detail)
|
|
99
|
+
data_uri = f"data:{self.media_type};base64,{self.base64}"
|
|
100
|
+
return MessageContent.image_part(url=data_uri, detail=self.detail)
|
|
101
|
+
|
|
102
|
+
def to_litellm_part(self) -> dict[str, Any]:
|
|
103
|
+
"""Convert to a LiteLLM-compatible message content part payload."""
|
|
104
|
+
return self.to_message_part().to_litellm_part()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class GeneratedImage(BaseModel):
|
|
108
|
+
"""A single image returned by an image-generation API."""
|
|
109
|
+
|
|
110
|
+
url: str | None = Field(default=None, description="URL to the generated image")
|
|
111
|
+
b64_json: str | None = Field(
|
|
112
|
+
default=None, description="Base64-encoded JSON payload of the image"
|
|
113
|
+
)
|
|
114
|
+
revised_prompt: str | None = Field(
|
|
115
|
+
default=None, description="Provider-revised prompt actually used"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class ImageGenerationResponse(BaseModel):
|
|
120
|
+
"""Normalized response from an image-generation API."""
|
|
121
|
+
|
|
122
|
+
created: int = Field(description="Unix timestamp when the image was created")
|
|
123
|
+
data: list[GeneratedImage] = Field(description="Generated images")
|
|
124
|
+
model: str | None = Field(default=None, description="Model used for generation")
|
|
125
|
+
usage: Any | None = Field(
|
|
126
|
+
default=None, description="Usage info (shape varies by provider)"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def build_image_generation_request(
|
|
131
|
+
*,
|
|
132
|
+
prompt: str,
|
|
133
|
+
model: str | None,
|
|
134
|
+
n: int,
|
|
135
|
+
size: str | None,
|
|
136
|
+
quality: str | None,
|
|
137
|
+
style: str | None,
|
|
138
|
+
response_format: Literal["url", "b64_json"],
|
|
139
|
+
extra_params: dict[str, Any],
|
|
140
|
+
) -> tuple[str, dict[str, Any]]:
|
|
141
|
+
"""Build provider parameters for image-generation calls."""
|
|
142
|
+
target_model = model or "dall-e-3"
|
|
143
|
+
params: dict[str, Any] = {"prompt": prompt, "model": target_model, "n": n}
|
|
144
|
+
|
|
145
|
+
if "dall-e" in target_model.lower():
|
|
146
|
+
params["response_format"] = response_format
|
|
147
|
+
if size:
|
|
148
|
+
params["size"] = size
|
|
149
|
+
if quality:
|
|
150
|
+
params["quality"] = quality
|
|
151
|
+
if style:
|
|
152
|
+
params["style"] = style
|
|
153
|
+
|
|
154
|
+
params.update(extra_params)
|
|
155
|
+
return target_model, params
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def build_image_edit_request(
|
|
159
|
+
*,
|
|
160
|
+
prompt: str,
|
|
161
|
+
model: str | None,
|
|
162
|
+
n: int,
|
|
163
|
+
size: str | None,
|
|
164
|
+
quality: str | None,
|
|
165
|
+
extra_params: dict[str, Any],
|
|
166
|
+
) -> tuple[str, dict[str, Any]]:
|
|
167
|
+
"""Build provider parameters for image-*edit* calls (references + prompt).
|
|
168
|
+
|
|
169
|
+
The input image files are passed separately to the provider call; this
|
|
170
|
+
returns only the JSON-friendly parameters.
|
|
171
|
+
"""
|
|
172
|
+
target_model = model or "gpt-image-1"
|
|
173
|
+
params: dict[str, Any] = {"prompt": prompt, "model": target_model, "n": n}
|
|
174
|
+
if size:
|
|
175
|
+
params["size"] = size
|
|
176
|
+
if quality:
|
|
177
|
+
params["quality"] = quality
|
|
178
|
+
params.update(extra_params)
|
|
179
|
+
return target_model, params
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def normalize_usage(usage_obj: Any) -> Any | None:
|
|
183
|
+
"""Convert provider-specific usage payloads into serializable objects."""
|
|
184
|
+
if not usage_obj:
|
|
185
|
+
return None
|
|
186
|
+
if hasattr(usage_obj, "model_dump"):
|
|
187
|
+
return usage_obj.model_dump()
|
|
188
|
+
if hasattr(usage_obj, "dict"):
|
|
189
|
+
return usage_obj.dict()
|
|
190
|
+
if isinstance(usage_obj, dict):
|
|
191
|
+
return usage_obj
|
|
192
|
+
return {
|
|
193
|
+
key: getattr(usage_obj, key)
|
|
194
|
+
for key in dir(usage_obj)
|
|
195
|
+
if not key.startswith("_") and not callable(getattr(usage_obj, key))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def parse_image_generation_response(
|
|
200
|
+
response: Any, *, target_model: str
|
|
201
|
+
) -> ImageGenerationResponse:
|
|
202
|
+
"""Normalize a LiteLLM image-generation response into a stable model."""
|
|
203
|
+
return ImageGenerationResponse(
|
|
204
|
+
created=response.created,
|
|
205
|
+
data=[
|
|
206
|
+
GeneratedImage(
|
|
207
|
+
url=img.get("url"),
|
|
208
|
+
b64_json=img.get("b64_json"),
|
|
209
|
+
revised_prompt=img.get("revised_prompt"),
|
|
210
|
+
)
|
|
211
|
+
for img in response.data
|
|
212
|
+
],
|
|
213
|
+
model=getattr(response, "model", target_model),
|
|
214
|
+
usage=normalize_usage(getattr(response, "usage", None)),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
__all__ = [
|
|
219
|
+
"GeneratedImage",
|
|
220
|
+
"ImageGenerationResponse",
|
|
221
|
+
"ImageInput",
|
|
222
|
+
"build_image_edit_request",
|
|
223
|
+
"build_image_generation_request",
|
|
224
|
+
"normalize_usage",
|
|
225
|
+
"parse_image_generation_response",
|
|
226
|
+
]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Conversation and multimodal message primitives.
|
|
2
|
+
|
|
3
|
+
This module defines the typed message model used throughout ellements:
|
|
4
|
+
|
|
5
|
+
- :class:`MessageContent` — a single content part (text or image)
|
|
6
|
+
- :class:`Message` — one message in a conversation
|
|
7
|
+
- :class:`Conversation` — an ordered thread of messages plus optional system prompt
|
|
8
|
+
|
|
9
|
+
Plus helpers for normalizing accepted inputs and building multimodal payloads.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from typing import Any, Literal, Self, TypeAlias
|
|
16
|
+
from uuid import uuid4
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, Field, field_validator
|
|
19
|
+
|
|
20
|
+
ImageDetail: TypeAlias = Literal["auto", "low", "high"]
|
|
21
|
+
|
|
22
|
+
_VALID_ROLES: frozenset[str] = frozenset({"system", "user", "assistant", "tool"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ImageURLPart(BaseModel):
|
|
26
|
+
"""Typed payload for the ``image_url`` field of an image content part.
|
|
27
|
+
|
|
28
|
+
Distinguishes between hosted URLs (``http://`` / ``https://``) and
|
|
29
|
+
data URIs (``data:image/...;base64,...``) only by inspection; the
|
|
30
|
+
distinction is opaque to the provider.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
url: str = Field(description="HTTP(S) URL or data URI to the image")
|
|
34
|
+
detail: ImageDetail | None = Field(default=None, description="Vision detail level")
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_data_uri(self) -> bool:
|
|
38
|
+
"""True if ``url`` is a base64-encoded data URI."""
|
|
39
|
+
return self.url.startswith("data:")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MessageContent(BaseModel):
|
|
43
|
+
"""One typed content part: either text or an image reference."""
|
|
44
|
+
|
|
45
|
+
type: Literal["text", "image_url"] = Field(description="Content part type")
|
|
46
|
+
text: str | None = Field(default=None, description="Text content when type='text'")
|
|
47
|
+
image_url: ImageURLPart | None = Field(
|
|
48
|
+
default=None,
|
|
49
|
+
description="Image reference when type='image_url'",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def model_post_init(self, __context: Any) -> None:
|
|
53
|
+
if self.type == "text" and not self.text:
|
|
54
|
+
raise ValueError("text must be provided when type='text'")
|
|
55
|
+
if self.type == "image_url" and self.image_url is None:
|
|
56
|
+
raise ValueError("image_url must be provided when type='image_url'")
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def text_part(cls, text: str) -> MessageContent:
|
|
60
|
+
"""Build a text content part."""
|
|
61
|
+
return cls(type="text", text=text)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def image_part(
|
|
65
|
+
cls,
|
|
66
|
+
*,
|
|
67
|
+
url: str,
|
|
68
|
+
detail: ImageDetail | None = None,
|
|
69
|
+
) -> MessageContent:
|
|
70
|
+
"""Build an image content part referencing *url* (URL or data URI)."""
|
|
71
|
+
return cls(type="image_url", image_url=ImageURLPart(url=url, detail=detail))
|
|
72
|
+
|
|
73
|
+
def to_litellm_part(self) -> dict[str, Any]:
|
|
74
|
+
"""Convert the content part to LiteLLM-compatible payload."""
|
|
75
|
+
if self.type == "text":
|
|
76
|
+
return {"type": "text", "text": self.text}
|
|
77
|
+
assert self.image_url is not None # checked in model_post_init
|
|
78
|
+
payload: dict[str, Any] = {"url": self.image_url.url}
|
|
79
|
+
if self.image_url.detail is not None:
|
|
80
|
+
payload["detail"] = self.image_url.detail
|
|
81
|
+
return {"type": "image_url", "image_url": payload}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def content_parts_to_litellm(parts: Sequence[MessageContent]) -> list[dict[str, Any]]:
|
|
85
|
+
"""Convert content parts into LiteLLM payloads."""
|
|
86
|
+
return [part.to_litellm_part() for part in parts]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Message(BaseModel):
|
|
90
|
+
"""One message in a conversation thread."""
|
|
91
|
+
|
|
92
|
+
role: str = Field(description="Role of the message sender")
|
|
93
|
+
content: str | list[MessageContent] = Field(
|
|
94
|
+
description="Message content as plain text or multimodal parts",
|
|
95
|
+
)
|
|
96
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
97
|
+
|
|
98
|
+
@field_validator("role")
|
|
99
|
+
@classmethod
|
|
100
|
+
def _validate_role(cls, value: str) -> str:
|
|
101
|
+
if value not in _VALID_ROLES:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
f"Invalid role {value!r}. "
|
|
104
|
+
f"Expected one of: {sorted(_VALID_ROLES)}."
|
|
105
|
+
)
|
|
106
|
+
return value
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Conversation(BaseModel):
|
|
110
|
+
"""Ordered thread of messages plus an optional system prompt."""
|
|
111
|
+
|
|
112
|
+
id: str = Field(default_factory=lambda: str(uuid4()))
|
|
113
|
+
messages: list[Message] = Field(default_factory=list)
|
|
114
|
+
model: str = Field(description="The LLM model identifier")
|
|
115
|
+
system_prompt: str | None = Field(default=None)
|
|
116
|
+
|
|
117
|
+
def add_message(
|
|
118
|
+
self,
|
|
119
|
+
role: str,
|
|
120
|
+
content: str | list[MessageContent],
|
|
121
|
+
metadata: dict[str, Any] | None = None,
|
|
122
|
+
) -> Self:
|
|
123
|
+
"""Append a message to the thread.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
ValueError: If *role* is not one of ``system``, ``user``,
|
|
127
|
+
``assistant``, or ``tool``.
|
|
128
|
+
"""
|
|
129
|
+
self.messages.append(
|
|
130
|
+
Message(role=role, content=content, metadata=metadata or {})
|
|
131
|
+
)
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def user(
|
|
135
|
+
self,
|
|
136
|
+
content: str | list[MessageContent],
|
|
137
|
+
metadata: dict[str, Any] | None = None,
|
|
138
|
+
) -> Self:
|
|
139
|
+
"""Append a user message."""
|
|
140
|
+
return self.add_message("user", content, metadata)
|
|
141
|
+
|
|
142
|
+
def assistant(
|
|
143
|
+
self,
|
|
144
|
+
content: str | list[MessageContent],
|
|
145
|
+
metadata: dict[str, Any] | None = None,
|
|
146
|
+
) -> Self:
|
|
147
|
+
"""Append an assistant message."""
|
|
148
|
+
return self.add_message("assistant", content, metadata)
|
|
149
|
+
|
|
150
|
+
def system(
|
|
151
|
+
self,
|
|
152
|
+
content: str | list[MessageContent],
|
|
153
|
+
metadata: dict[str, Any] | None = None,
|
|
154
|
+
) -> Self:
|
|
155
|
+
"""Append a system message."""
|
|
156
|
+
return self.add_message("system", content, metadata)
|
|
157
|
+
|
|
158
|
+
def to_litellm_messages(self) -> list[dict[str, Any]]:
|
|
159
|
+
"""Convert the conversation into LiteLLM message payloads."""
|
|
160
|
+
messages: list[dict[str, Any]] = []
|
|
161
|
+
if self.system_prompt:
|
|
162
|
+
messages.append({"role": "system", "content": self.system_prompt})
|
|
163
|
+
|
|
164
|
+
for msg in self.messages:
|
|
165
|
+
if isinstance(msg.content, str):
|
|
166
|
+
messages.append({"role": msg.role, "content": msg.content})
|
|
167
|
+
continue
|
|
168
|
+
messages.append(
|
|
169
|
+
{
|
|
170
|
+
"role": msg.role,
|
|
171
|
+
"content": content_parts_to_litellm(msg.content),
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return messages
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
MessageInput: TypeAlias = str | list[dict[str, Any]] | Conversation
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def normalize_message_input(messages: MessageInput) -> list[dict[str, Any]]:
|
|
182
|
+
"""Normalize accepted message inputs into mutable LiteLLM-style payloads."""
|
|
183
|
+
if isinstance(messages, str):
|
|
184
|
+
return [{"role": "user", "content": messages}]
|
|
185
|
+
if isinstance(messages, Conversation):
|
|
186
|
+
return messages.to_litellm_messages()
|
|
187
|
+
return list(messages)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def build_multimodal_user_message(
|
|
191
|
+
prompt: str,
|
|
192
|
+
parts: Sequence[MessageContent],
|
|
193
|
+
) -> list[dict[str, Any]]:
|
|
194
|
+
"""Build a single-user multimodal message payload combining text and parts."""
|
|
195
|
+
return [
|
|
196
|
+
{
|
|
197
|
+
"role": "user",
|
|
198
|
+
"content": content_parts_to_litellm(
|
|
199
|
+
[MessageContent.text_part(prompt), *parts]
|
|
200
|
+
),
|
|
201
|
+
}
|
|
202
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Model-specific parameter filtering helpers for LiteLLM calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import warnings
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
# Model-specific parameter compatibility
|
|
10
|
+
# Maps model patterns to sets of unsupported parameters.
|
|
11
|
+
MODEL_PARAMETER_RESTRICTIONS = {
|
|
12
|
+
r"(openai/(responses/)?)?gpt-5(-.*)?": {
|
|
13
|
+
"temperature",
|
|
14
|
+
"top_p",
|
|
15
|
+
"presence_penalty",
|
|
16
|
+
"frequency_penalty",
|
|
17
|
+
"logprobs",
|
|
18
|
+
"top_logprobs",
|
|
19
|
+
"logit_bias",
|
|
20
|
+
"max_tokens",
|
|
21
|
+
},
|
|
22
|
+
r"o[34](-.*)?": {
|
|
23
|
+
"temperature",
|
|
24
|
+
"top_p",
|
|
25
|
+
"presence_penalty",
|
|
26
|
+
"frequency_penalty",
|
|
27
|
+
"logprobs",
|
|
28
|
+
"top_logprobs",
|
|
29
|
+
"logit_bias",
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_unsupported_parameters(model: str) -> set[str]:
|
|
35
|
+
"""Get the set of unsupported parameters for a given model."""
|
|
36
|
+
unsupported = set()
|
|
37
|
+
|
|
38
|
+
for pattern, restricted_params in MODEL_PARAMETER_RESTRICTIONS.items():
|
|
39
|
+
if re.match(pattern, model, re.IGNORECASE):
|
|
40
|
+
unsupported.update(restricted_params)
|
|
41
|
+
|
|
42
|
+
return unsupported
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def filter_parameters(model: str, **kwargs: Any) -> dict[str, Any]:
|
|
46
|
+
"""Filter out parameters that are not supported by the given model."""
|
|
47
|
+
unsupported = get_unsupported_parameters(model)
|
|
48
|
+
|
|
49
|
+
filtered = {
|
|
50
|
+
key: value
|
|
51
|
+
for key, value in kwargs.items()
|
|
52
|
+
if key not in unsupported and value is not None
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
removed = set(kwargs.keys()) - set(filtered.keys())
|
|
56
|
+
removed_unsupported = {key for key in removed if kwargs[key] is not None}
|
|
57
|
+
|
|
58
|
+
if removed_unsupported:
|
|
59
|
+
warnings.warn(
|
|
60
|
+
f"Model '{model}' does not support parameters: {', '.join(sorted(removed_unsupported))}. "
|
|
61
|
+
f"These parameters have been automatically filtered out.",
|
|
62
|
+
UserWarning,
|
|
63
|
+
stacklevel=3,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return filtered
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Structural protocols for LLM clients.
|
|
2
|
+
|
|
3
|
+
:class:`LLMClientProtocol` describes the surface that
|
|
4
|
+
:class:`ellements.core.LLMClient` exposes, decoupled from its concrete
|
|
5
|
+
implementation. Consumers that only need a subset of the API (which is
|
|
6
|
+
almost everyone — judges only need ``complete_structured``, strategies
|
|
7
|
+
only need ``complete``) can use this Protocol or a narrower one
|
|
8
|
+
defined locally, and tests can supply hand-rolled fakes without
|
|
9
|
+
inheriting from :class:`LLMClient` or pulling in litellm.
|
|
10
|
+
|
|
11
|
+
Structural typing (:pep:`544`) means **any** object with the right
|
|
12
|
+
methods satisfies the Protocol — the implementation does **not** need
|
|
13
|
+
to inherit from or know about :class:`LLMClientProtocol`. The
|
|
14
|
+
``@runtime_checkable`` decorator additionally enables ``isinstance``
|
|
15
|
+
checks at runtime, though those only verify method *names*, not
|
|
16
|
+
signatures.
|
|
17
|
+
|
|
18
|
+
Why a single big Protocol rather than per-method micro-Protocols?
|
|
19
|
+
Because the same client routinely fields many methods, and consumers
|
|
20
|
+
that already type-check against this Protocol can use any combination
|
|
21
|
+
without further imports. Tests/mocks that only implement what they
|
|
22
|
+
need still pass mypy (Protocols are structural, so missing methods
|
|
23
|
+
only error at the call site).
|
|
24
|
+
|
|
25
|
+
If you need finer-grained Protocols (interface segregation for unit
|
|
26
|
+
tests of small, focused functions), define them locally — they are
|
|
27
|
+
cheap and don't require coordination with this module.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from collections.abc import AsyncIterator, Iterable, Mapping
|
|
33
|
+
from typing import Any, Protocol, TypeVar, runtime_checkable
|
|
34
|
+
|
|
35
|
+
from ..tools import ToolCallResponse, ToolDialect, ToolExecutor, ToolRegistry
|
|
36
|
+
from .images import ImageGenerationResponse
|
|
37
|
+
from .messages import Conversation, MessageInput
|
|
38
|
+
|
|
39
|
+
T = TypeVar("T")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@runtime_checkable
|
|
43
|
+
class LLMClientProtocol(Protocol):
|
|
44
|
+
"""The complete public surface of an ellements LLM client.
|
|
45
|
+
|
|
46
|
+
Attributes:
|
|
47
|
+
model: The default model identifier used when callers omit
|
|
48
|
+
``model=`` on a per-call basis.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
model: str
|
|
52
|
+
|
|
53
|
+
async def complete(
|
|
54
|
+
self,
|
|
55
|
+
messages: MessageInput,
|
|
56
|
+
*,
|
|
57
|
+
model: str | None = None,
|
|
58
|
+
temperature: float = 0.7,
|
|
59
|
+
max_tokens: int | None = None,
|
|
60
|
+
**kwargs: Any,
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Generate a single text completion. See
|
|
63
|
+
:meth:`ellements.core.LLMClient.complete`."""
|
|
64
|
+
|
|
65
|
+
async def complete_structured(
|
|
66
|
+
self,
|
|
67
|
+
messages: MessageInput,
|
|
68
|
+
response_model: type[T],
|
|
69
|
+
*,
|
|
70
|
+
model: str | None = None,
|
|
71
|
+
temperature: float = 0.7,
|
|
72
|
+
max_tokens: int | None = None,
|
|
73
|
+
**kwargs: Any,
|
|
74
|
+
) -> T:
|
|
75
|
+
"""Generate a Pydantic-validated structured completion. See
|
|
76
|
+
:meth:`ellements.core.LLMClient.complete_structured`."""
|
|
77
|
+
|
|
78
|
+
async def complete_with_tools(
|
|
79
|
+
self,
|
|
80
|
+
messages: MessageInput,
|
|
81
|
+
tools: ToolRegistry | Mapping[str, Any] | Iterable[Any],
|
|
82
|
+
*,
|
|
83
|
+
tool_executor: ToolExecutor | None = None,
|
|
84
|
+
dialect: ToolDialect | None = None,
|
|
85
|
+
model: str | None = None,
|
|
86
|
+
temperature: float = 0.7,
|
|
87
|
+
max_tokens: int | None = None,
|
|
88
|
+
max_iterations: int = 10,
|
|
89
|
+
**kwargs: Any,
|
|
90
|
+
) -> ToolCallResponse:
|
|
91
|
+
"""Run a multi-turn completion loop with tool calling. See
|
|
92
|
+
:meth:`ellements.core.LLMClient.complete_with_tools`."""
|
|
93
|
+
|
|
94
|
+
def stream(
|
|
95
|
+
self,
|
|
96
|
+
messages: MessageInput,
|
|
97
|
+
*,
|
|
98
|
+
model: str | None = None,
|
|
99
|
+
temperature: float = 0.7,
|
|
100
|
+
max_tokens: int | None = None,
|
|
101
|
+
**kwargs: Any,
|
|
102
|
+
) -> AsyncIterator[str]:
|
|
103
|
+
"""Stream completion tokens. See
|
|
104
|
+
:meth:`ellements.core.LLMClient.stream`."""
|
|
105
|
+
|
|
106
|
+
async def continue_conversation(
|
|
107
|
+
self,
|
|
108
|
+
conversation: Conversation,
|
|
109
|
+
user_message: str,
|
|
110
|
+
*,
|
|
111
|
+
temperature: float = 0.7,
|
|
112
|
+
max_tokens: int | None = None,
|
|
113
|
+
**kwargs: Any,
|
|
114
|
+
) -> str:
|
|
115
|
+
"""Append a user turn + assistant response. See
|
|
116
|
+
:meth:`ellements.core.LLMClient.continue_conversation`."""
|
|
117
|
+
|
|
118
|
+
async def loglikelihood(
|
|
119
|
+
self,
|
|
120
|
+
context: str,
|
|
121
|
+
continuation: str,
|
|
122
|
+
*,
|
|
123
|
+
model: str | None = None,
|
|
124
|
+
max_tokens: int | None = None,
|
|
125
|
+
**kwargs: Any,
|
|
126
|
+
) -> tuple[float, bool]:
|
|
127
|
+
"""Compute log-likelihood of *continuation* given *context*. See
|
|
128
|
+
:meth:`ellements.core.LLMClient.loglikelihood`."""
|
|
129
|
+
|
|
130
|
+
async def generate_image(
|
|
131
|
+
self,
|
|
132
|
+
prompt: str,
|
|
133
|
+
*,
|
|
134
|
+
model: str | None = None,
|
|
135
|
+
n: int = 1,
|
|
136
|
+
size: str | None = None,
|
|
137
|
+
quality: str | None = None,
|
|
138
|
+
style: str | None = None,
|
|
139
|
+
response_format: str = "url",
|
|
140
|
+
**kwargs: Any,
|
|
141
|
+
) -> ImageGenerationResponse:
|
|
142
|
+
"""Generate images from a text prompt. See
|
|
143
|
+
:meth:`ellements.core.LLMClient.generate_image`."""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
__all__ = ["LLMClientProtocol"]
|