unique_toolkit 2026.32.0.dev5__py3-none-any.whl → 2026.32.0.dev7__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.
@@ -43,14 +43,18 @@ class AnalyticsToolName(TypedDict):
43
43
  display_name: str
44
44
 
45
45
 
46
- class AnalyticsTokenUsage(TypedDict):
47
- model_name: str
46
+ class AnalyticsConsumption(TypedDict):
48
47
  completion_tokens: int | None
49
48
  prompt_tokens: int | None
50
49
  total_tokens: int | None
51
50
  reasoning_tokens: int | None
52
51
  cached_tokens: int | None
53
52
  cache_write_tokens: int | None
53
+ cost_usd: float | None
54
+
55
+
56
+ class AnalyticsConsumptionByLlm(AnalyticsConsumption):
57
+ model_name: str
54
58
 
55
59
 
56
60
  class Analytics(TypedDict):
@@ -58,6 +62,10 @@ class Analytics(TypedDict):
58
62
  artifacts_created_count: int | None
59
63
  artifacts_created_filetype: list[str] | None
60
64
  context_memory_updated: bool | None
65
+ # Sum of all model usages this turn. None when no invocations reported usage.
66
+ consumption: AnalyticsConsumption | None
67
+ # Per-model usage totals, sorted by model_name.
68
+ consumption_by_llm: list[AnalyticsConsumptionByLlm]
61
69
  language_model: AnalyticsLanguageModel
62
70
  loop_iteration_count: int
63
71
  mcp_tool_names_used: list[AnalyticsToolName]
@@ -67,7 +75,6 @@ class Analytics(TypedDict):
67
75
  references_count: int
68
76
  skills_used: list[AnalyticsSkill]
69
77
  subagent_names_used: list[AnalyticsToolName]
70
- tokens: list[AnalyticsTokenUsage]
71
78
  tool_call_count: int
72
79
  tools_used: list[AnalyticsTool]
73
80
  total_time_to_answer_ms: int | None
@@ -149,6 +156,7 @@ class DebugInfoManager:
149
156
  # DisplayCodeInterpreterFilesPostProcessor) into debug_info["artifacts"].
150
157
  # Absent when no Code Interpreter ran this turn → artifact fields stay
151
158
  # None, preserving the always-present, null-when-unknown contract.
159
+ consumption_by_llm = _aggregate_consumption_by_llm(invocations or [])
152
160
  analytics = Analytics(
153
161
  tools_used=analytics_tools,
154
162
  tool_call_count=len(analytics_tools),
@@ -167,7 +175,8 @@ class DebugInfoManager:
167
175
  loop_iteration_count=loop_iteration_count,
168
176
  subagent_names_used=_unique_tool_names(analytics_tools, "is_sub_agent"),
169
177
  mcp_tool_names_used=_unique_tool_names(analytics_tools, "is_mcp"),
170
- tokens=_aggregate_tokens_by_model(invocations or []),
178
+ consumption_by_llm=consumption_by_llm,
179
+ consumption=_aggregate_consumption(invocations or []),
171
180
  total_time_to_answer_ms=total_time_to_answer_ms,
172
181
  artifacts_created_count=artifacts["count"] if artifacts else None,
173
182
  artifacts_created_filetype=artifacts["filetypes"] if artifacts else None,
@@ -179,27 +188,66 @@ class DebugInfoManager:
179
188
  self.add("analytics", analytics)
180
189
 
181
190
 
182
- def _aggregate_tokens_by_model(
191
+ def _sum_invocation_costs(
192
+ invocations: list[LanguageModelInvocationStats],
193
+ ) -> float | None:
194
+ """Sum costs only when every invocation has a known cost; else None."""
195
+ costs = [invocation.cost_usd for invocation in invocations]
196
+ if not costs or any(cost is None for cost in costs):
197
+ return None
198
+ return sum(cost for cost in costs if cost is not None)
199
+
200
+
201
+ def _to_analytics_consumption(
202
+ usage: LanguageModelTokenUsage,
203
+ cost_usd: float | None,
204
+ ) -> AnalyticsConsumption:
205
+ return AnalyticsConsumption(
206
+ completion_tokens=usage.completion_tokens,
207
+ prompt_tokens=usage.prompt_tokens,
208
+ total_tokens=usage.total_tokens,
209
+ reasoning_tokens=usage.reasoning_tokens,
210
+ cached_tokens=usage.cached_tokens,
211
+ cache_write_tokens=usage.cache_write_tokens,
212
+ cost_usd=cost_usd,
213
+ )
214
+
215
+
216
+ def _aggregate_consumption(
183
217
  invocations: list[LanguageModelInvocationStats],
184
- ) -> list[AnalyticsTokenUsage]:
185
- usages_by_model: dict[str, list[LanguageModelTokenUsage]] = defaultdict(list)
218
+ ) -> AnalyticsConsumption | None:
219
+ """Aggregate token usage and cost across all models for this turn."""
220
+ usage = LanguageModelTokenUsage.sum_usages(
221
+ invocation.token_usage for invocation in invocations
222
+ )
223
+ if usage is None:
224
+ return None
225
+ return _to_analytics_consumption(usage, _sum_invocation_costs(invocations))
226
+
227
+
228
+ def _aggregate_consumption_by_llm(
229
+ invocations: list[LanguageModelInvocationStats],
230
+ ) -> list[AnalyticsConsumptionByLlm]:
231
+ invocations_by_model: dict[str, list[LanguageModelInvocationStats]] = defaultdict(
232
+ list
233
+ )
186
234
  for invocation in invocations:
187
- usages_by_model[str(invocation.model_name)].append(invocation.token_usage)
235
+ invocations_by_model[str(invocation.model_name)].append(invocation)
188
236
 
189
- totals: list[AnalyticsTokenUsage] = []
190
- for model_name in sorted(usages_by_model):
191
- usage = LanguageModelTokenUsage.sum_usages(usages_by_model[model_name])
237
+ totals: list[AnalyticsConsumptionByLlm] = []
238
+ for model_name in sorted(invocations_by_model):
239
+ model_invocations = invocations_by_model[model_name]
240
+ usage = LanguageModelTokenUsage.sum_usages(
241
+ invocation.token_usage for invocation in model_invocations
242
+ )
192
243
  if usage is None:
193
244
  continue
194
245
  totals.append(
195
- AnalyticsTokenUsage(
246
+ AnalyticsConsumptionByLlm(
196
247
  model_name=model_name,
197
- completion_tokens=usage.completion_tokens,
198
- prompt_tokens=usage.prompt_tokens,
199
- total_tokens=usage.total_tokens,
200
- reasoning_tokens=usage.reasoning_tokens,
201
- cached_tokens=usage.cached_tokens,
202
- cache_write_tokens=usage.cache_write_tokens,
248
+ **_to_analytics_consumption(
249
+ usage, _sum_invocation_costs(model_invocations)
250
+ ),
203
251
  )
204
252
  )
205
253
  return totals
@@ -93,6 +93,7 @@ class LanguageModelName(StrEnum):
93
93
  ANTHROPIC_CLAUDE_OPUS_4_6 = "litellm:anthropic-claude-opus-4-6"
94
94
  ANTHROPIC_CLAUDE_OPUS_4_7 = "litellm:anthropic-claude-opus-4-7"
95
95
  ANTHROPIC_CLAUDE_OPUS_4_8 = "litellm:anthropic-claude-opus-4-8"
96
+ ANTHROPIC_CLAUDE_OPUS_5 = "litellm:anthropic-claude-opus-5"
96
97
  ANTHROPIC_CLAUDE_FABLE_5 = "litellm:anthropic-claude-fable-5"
97
98
  GEMINI_2_0_FLASH = "litellm:gemini-2-0-flash"
98
99
  GEMINI_2_5_FLASH = "litellm:gemini-2-5-flash"
@@ -144,6 +145,7 @@ class LanguageModelName(StrEnum):
144
145
  VERTEX_CLAUDE_OPUS_4_6 = "litellm:vertex-claude-opus-4-6"
145
146
  VERTEX_CLAUDE_OPUS_4_7 = "litellm:vertex-claude-opus-4-7"
146
147
  VERTEX_CLAUDE_OPUS_4_8 = "litellm:vertex-claude-opus-4-8"
148
+ VERTEX_CLAUDE_OPUS_5 = "litellm:vertex-claude-opus-5"
147
149
  VERTEX_CLAUDE_FABLE_5 = "litellm:vertex-claude-fable-5"
148
150
 
149
151
 
@@ -1812,6 +1814,30 @@ class LanguageModelInfo(BaseModel):
1812
1814
  published_at=date(2026, 5, 28),
1813
1815
  supported_reasoning_efforts=[],
1814
1816
  )
1817
+ case (
1818
+ LanguageModelName.ANTHROPIC_CLAUDE_OPUS_5
1819
+ | LanguageModelName.VERTEX_CLAUDE_OPUS_5
1820
+ ):
1821
+ return cls(
1822
+ name=model_name,
1823
+ capabilities=[
1824
+ ModelCapabilities.FUNCTION_CALLING,
1825
+ ModelCapabilities.STREAMING,
1826
+ ModelCapabilities.VISION,
1827
+ ModelCapabilities.REASONING,
1828
+ ],
1829
+ provider=LanguageModelProvider.LITELLM,
1830
+ family=ModelFamily.ANTHROPIC,
1831
+ version="claude-opus-5",
1832
+ encoder_name=EncoderName.O200K_BASE, # TODO: Update encoder with litellm
1833
+ token_limits=LanguageModelTokenLimits(
1834
+ token_limit_input=1_000_000,
1835
+ token_limit_output=128_000,
1836
+ ),
1837
+ info_cutoff_at=date(2026, 5, 1),
1838
+ published_at=date(2026, 7, 24),
1839
+ supported_reasoning_efforts=[],
1840
+ )
1815
1841
  case (
1816
1842
  LanguageModelName.ANTHROPIC_CLAUDE_FABLE_5
1817
1843
  | LanguageModelName.VERTEX_CLAUDE_FABLE_5
@@ -11,6 +11,7 @@ from humps import camelize
11
11
  from pydantic import BaseModel, BeforeValidator, ConfigDict
12
12
 
13
13
  from unique_toolkit.language_model.infos import LanguageModelName
14
+ from unique_toolkit.language_model.model_costs import calculate_invocation_cost_usd
14
15
  from unique_toolkit.language_model.schemas import LanguageModelTokenUsage
15
16
 
16
17
  # `protected_namespaces=()` allows the `model_name` field name.
@@ -59,6 +60,7 @@ class LanguageModelInvocationStats(BaseModel):
59
60
  model_name: ModelName
60
61
  token_usage: LanguageModelTokenUsage
61
62
  source: Source # e.g. "main_loop", tool/evaluation/postprocessor name
63
+ cost_usd: float | None = None
62
64
 
63
65
  @classmethod
64
66
  def from_usage(
@@ -67,4 +69,9 @@ class LanguageModelInvocationStats(BaseModel):
67
69
  token_usage: LanguageModelTokenUsage,
68
70
  source: str,
69
71
  ) -> Self:
70
- return cls(model_name=model_name, token_usage=token_usage, source=source)
72
+ return cls(
73
+ model_name=model_name,
74
+ token_usage=token_usage,
75
+ source=source,
76
+ cost_usd=calculate_invocation_cost_usd(str(model_name), token_usage),
77
+ )
@@ -0,0 +1,118 @@
1
+ """Load model prices and calculate the USD cost of LLM invocations."""
2
+
3
+ import logging
4
+ import os
5
+ import time
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+ from pydantic import BaseModel, ConfigDict, Field
10
+
11
+ from unique_toolkit.language_model.schemas import LanguageModelTokenUsage
12
+
13
+ logger = logging.getLogger(f"toolkit.language_model.{__name__}")
14
+
15
+ MODEL_COSTS_FILE_ENV = "MODEL_COSTS_FILE"
16
+ _LITELLM_PREFIX = "litellm:"
17
+ _TOKENS_PER_MILLION = 1_000_000
18
+
19
+ # Safety-only bound on how long a process may keep a loaded catalog. Not the
20
+ # primary refresh mechanism — just ensures a long-lived worker eventually
21
+ # re-reads a remounted price sheet.
22
+ _CACHE_MAX_AGE_SECONDS = 5 * 60
23
+
24
+
25
+ class ModelCost(BaseModel):
26
+ """Per-million-token prices for one language model."""
27
+
28
+ model_config = ConfigDict(extra="forbid")
29
+
30
+ input: float
31
+ completion: float
32
+ currency: str = Field(default="USD", min_length=1)
33
+
34
+
35
+ class ModelCostCatalog(BaseModel):
36
+ """Versioned model-price catalog rendered by the platform Helm charts."""
37
+
38
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
39
+
40
+ cost_schema_version: int = Field(alias="costSchemaVersion")
41
+ models: dict[str, ModelCost] = Field(min_length=1)
42
+
43
+
44
+ _catalog_cache: dict[Path, tuple[ModelCostCatalog, float]] = {}
45
+
46
+
47
+ def _parse_model_cost_catalog(path: Path) -> ModelCostCatalog:
48
+ payload = yaml.safe_load(path.read_text(encoding="utf-8"))
49
+ catalog = ModelCostCatalog.model_validate(payload)
50
+ if catalog.cost_schema_version != 1:
51
+ raise ValueError(
52
+ f"Unsupported model cost schema version: {catalog.cost_schema_version}"
53
+ )
54
+ return catalog
55
+
56
+
57
+ def _load_model_cost_catalog(path: Path) -> ModelCostCatalog:
58
+ now = time.monotonic()
59
+ cached = _catalog_cache.get(path)
60
+ if cached is not None:
61
+ catalog, loaded_at = cached
62
+ if now - loaded_at < _CACHE_MAX_AGE_SECONDS:
63
+ return catalog
64
+
65
+ catalog = _parse_model_cost_catalog(path)
66
+ _catalog_cache[path] = (catalog, now)
67
+ return catalog
68
+
69
+
70
+ def load_model_cost_catalog(path: str | Path | None = None) -> ModelCostCatalog | None:
71
+ """Load and cache the configured catalog, or return None when unavailable.
72
+
73
+ Returns None when the catalog path is unset, or when the file cannot be
74
+ read/parsed. Cost capture must never abort a successful LLM call over a
75
+ missing or invalid price sheet.
76
+ """
77
+
78
+ configured_path = path or os.getenv(MODEL_COSTS_FILE_ENV)
79
+ if configured_path is None or not str(configured_path).strip():
80
+ return None
81
+ try:
82
+ return _load_model_cost_catalog(Path(configured_path))
83
+ except Exception as exc:
84
+ logger.warning(
85
+ "Failed to load model cost catalog from %s: %s",
86
+ configured_path,
87
+ exc,
88
+ )
89
+ return None
90
+
91
+
92
+ def calculate_invocation_cost_usd(
93
+ model_name: str,
94
+ token_usage: LanguageModelTokenUsage,
95
+ catalog: ModelCostCatalog | None = None,
96
+ ) -> float | None:
97
+ """Calculate invocation cost using prompt and completion token totals."""
98
+
99
+ if token_usage.prompt_tokens is None or token_usage.completion_tokens is None:
100
+ return None
101
+
102
+ active_catalog = catalog if catalog is not None else load_model_cost_catalog()
103
+ if active_catalog is None:
104
+ return None
105
+
106
+ normalized_model_name = str(model_name)
107
+ model_cost = active_catalog.models.get(normalized_model_name)
108
+ if model_cost is None and normalized_model_name.startswith(_LITELLM_PREFIX):
109
+ model_cost = active_catalog.models.get(
110
+ normalized_model_name.removeprefix(_LITELLM_PREFIX)
111
+ )
112
+ if model_cost is None:
113
+ return None
114
+
115
+ return (
116
+ token_usage.prompt_tokens * model_cost.input
117
+ + token_usage.completion_tokens * model_cost.completion
118
+ ) / _TOKENS_PER_MILLION
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: unique-toolkit
3
- Version: 2026.32.0.dev5
3
+ Version: 2026.32.0.dev7
4
4
  Summary:
5
5
  Author: Cedric Klinkert, Martin Fadler, Sadique Sheik, Fabian Schläpfer, Pascal Hauri, Ejiroghene Oki-Peter, Rami Azouz, Ricardo Visini, Sanzio Monti
6
6
  Author-email: Cedric Klinkert <cedric.klinkert@unique.ch>, Martin Fadler <martin.fadler@unique.ch>, Sadique Sheik <sadique@unique.ch>, Fabian Schläpfer <fabian@unique.ch>, Pascal Hauri <pascal@unique.ch>, Ejiroghene Oki-Peter <oki-peter.ejiroghene@unique.ai>, Rami Azouz <rami.ext@unique.ch>, Ricardo Visini <ricardo.visini@unique.ch>, Sanzio Monti <sanzio.monti@unique.ai>
@@ -33,6 +33,7 @@ Requires-Dist: tenacity>=9.1.4
33
33
  Requires-Dist: pydantic-core>=2.41.5
34
34
  Requires-Dist: cachetools>=5.0,<6
35
35
  Requires-Dist: rapidfuzz>=3.0,<4
36
+ Requires-Dist: pyyaml>=6.0.3
36
37
  Requires-Dist: fastapi>=0.133.0,<1.0.0 ; extra == 'fastapi'
37
38
  Requires-Dist: starlette>=1.3.1,<2.0.0 ; extra == 'fastapi'
38
39
  Requires-Dist: uvicorn>=0.37.0,<0.38 ; extra == 'fastapi'
@@ -83,7 +83,7 @@ unique_toolkit/_common/utils/write_configuration.py,sha256=fzvr4C-XBL3OSM3Od9Tbq
83
83
  unique_toolkit/_common/validate_required_values.py,sha256=cA8GPwkV1EPsG6ffIkmAn3Dx4LRRMC1EAURmT7PyuCg,551
84
84
  unique_toolkit/_common/validators.py,sha256=ElnkMsyEY24TfzfTVHvireyT39EnZgW5N40T0P4b6gE,2638
85
85
  unique_toolkit/agentic/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
86
- unique_toolkit/agentic/debug_info_manager/debug_info_manager.py,sha256=VZLDOTAJJ_jVOZ4Zw4C2F-uCv3vsjZTcvbljGBYKh-A,10501
86
+ unique_toolkit/agentic/debug_info_manager/debug_info_manager.py,sha256=fqhbKe0WWB8nZ7CzeEcgQGMHJajIMVOpKkUWOqiDdUE,12149
87
87
  unique_toolkit/agentic/debug_info_manager/test/test_debug_info_manager.py,sha256=bDjrHLqfwtd7ewdIdyTFHabG-15t3iXvs34ywoYXcik,11888
88
88
  unique_toolkit/agentic/evaluation/config.py,sha256=u5-iuT-4mfA2_9UZjZ-TN3YutVf35aR4EsF9l61Odnk,1696
89
89
  unique_toolkit/agentic/evaluation/context_relevancy/prompts/__init__.py,sha256=IKGvHW2viBylTUjEJNKvIXO4YrYwdJXBHSxA9rKtiRI,355
@@ -392,8 +392,9 @@ unique_toolkit/language_model/builder.py,sha256=KwBzsD6-fvQnB4p2I7M-5Tk6j4tmQ973
392
392
  unique_toolkit/language_model/constants.py,sha256=B-topqW0r83dkC_25DeQfnPk3n53qzIHUCBS7YJ0-1U,119
393
393
  unique_toolkit/language_model/default_language_model.py,sha256=mcyuFs_V6tp52C8xJSHiEHO4kFGj6xed1gvozvCRD7k,1181
394
394
  unique_toolkit/language_model/functions.py,sha256=uYIsVQfF4uwW6cftlKhQElMLpXNaVe_57FFobhC7R4A,23503
395
- unique_toolkit/language_model/infos.py,sha256=y1JLszdCyKBUTCjPobYsB02G2WXZOGoHzQtjNqJ7EOc,140831
396
- unique_toolkit/language_model/invocation_stats.py,sha256=ApRy7YzlcWd5so3tm8RhkrJn6Ip5_rjZwYcyh92Wc_4,2244
395
+ unique_toolkit/language_model/infos.py,sha256=zElSKg539eX0X-WWCAs4UO_SL1PxJ2epynut0vY4jRA,142052
396
+ unique_toolkit/language_model/invocation_stats.py,sha256=XAM_qckI-BoMBSgDpVcxFD1cFSdi1-GHZToHz_jLynM,2491
397
+ unique_toolkit/language_model/model_costs.py,sha256=WM1u2fWAKpPB_creS-ew8YAzc1V8oiaCbvhpSJaO8B0,3789
397
398
  unique_toolkit/language_model/prompt.py,sha256=JSawaLjQg3VR-E2fK8engFyJnNdk21zaO8pPIodzN4Q,3991
398
399
  unique_toolkit/language_model/reference.py,sha256=T3wr2jP_uRg5wKVIbKqLbxJlcgxfbZQGq3XdjbQ2kSo,6984
399
400
  unique_toolkit/language_model/schemas.py,sha256=IUmp_JMHx0czadJlmsLL9nmc-nCPmVRVLp2T5DSYsqY,35402
@@ -418,6 +419,6 @@ unique_toolkit/short_term_memory/service.py,sha256=2JMBEBn6GoyK-a0xvdcWpPft-3C74
418
419
  unique_toolkit/smart_rules/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
419
420
  unique_toolkit/smart_rules/compile.py,sha256=vRe0aMJWuVIK-VsqomgxQImzke7MDmwgSxxJfYCa7zw,1386
420
421
  unique_toolkit/test_utilities/events.py,sha256=FLrhMh0A_mQutAUjoD0FdMcVQ4Gq31r35OVGzRjvLro,6760
421
- unique_toolkit-2026.32.0.dev5.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
422
- unique_toolkit-2026.32.0.dev5.dist-info/METADATA,sha256=1bEknHHM6C2M0YX9ZPdElqUVEOA1AnI_u-_zmXVKrQM,7465
423
- unique_toolkit-2026.32.0.dev5.dist-info/RECORD,,
422
+ unique_toolkit-2026.32.0.dev7.dist-info/WHEEL,sha256=eh7sammvW2TypMMMGKgsM83HyA_3qQ5Lgg3ynoecH3M,79
423
+ unique_toolkit-2026.32.0.dev7.dist-info/METADATA,sha256=NPHWeljbN2U6iJuo-_bPj7Fiw7l1ZxiBiCVJTGBnLzs,7494
424
+ unique_toolkit-2026.32.0.dev7.dist-info/RECORD,,