prompture 0.0.38.dev2__py3-none-any.whl → 0.0.42__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 (46) hide show
  1. prompture/__init__.py +12 -1
  2. prompture/_version.py +2 -2
  3. prompture/agent.py +11 -11
  4. prompture/async_agent.py +11 -11
  5. prompture/async_conversation.py +9 -0
  6. prompture/async_core.py +16 -0
  7. prompture/async_driver.py +39 -0
  8. prompture/async_groups.py +63 -0
  9. prompture/conversation.py +9 -0
  10. prompture/core.py +16 -0
  11. prompture/cost_mixin.py +62 -0
  12. prompture/discovery.py +108 -43
  13. prompture/driver.py +39 -0
  14. prompture/drivers/__init__.py +39 -0
  15. prompture/drivers/async_azure_driver.py +7 -6
  16. prompture/drivers/async_claude_driver.py +177 -8
  17. prompture/drivers/async_google_driver.py +10 -0
  18. prompture/drivers/async_grok_driver.py +4 -4
  19. prompture/drivers/async_groq_driver.py +4 -4
  20. prompture/drivers/async_modelscope_driver.py +286 -0
  21. prompture/drivers/async_moonshot_driver.py +312 -0
  22. prompture/drivers/async_openai_driver.py +158 -6
  23. prompture/drivers/async_openrouter_driver.py +196 -7
  24. prompture/drivers/async_registry.py +30 -0
  25. prompture/drivers/async_zai_driver.py +303 -0
  26. prompture/drivers/azure_driver.py +6 -5
  27. prompture/drivers/claude_driver.py +10 -0
  28. prompture/drivers/google_driver.py +10 -0
  29. prompture/drivers/grok_driver.py +4 -4
  30. prompture/drivers/groq_driver.py +4 -4
  31. prompture/drivers/modelscope_driver.py +303 -0
  32. prompture/drivers/moonshot_driver.py +342 -0
  33. prompture/drivers/openai_driver.py +22 -12
  34. prompture/drivers/openrouter_driver.py +248 -44
  35. prompture/drivers/zai_driver.py +318 -0
  36. prompture/groups.py +42 -0
  37. prompture/ledger.py +252 -0
  38. prompture/model_rates.py +114 -2
  39. prompture/settings.py +16 -1
  40. {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/METADATA +1 -1
  41. prompture-0.0.42.dist-info/RECORD +84 -0
  42. prompture-0.0.38.dev2.dist-info/RECORD +0 -77
  43. {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/WHEEL +0 -0
  44. {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/entry_points.txt +0 -0
  45. {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/licenses/LICENSE +0 -0
  46. {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/top_level.txt +0 -0
prompture/__init__.py CHANGED
@@ -110,8 +110,15 @@ from .image import (
110
110
  image_from_url,
111
111
  make_image,
112
112
  )
113
+ from .ledger import ModelUsageLedger, get_recently_used_models
113
114
  from .logging import JSONFormatter, configure_logging
114
- from .model_rates import get_model_info, get_model_rates, refresh_rates_cache
115
+ from .model_rates import (
116
+ ModelCapabilities,
117
+ get_model_capabilities,
118
+ get_model_info,
119
+ get_model_rates,
120
+ refresh_rates_cache,
121
+ )
115
122
  from .persistence import ConversationStore
116
123
  from .persona import (
117
124
  PERSONAS,
@@ -213,7 +220,9 @@ __all__ = [
213
220
  "LocalHTTPDriver",
214
221
  "LoopGroup",
215
222
  "MemoryCacheBackend",
223
+ "ModelCapabilities",
216
224
  "ModelRetry",
225
+ "ModelUsageLedger",
217
226
  "OllamaDriver",
218
227
  "OpenAIDriver",
219
228
  "OpenRouterDriver",
@@ -255,11 +264,13 @@ __all__ = [
255
264
  "get_driver_for_model",
256
265
  "get_field_definition",
257
266
  "get_field_names",
267
+ "get_model_capabilities",
258
268
  "get_model_info",
259
269
  "get_model_rates",
260
270
  "get_persona",
261
271
  "get_persona_names",
262
272
  "get_persona_registry_snapshot",
273
+ "get_recently_used_models",
263
274
  "get_registry_snapshot",
264
275
  "get_required_fields",
265
276
  "get_trait",
prompture/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.0.38.dev2'
32
- __version_tuple__ = version_tuple = (0, 0, 38, 'dev2')
31
+ __version__ = version = '0.0.42'
32
+ __version_tuple__ = version_tuple = (0, 0, 42)
33
33
 
34
34
  __commit_id__ = commit_id = None
prompture/agent.py CHANGED
@@ -188,7 +188,7 @@ class Agent(Generic[DepsType]):
188
188
  for fn in tools:
189
189
  self._tools.register(fn)
190
190
 
191
- self._state = AgentState.idle
191
+ self._lifecycle = AgentState.idle
192
192
  self._stop_requested = False
193
193
 
194
194
  # ------------------------------------------------------------------
@@ -206,7 +206,7 @@ class Agent(Generic[DepsType]):
206
206
  @property
207
207
  def state(self) -> AgentState:
208
208
  """Current lifecycle state of the agent."""
209
- return self._state
209
+ return self._lifecycle
210
210
 
211
211
  def stop(self) -> None:
212
212
  """Request graceful shutdown after the current iteration."""
@@ -265,16 +265,16 @@ class Agent(Generic[DepsType]):
265
265
  prompt: The user prompt to send.
266
266
  deps: Optional dependencies injected into :class:`RunContext`.
267
267
  """
268
- self._state = AgentState.running
268
+ self._lifecycle = AgentState.running
269
269
  self._stop_requested = False
270
270
  steps: list[AgentStep] = []
271
271
 
272
272
  try:
273
273
  result = self._execute(prompt, steps, deps)
274
- self._state = AgentState.idle
274
+ self._lifecycle = AgentState.idle
275
275
  return result
276
276
  except Exception:
277
- self._state = AgentState.errored
277
+ self._lifecycle = AgentState.errored
278
278
  raise
279
279
 
280
280
  # ------------------------------------------------------------------
@@ -722,7 +722,7 @@ class Agent(Generic[DepsType]):
722
722
 
723
723
  def _execute_iter(self, prompt: str, deps: Any) -> Generator[AgentStep, None, AgentResult]:
724
724
  """Generator that executes the agent loop and yields each step."""
725
- self._state = AgentState.running
725
+ self._lifecycle = AgentState.running
726
726
  self._stop_requested = False
727
727
  steps: list[AgentStep] = []
728
728
 
@@ -730,10 +730,10 @@ class Agent(Generic[DepsType]):
730
730
  result = self._execute(prompt, steps, deps)
731
731
  # Yield each step one at a time
732
732
  yield from result.steps
733
- self._state = AgentState.idle
733
+ self._lifecycle = AgentState.idle
734
734
  return result
735
735
  except Exception:
736
- self._state = AgentState.errored
736
+ self._lifecycle = AgentState.errored
737
737
  raise
738
738
 
739
739
  # ------------------------------------------------------------------
@@ -757,7 +757,7 @@ class Agent(Generic[DepsType]):
757
757
 
758
758
  def _execute_stream(self, prompt: str, deps: Any) -> Generator[StreamEvent, None, AgentResult]:
759
759
  """Generator that executes the agent loop and yields stream events."""
760
- self._state = AgentState.running
760
+ self._lifecycle = AgentState.running
761
761
  self._stop_requested = False
762
762
  steps: list[AgentStep] = []
763
763
 
@@ -853,10 +853,10 @@ class Agent(Generic[DepsType]):
853
853
  data=result,
854
854
  )
855
855
 
856
- self._state = AgentState.idle
856
+ self._lifecycle = AgentState.idle
857
857
  return result
858
858
  except Exception:
859
- self._state = AgentState.errored
859
+ self._lifecycle = AgentState.errored
860
860
  raise
861
861
 
862
862
 
prompture/async_agent.py CHANGED
@@ -182,7 +182,7 @@ class AsyncAgent(Generic[DepsType]):
182
182
  for fn in tools:
183
183
  self._tools.register(fn)
184
184
 
185
- self._state = AgentState.idle
185
+ self._lifecycle = AgentState.idle
186
186
  self._stop_requested = False
187
187
 
188
188
  # ------------------------------------------------------------------
@@ -197,7 +197,7 @@ class AsyncAgent(Generic[DepsType]):
197
197
  @property
198
198
  def state(self) -> AgentState:
199
199
  """Current lifecycle state of the agent."""
200
- return self._state
200
+ return self._lifecycle
201
201
 
202
202
  def stop(self) -> None:
203
203
  """Request graceful shutdown after the current iteration."""
@@ -264,16 +264,16 @@ class AsyncAgent(Generic[DepsType]):
264
264
  Creates a fresh conversation, sends the prompt, handles tool calls,
265
265
  and optionally parses the final response into ``output_type``.
266
266
  """
267
- self._state = AgentState.running
267
+ self._lifecycle = AgentState.running
268
268
  self._stop_requested = False
269
269
  steps: list[AgentStep] = []
270
270
 
271
271
  try:
272
272
  result = await self._execute(prompt, steps, deps)
273
- self._state = AgentState.idle
273
+ self._lifecycle = AgentState.idle
274
274
  return result
275
275
  except Exception:
276
- self._state = AgentState.errored
276
+ self._lifecycle = AgentState.errored
277
277
  raise
278
278
 
279
279
  async def iter(self, prompt: str, *, deps: Any = None) -> AsyncAgentIterator:
@@ -714,7 +714,7 @@ class AsyncAgent(Generic[DepsType]):
714
714
 
715
715
  async def _execute_iter(self, prompt: str, deps: Any) -> AsyncGenerator[AgentStep, None]:
716
716
  """Async generator that executes the agent loop and yields each step."""
717
- self._state = AgentState.running
717
+ self._lifecycle = AgentState.running
718
718
  self._stop_requested = False
719
719
  steps: list[AgentStep] = []
720
720
 
@@ -722,11 +722,11 @@ class AsyncAgent(Generic[DepsType]):
722
722
  result = await self._execute(prompt, steps, deps)
723
723
  for step in result.steps:
724
724
  yield step
725
- self._state = AgentState.idle
725
+ self._lifecycle = AgentState.idle
726
726
  # Store result on the generator for retrieval
727
727
  self._last_iter_result = result
728
728
  except Exception:
729
- self._state = AgentState.errored
729
+ self._lifecycle = AgentState.errored
730
730
  raise
731
731
 
732
732
  # ------------------------------------------------------------------
@@ -735,7 +735,7 @@ class AsyncAgent(Generic[DepsType]):
735
735
 
736
736
  async def _execute_stream(self, prompt: str, deps: Any) -> AsyncGenerator[StreamEvent, None]:
737
737
  """Async generator that executes the agent loop and yields stream events."""
738
- self._state = AgentState.running
738
+ self._lifecycle = AgentState.running
739
739
  self._stop_requested = False
740
740
  steps: list[AgentStep] = []
741
741
 
@@ -803,10 +803,10 @@ class AsyncAgent(Generic[DepsType]):
803
803
 
804
804
  yield StreamEvent(event_type=StreamEventType.output, data=result)
805
805
 
806
- self._state = AgentState.idle
806
+ self._lifecycle = AgentState.idle
807
807
  self._last_stream_result = result
808
808
  except Exception:
809
- self._state = AgentState.errored
809
+ self._lifecycle = AgentState.errored
810
810
  raise
811
811
 
812
812
 
@@ -304,6 +304,15 @@ class AsyncConversation:
304
304
  self._usage["turns"] += 1
305
305
  self._maybe_auto_save()
306
306
 
307
+ from .ledger import _resolve_api_key_hash, record_model_usage
308
+
309
+ record_model_usage(
310
+ self._model_name,
311
+ api_key_hash=_resolve_api_key_hash(self._model_name),
312
+ tokens=meta.get("total_tokens", 0),
313
+ cost=meta.get("cost", 0.0),
314
+ )
315
+
307
316
  async def ask(
308
317
  self,
309
318
  content: str,
prompture/async_core.py CHANGED
@@ -35,6 +35,18 @@ from .tools import (
35
35
  logger = logging.getLogger("prompture.async_core")
36
36
 
37
37
 
38
+ def _record_usage_to_ledger(model_name: str, meta: dict[str, Any]) -> None:
39
+ """Fire-and-forget ledger recording for standalone async core functions."""
40
+ from .ledger import _resolve_api_key_hash, record_model_usage
41
+
42
+ record_model_usage(
43
+ model_name,
44
+ api_key_hash=_resolve_api_key_hash(model_name),
45
+ tokens=meta.get("total_tokens", 0),
46
+ cost=meta.get("cost", 0.0),
47
+ )
48
+
49
+
38
50
  async def clean_json_text_with_ai(
39
51
  driver: AsyncDriver, text: str, model_name: str = "", options: dict[str, Any] | None = None
40
52
  ) -> str:
@@ -117,6 +129,8 @@ async def render_output(
117
129
  "model_name": model_name or getattr(driver, "model", ""),
118
130
  }
119
131
 
132
+ _record_usage_to_ledger(model_name, resp.get("meta", {}))
133
+
120
134
  return {"text": raw, "usage": usage, "output_format": output_format}
121
135
 
122
136
 
@@ -211,6 +225,8 @@ async def ask_for_json(
211
225
  raw = resp.get("text", "")
212
226
  cleaned = clean_json_text(raw)
213
227
 
228
+ _record_usage_to_ledger(model_name, resp.get("meta", {}))
229
+
214
230
  try:
215
231
  json_obj = json.loads(cleaned)
216
232
  json_string = cleaned
prompture/async_driver.py CHANGED
@@ -166,6 +166,45 @@ class AsyncDriver:
166
166
  except Exception:
167
167
  logger.exception("Callback %s raised an exception", event)
168
168
 
169
+ def _validate_model_capabilities(
170
+ self,
171
+ provider: str,
172
+ model: str,
173
+ *,
174
+ using_tool_use: bool = False,
175
+ using_json_schema: bool = False,
176
+ using_vision: bool = False,
177
+ ) -> None:
178
+ """Log warnings when the model may not support a requested feature.
179
+
180
+ Uses models.dev metadata as a secondary signal. Warnings only — the
181
+ API is the final authority and models.dev data may be stale.
182
+ """
183
+ from .model_rates import get_model_capabilities
184
+
185
+ caps = get_model_capabilities(provider, model)
186
+ if caps is None:
187
+ return
188
+
189
+ if using_tool_use and caps.supports_tool_use is False:
190
+ logger.warning(
191
+ "Model %s/%s may not support tool use according to models.dev metadata",
192
+ provider,
193
+ model,
194
+ )
195
+ if using_json_schema and caps.supports_structured_output is False:
196
+ logger.warning(
197
+ "Model %s/%s may not support structured output / JSON schema according to models.dev metadata",
198
+ provider,
199
+ model,
200
+ )
201
+ if using_vision and caps.supports_vision is False:
202
+ logger.warning(
203
+ "Model %s/%s may not support vision/image inputs according to models.dev metadata",
204
+ provider,
205
+ model,
206
+ )
207
+
169
208
  def _check_vision_support(self, messages: list[dict[str, Any]]) -> None:
170
209
  """Raise if messages contain image blocks and the driver lacks vision support."""
171
210
  if self.supports_vision:
prompture/async_groups.py CHANGED
@@ -70,6 +70,27 @@ class ParallelGroup:
70
70
  """Request graceful shutdown."""
71
71
  self._stop_requested = True
72
72
 
73
+ @property
74
+ def shared_state(self) -> dict[str, Any]:
75
+ """Return a copy of the current shared execution state."""
76
+ return dict(self._state)
77
+
78
+ def inject_state(self, state: dict[str, Any], *, recursive: bool = False) -> None:
79
+ """Merge external key-value pairs into this group's shared state.
80
+
81
+ Existing keys are NOT overwritten (uses setdefault semantics).
82
+
83
+ Args:
84
+ state: Key-value pairs to inject.
85
+ recursive: If True, also inject into nested sub-groups.
86
+ """
87
+ for k, v in state.items():
88
+ self._state.setdefault(k, v)
89
+ if recursive:
90
+ for agent, _ in self._agents:
91
+ if hasattr(agent, "inject_state"):
92
+ agent.inject_state(state, recursive=True)
93
+
73
94
  async def run_async(self, prompt: str = "") -> GroupResult:
74
95
  """Execute all agents concurrently."""
75
96
  self._stop_requested = False
@@ -213,6 +234,27 @@ class AsyncSequentialGroup:
213
234
  def stop(self) -> None:
214
235
  self._stop_requested = True
215
236
 
237
+ @property
238
+ def shared_state(self) -> dict[str, Any]:
239
+ """Return a copy of the current shared execution state."""
240
+ return dict(self._state)
241
+
242
+ def inject_state(self, state: dict[str, Any], *, recursive: bool = False) -> None:
243
+ """Merge external key-value pairs into this group's shared state.
244
+
245
+ Existing keys are NOT overwritten (uses setdefault semantics).
246
+
247
+ Args:
248
+ state: Key-value pairs to inject.
249
+ recursive: If True, also inject into nested sub-groups.
250
+ """
251
+ for k, v in state.items():
252
+ self._state.setdefault(k, v)
253
+ if recursive:
254
+ for agent, _ in self._agents:
255
+ if hasattr(agent, "inject_state"):
256
+ agent.inject_state(state, recursive=True)
257
+
216
258
  async def run(self, prompt: str = "") -> GroupResult:
217
259
  """Execute all agents in sequence (async)."""
218
260
  self._stop_requested = False
@@ -351,6 +393,27 @@ class AsyncLoopGroup:
351
393
  def stop(self) -> None:
352
394
  self._stop_requested = True
353
395
 
396
+ @property
397
+ def shared_state(self) -> dict[str, Any]:
398
+ """Return a copy of the current shared execution state."""
399
+ return dict(self._state)
400
+
401
+ def inject_state(self, state: dict[str, Any], *, recursive: bool = False) -> None:
402
+ """Merge external key-value pairs into this group's shared state.
403
+
404
+ Existing keys are NOT overwritten (uses setdefault semantics).
405
+
406
+ Args:
407
+ state: Key-value pairs to inject.
408
+ recursive: If True, also inject into nested sub-groups.
409
+ """
410
+ for k, v in state.items():
411
+ self._state.setdefault(k, v)
412
+ if recursive:
413
+ for agent, _ in self._agents:
414
+ if hasattr(agent, "inject_state"):
415
+ agent.inject_state(state, recursive=True)
416
+
354
417
  async def run(self, prompt: str = "") -> GroupResult:
355
418
  """Execute the loop (async)."""
356
419
  self._stop_requested = False
prompture/conversation.py CHANGED
@@ -311,6 +311,15 @@ class Conversation:
311
311
  self._usage["turns"] += 1
312
312
  self._maybe_auto_save()
313
313
 
314
+ from .ledger import _resolve_api_key_hash, record_model_usage
315
+
316
+ record_model_usage(
317
+ self._model_name,
318
+ api_key_hash=_resolve_api_key_hash(self._model_name),
319
+ tokens=meta.get("total_tokens", 0),
320
+ cost=meta.get("cost", 0.0),
321
+ )
322
+
314
323
  def ask(
315
324
  self,
316
325
  content: str,
prompture/core.py CHANGED
@@ -31,6 +31,18 @@ from .tools import (
31
31
  logger = logging.getLogger("prompture.core")
32
32
 
33
33
 
34
+ def _record_usage_to_ledger(model_name: str, meta: dict[str, Any]) -> None:
35
+ """Fire-and-forget ledger recording for standalone core functions."""
36
+ from .ledger import _resolve_api_key_hash, record_model_usage
37
+
38
+ record_model_usage(
39
+ model_name,
40
+ api_key_hash=_resolve_api_key_hash(model_name),
41
+ tokens=meta.get("total_tokens", 0),
42
+ cost=meta.get("cost", 0.0),
43
+ )
44
+
45
+
34
46
  def _build_content_with_images(text: str, images: list[ImageInput] | None = None) -> str | list[dict[str, Any]]:
35
47
  """Return plain string when no images, or a list of content blocks."""
36
48
  if not images:
@@ -231,6 +243,8 @@ def render_output(
231
243
  "model_name": model_name or getattr(driver, "model", ""),
232
244
  }
233
245
 
246
+ _record_usage_to_ledger(model_name, resp.get("meta", {}))
247
+
234
248
  return {"text": raw, "usage": usage, "output_format": output_format}
235
249
 
236
250
 
@@ -353,6 +367,8 @@ def ask_for_json(
353
367
  raw = resp.get("text", "")
354
368
  cleaned = clean_json_text(raw)
355
369
 
370
+ _record_usage_to_ledger(model_name, resp.get("meta", {}))
371
+
356
372
  try:
357
373
  json_obj = json.loads(cleaned)
358
374
  json_string = cleaned
prompture/cost_mixin.py CHANGED
@@ -2,9 +2,34 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import copy
5
6
  from typing import Any
6
7
 
7
8
 
9
+ def prepare_strict_schema(schema: dict[str, Any]) -> dict[str, Any]:
10
+ """Prepare a JSON schema for OpenAI strict structured-output mode.
11
+
12
+ OpenAI's ``strict: true`` requires every object to have
13
+ ``"additionalProperties": false`` and a ``"required"`` array listing
14
+ all property keys. This function recursively patches a schema copy
15
+ so callers don't need to worry about these constraints.
16
+ """
17
+ schema = copy.deepcopy(schema)
18
+ _patch_strict(schema)
19
+ return schema
20
+
21
+
22
+ def _patch_strict(node: dict[str, Any]) -> None:
23
+ """Recursively add strict-mode constraints to an object schema node."""
24
+ if node.get("type") == "object" and "properties" in node:
25
+ node.setdefault("additionalProperties", False)
26
+ node.setdefault("required", list(node["properties"].keys()))
27
+ for prop in node["properties"].values():
28
+ _patch_strict(prop)
29
+ elif node.get("type") == "array" and isinstance(node.get("items"), dict):
30
+ _patch_strict(node["items"])
31
+
32
+
8
33
  class CostMixin:
9
34
  """Mixin that provides ``_calculate_cost`` to sync and async drivers.
10
35
 
@@ -49,3 +74,40 @@ class CostMixin:
49
74
  completion_cost = (completion_tokens / unit) * model_pricing["completion"]
50
75
 
51
76
  return round(prompt_cost + completion_cost, 6)
77
+
78
+ def _get_model_config(self, provider: str, model: str) -> dict[str, Any]:
79
+ """Merge live models.dev capabilities with hardcoded ``MODEL_PRICING``.
80
+
81
+ Returns a dict with:
82
+ - ``tokens_param`` — always from hardcoded ``MODEL_PRICING`` (API-specific)
83
+ - ``supports_temperature`` — prefers live data, falls back to hardcoded, default ``True``
84
+ - ``context_window`` — from live data only (``None`` if unavailable)
85
+ - ``max_output_tokens`` — from live data only (``None`` if unavailable)
86
+ """
87
+ from .model_rates import get_model_capabilities
88
+
89
+ hardcoded = self.MODEL_PRICING.get(model, {})
90
+
91
+ # tokens_param is always from hardcoded config (API-specific, not in models.dev)
92
+ tokens_param = hardcoded.get("tokens_param", "max_tokens")
93
+
94
+ # Start with hardcoded supports_temperature, default True
95
+ supports_temperature = hardcoded.get("supports_temperature", True)
96
+
97
+ context_window: int | None = None
98
+ max_output_tokens: int | None = None
99
+
100
+ # Override with live data when available
101
+ caps = get_model_capabilities(provider, model)
102
+ if caps is not None:
103
+ if caps.supports_temperature is not None:
104
+ supports_temperature = caps.supports_temperature
105
+ context_window = caps.context_window
106
+ max_output_tokens = caps.max_output_tokens
107
+
108
+ return {
109
+ "tokens_param": tokens_param,
110
+ "supports_temperature": supports_temperature,
111
+ "context_window": context_window,
112
+ "max_output_tokens": max_output_tokens,
113
+ }