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.
- prompture/__init__.py +12 -1
- prompture/_version.py +2 -2
- prompture/agent.py +11 -11
- prompture/async_agent.py +11 -11
- prompture/async_conversation.py +9 -0
- prompture/async_core.py +16 -0
- prompture/async_driver.py +39 -0
- prompture/async_groups.py +63 -0
- prompture/conversation.py +9 -0
- prompture/core.py +16 -0
- prompture/cost_mixin.py +62 -0
- prompture/discovery.py +108 -43
- prompture/driver.py +39 -0
- prompture/drivers/__init__.py +39 -0
- prompture/drivers/async_azure_driver.py +7 -6
- prompture/drivers/async_claude_driver.py +177 -8
- prompture/drivers/async_google_driver.py +10 -0
- prompture/drivers/async_grok_driver.py +4 -4
- prompture/drivers/async_groq_driver.py +4 -4
- prompture/drivers/async_modelscope_driver.py +286 -0
- prompture/drivers/async_moonshot_driver.py +312 -0
- prompture/drivers/async_openai_driver.py +158 -6
- prompture/drivers/async_openrouter_driver.py +196 -7
- prompture/drivers/async_registry.py +30 -0
- prompture/drivers/async_zai_driver.py +303 -0
- prompture/drivers/azure_driver.py +6 -5
- prompture/drivers/claude_driver.py +10 -0
- prompture/drivers/google_driver.py +10 -0
- prompture/drivers/grok_driver.py +4 -4
- prompture/drivers/groq_driver.py +4 -4
- prompture/drivers/modelscope_driver.py +303 -0
- prompture/drivers/moonshot_driver.py +342 -0
- prompture/drivers/openai_driver.py +22 -12
- prompture/drivers/openrouter_driver.py +248 -44
- prompture/drivers/zai_driver.py +318 -0
- prompture/groups.py +42 -0
- prompture/ledger.py +252 -0
- prompture/model_rates.py +114 -2
- prompture/settings.py +16 -1
- {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/METADATA +1 -1
- prompture-0.0.42.dist-info/RECORD +84 -0
- prompture-0.0.38.dev2.dist-info/RECORD +0 -77
- {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/WHEEL +0 -0
- {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/entry_points.txt +0 -0
- {prompture-0.0.38.dev2.dist-info → prompture-0.0.42.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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.
|
|
32
|
-
__version_tuple__ = version_tuple = (0, 0,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
274
|
+
self._lifecycle = AgentState.idle
|
|
275
275
|
return result
|
|
276
276
|
except Exception:
|
|
277
|
-
self.
|
|
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.
|
|
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.
|
|
733
|
+
self._lifecycle = AgentState.idle
|
|
734
734
|
return result
|
|
735
735
|
except Exception:
|
|
736
|
-
self.
|
|
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.
|
|
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.
|
|
856
|
+
self._lifecycle = AgentState.idle
|
|
857
857
|
return result
|
|
858
858
|
except Exception:
|
|
859
|
-
self.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
273
|
+
self._lifecycle = AgentState.idle
|
|
274
274
|
return result
|
|
275
275
|
except Exception:
|
|
276
|
-
self.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
806
|
+
self._lifecycle = AgentState.idle
|
|
807
807
|
self._last_stream_result = result
|
|
808
808
|
except Exception:
|
|
809
|
-
self.
|
|
809
|
+
self._lifecycle = AgentState.errored
|
|
810
810
|
raise
|
|
811
811
|
|
|
812
812
|
|
prompture/async_conversation.py
CHANGED
|
@@ -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
|
+
}
|