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,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"]