prompture 0.0.35__py3-none-any.whl → 0.0.40.dev1__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 +132 -3
- prompture/_version.py +2 -2
- prompture/agent.py +924 -0
- prompture/agent_types.py +156 -0
- prompture/async_agent.py +880 -0
- prompture/async_conversation.py +208 -17
- prompture/async_core.py +16 -0
- prompture/async_driver.py +63 -0
- prompture/async_groups.py +551 -0
- prompture/conversation.py +222 -18
- prompture/core.py +46 -12
- prompture/cost_mixin.py +37 -0
- prompture/discovery.py +132 -44
- prompture/driver.py +77 -0
- prompture/drivers/__init__.py +5 -1
- prompture/drivers/async_azure_driver.py +11 -5
- prompture/drivers/async_claude_driver.py +184 -9
- prompture/drivers/async_google_driver.py +222 -28
- prompture/drivers/async_grok_driver.py +11 -5
- prompture/drivers/async_groq_driver.py +11 -5
- prompture/drivers/async_lmstudio_driver.py +74 -5
- prompture/drivers/async_ollama_driver.py +13 -3
- prompture/drivers/async_openai_driver.py +162 -5
- prompture/drivers/async_openrouter_driver.py +11 -5
- prompture/drivers/async_registry.py +5 -1
- prompture/drivers/azure_driver.py +10 -4
- prompture/drivers/claude_driver.py +17 -1
- prompture/drivers/google_driver.py +227 -33
- prompture/drivers/grok_driver.py +11 -5
- prompture/drivers/groq_driver.py +11 -5
- prompture/drivers/lmstudio_driver.py +73 -8
- prompture/drivers/ollama_driver.py +16 -5
- prompture/drivers/openai_driver.py +26 -11
- prompture/drivers/openrouter_driver.py +11 -5
- prompture/drivers/vision_helpers.py +153 -0
- prompture/group_types.py +147 -0
- prompture/groups.py +530 -0
- prompture/image.py +180 -0
- prompture/ledger.py +252 -0
- prompture/model_rates.py +112 -2
- prompture/persistence.py +254 -0
- prompture/persona.py +482 -0
- prompture/serialization.py +218 -0
- prompture/settings.py +1 -0
- prompture-0.0.40.dev1.dist-info/METADATA +369 -0
- prompture-0.0.40.dev1.dist-info/RECORD +78 -0
- prompture-0.0.35.dist-info/METADATA +0 -464
- prompture-0.0.35.dist-info/RECORD +0 -66
- {prompture-0.0.35.dist-info → prompture-0.0.40.dev1.dist-info}/WHEEL +0 -0
- {prompture-0.0.35.dist-info → prompture-0.0.40.dev1.dist-info}/entry_points.txt +0 -0
- {prompture-0.0.35.dist-info → prompture-0.0.40.dev1.dist-info}/licenses/LICENSE +0 -0
- {prompture-0.0.35.dist-info → prompture-0.0.40.dev1.dist-info}/top_level.txt +0 -0
prompture/conversation.py
CHANGED
|
@@ -4,9 +4,11 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
|
+
import uuid
|
|
7
8
|
from collections.abc import Iterator
|
|
8
|
-
from datetime import date, datetime
|
|
9
|
+
from datetime import date, datetime, timezone
|
|
9
10
|
from decimal import Decimal
|
|
11
|
+
from pathlib import Path
|
|
10
12
|
from typing import Any, Callable, Literal, Union
|
|
11
13
|
|
|
12
14
|
from pydantic import BaseModel
|
|
@@ -15,6 +17,11 @@ from .callbacks import DriverCallbacks
|
|
|
15
17
|
from .driver import Driver
|
|
16
18
|
from .drivers import get_driver_for_model
|
|
17
19
|
from .field_definitions import get_registry_snapshot
|
|
20
|
+
from .image import ImageInput, make_image
|
|
21
|
+
from .persistence import load_from_file, save_to_file
|
|
22
|
+
from .persona import Persona, get_persona
|
|
23
|
+
from .serialization import export_conversation, import_conversation
|
|
24
|
+
from .session import UsageSession
|
|
18
25
|
from .tools import (
|
|
19
26
|
clean_json_text,
|
|
20
27
|
convert_value,
|
|
@@ -44,13 +51,34 @@ class Conversation:
|
|
|
44
51
|
*,
|
|
45
52
|
driver: Driver | None = None,
|
|
46
53
|
system_prompt: str | None = None,
|
|
54
|
+
persona: str | Persona | None = None,
|
|
47
55
|
options: dict[str, Any] | None = None,
|
|
48
56
|
callbacks: DriverCallbacks | None = None,
|
|
49
57
|
tools: ToolRegistry | None = None,
|
|
50
58
|
max_tool_rounds: int = 10,
|
|
59
|
+
conversation_id: str | None = None,
|
|
60
|
+
auto_save: str | Path | None = None,
|
|
61
|
+
tags: list[str] | None = None,
|
|
51
62
|
) -> None:
|
|
63
|
+
if system_prompt is not None and persona is not None:
|
|
64
|
+
raise ValueError("Cannot provide both 'system_prompt' and 'persona'. Use one or the other.")
|
|
65
|
+
|
|
66
|
+
# Resolve persona
|
|
67
|
+
resolved_persona: Persona | None = None
|
|
68
|
+
if persona is not None:
|
|
69
|
+
if isinstance(persona, str):
|
|
70
|
+
resolved_persona = get_persona(persona)
|
|
71
|
+
if resolved_persona is None:
|
|
72
|
+
raise ValueError(f"Persona '{persona}' not found in registry.")
|
|
73
|
+
else:
|
|
74
|
+
resolved_persona = persona
|
|
75
|
+
|
|
52
76
|
if model_name is None and driver is None:
|
|
53
|
-
|
|
77
|
+
# Check persona for model_hint
|
|
78
|
+
if resolved_persona is not None and resolved_persona.model_hint:
|
|
79
|
+
model_name = resolved_persona.model_hint
|
|
80
|
+
else:
|
|
81
|
+
raise ValueError("Either model_name or driver must be provided")
|
|
54
82
|
|
|
55
83
|
if driver is not None:
|
|
56
84
|
self._driver = driver
|
|
@@ -61,8 +89,16 @@ class Conversation:
|
|
|
61
89
|
self._driver.callbacks = callbacks
|
|
62
90
|
|
|
63
91
|
self._model_name = model_name or ""
|
|
64
|
-
|
|
65
|
-
|
|
92
|
+
|
|
93
|
+
# Apply persona: render system_prompt and merge settings
|
|
94
|
+
if resolved_persona is not None:
|
|
95
|
+
self._system_prompt = resolved_persona.render()
|
|
96
|
+
# Persona settings as defaults, explicit options override
|
|
97
|
+
self._options = {**resolved_persona.settings, **(dict(options) if options else {})}
|
|
98
|
+
else:
|
|
99
|
+
self._system_prompt = system_prompt
|
|
100
|
+
self._options = dict(options) if options else {}
|
|
101
|
+
|
|
66
102
|
self._messages: list[dict[str, Any]] = []
|
|
67
103
|
self._usage = {
|
|
68
104
|
"prompt_tokens": 0,
|
|
@@ -74,6 +110,14 @@ class Conversation:
|
|
|
74
110
|
self._tools = tools or ToolRegistry()
|
|
75
111
|
self._max_tool_rounds = max_tool_rounds
|
|
76
112
|
|
|
113
|
+
# Persistence
|
|
114
|
+
self._conversation_id = conversation_id or str(uuid.uuid4())
|
|
115
|
+
self._auto_save = Path(auto_save) if auto_save else None
|
|
116
|
+
self._metadata: dict[str, Any] = {
|
|
117
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
118
|
+
"tags": list(tags) if tags else [],
|
|
119
|
+
}
|
|
120
|
+
|
|
77
121
|
# ------------------------------------------------------------------
|
|
78
122
|
# Public helpers
|
|
79
123
|
# ------------------------------------------------------------------
|
|
@@ -92,11 +136,12 @@ class Conversation:
|
|
|
92
136
|
"""Reset message history (keeps system_prompt and driver)."""
|
|
93
137
|
self._messages.clear()
|
|
94
138
|
|
|
95
|
-
def add_context(self, role: str, content: str) -> None:
|
|
139
|
+
def add_context(self, role: str, content: str, images: list[ImageInput] | None = None) -> None:
|
|
96
140
|
"""Seed the history with a user or assistant message."""
|
|
97
141
|
if role not in ("user", "assistant"):
|
|
98
142
|
raise ValueError("role must be 'user' or 'assistant'")
|
|
99
|
-
self.
|
|
143
|
+
msg_content = self._build_content_with_images(content, images)
|
|
144
|
+
self._messages.append({"role": role, "content": msg_content})
|
|
100
145
|
|
|
101
146
|
def register_tool(
|
|
102
147
|
self,
|
|
@@ -113,17 +158,149 @@ class Conversation:
|
|
|
113
158
|
u = self._usage
|
|
114
159
|
return f"Conversation: {u['total_tokens']:,} tokens across {u['turns']} turn(s) costing ${u['cost']:.4f}"
|
|
115
160
|
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
# Persistence properties
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def conversation_id(self) -> str:
|
|
167
|
+
"""Unique identifier for this conversation."""
|
|
168
|
+
return self._conversation_id
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def tags(self) -> list[str]:
|
|
172
|
+
"""Tags attached to this conversation."""
|
|
173
|
+
return self._metadata.get("tags", [])
|
|
174
|
+
|
|
175
|
+
@tags.setter
|
|
176
|
+
def tags(self, value: list[str]) -> None:
|
|
177
|
+
self._metadata["tags"] = list(value)
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
# Export / Import
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def export(self, *, usage_session: UsageSession | None = None, strip_images: bool = False) -> dict[str, Any]:
|
|
184
|
+
"""Export conversation state to a JSON-serializable dict."""
|
|
185
|
+
tools_metadata = (
|
|
186
|
+
[
|
|
187
|
+
{"name": td.name, "description": td.description, "parameters": td.parameters}
|
|
188
|
+
for td in self._tools.definitions
|
|
189
|
+
]
|
|
190
|
+
if self._tools and self._tools.definitions
|
|
191
|
+
else None
|
|
192
|
+
)
|
|
193
|
+
return export_conversation(
|
|
194
|
+
model_name=self._model_name,
|
|
195
|
+
system_prompt=self._system_prompt,
|
|
196
|
+
options=self._options,
|
|
197
|
+
messages=self._messages,
|
|
198
|
+
usage=self._usage,
|
|
199
|
+
max_tool_rounds=self._max_tool_rounds,
|
|
200
|
+
tools_metadata=tools_metadata,
|
|
201
|
+
usage_session=usage_session,
|
|
202
|
+
metadata=self._metadata,
|
|
203
|
+
conversation_id=self._conversation_id,
|
|
204
|
+
strip_images=strip_images,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
@classmethod
|
|
208
|
+
def from_export(
|
|
209
|
+
cls,
|
|
210
|
+
data: dict[str, Any],
|
|
211
|
+
*,
|
|
212
|
+
callbacks: DriverCallbacks | None = None,
|
|
213
|
+
tools: ToolRegistry | None = None,
|
|
214
|
+
) -> Conversation:
|
|
215
|
+
"""Reconstruct a :class:`Conversation` from an export dict.
|
|
216
|
+
|
|
217
|
+
The driver is reconstructed from the stored ``model_name``.
|
|
218
|
+
Callbacks and tool *functions* must be re-attached by the caller
|
|
219
|
+
(tool metadata — name/description/parameters — is preserved in
|
|
220
|
+
the export but executable functions cannot be serialized).
|
|
221
|
+
"""
|
|
222
|
+
imported = import_conversation(data)
|
|
223
|
+
|
|
224
|
+
model_name = imported.get("model_name") or ""
|
|
225
|
+
if not model_name:
|
|
226
|
+
raise ValueError("Cannot restore conversation: export has no model_name")
|
|
227
|
+
conv = cls(
|
|
228
|
+
model_name=model_name,
|
|
229
|
+
system_prompt=imported.get("system_prompt"),
|
|
230
|
+
options=imported.get("options", {}),
|
|
231
|
+
callbacks=callbacks,
|
|
232
|
+
tools=tools,
|
|
233
|
+
max_tool_rounds=imported.get("max_tool_rounds", 10),
|
|
234
|
+
conversation_id=imported.get("conversation_id"),
|
|
235
|
+
tags=imported.get("metadata", {}).get("tags", []),
|
|
236
|
+
)
|
|
237
|
+
conv._messages = imported.get("messages", [])
|
|
238
|
+
conv._usage = imported.get(
|
|
239
|
+
"usage",
|
|
240
|
+
{
|
|
241
|
+
"prompt_tokens": 0,
|
|
242
|
+
"completion_tokens": 0,
|
|
243
|
+
"total_tokens": 0,
|
|
244
|
+
"cost": 0.0,
|
|
245
|
+
"turns": 0,
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
meta = imported.get("metadata", {})
|
|
249
|
+
if "created_at" in meta:
|
|
250
|
+
conv._metadata["created_at"] = meta["created_at"]
|
|
251
|
+
return conv
|
|
252
|
+
|
|
253
|
+
def save(self, path: str | Path, **kwargs: Any) -> None:
|
|
254
|
+
"""Export and write to a JSON file.
|
|
255
|
+
|
|
256
|
+
Keyword arguments are forwarded to :meth:`export`.
|
|
257
|
+
"""
|
|
258
|
+
save_to_file(self.export(**kwargs), path)
|
|
259
|
+
|
|
260
|
+
@classmethod
|
|
261
|
+
def load(
|
|
262
|
+
cls,
|
|
263
|
+
path: str | Path,
|
|
264
|
+
*,
|
|
265
|
+
callbacks: DriverCallbacks | None = None,
|
|
266
|
+
tools: ToolRegistry | None = None,
|
|
267
|
+
) -> Conversation:
|
|
268
|
+
"""Load a conversation from a JSON file."""
|
|
269
|
+
data = load_from_file(path)
|
|
270
|
+
return cls.from_export(data, callbacks=callbacks, tools=tools)
|
|
271
|
+
|
|
272
|
+
def _maybe_auto_save(self) -> None:
|
|
273
|
+
"""Auto-save after each turn if configured. Errors are silently logged."""
|
|
274
|
+
if self._auto_save is None:
|
|
275
|
+
return
|
|
276
|
+
try:
|
|
277
|
+
self.save(self._auto_save)
|
|
278
|
+
except Exception:
|
|
279
|
+
logger.debug("Auto-save failed for conversation %s", self._conversation_id, exc_info=True)
|
|
280
|
+
|
|
116
281
|
# ------------------------------------------------------------------
|
|
117
282
|
# Core methods
|
|
118
283
|
# ------------------------------------------------------------------
|
|
119
284
|
|
|
120
|
-
|
|
285
|
+
@staticmethod
|
|
286
|
+
def _build_content_with_images(text: str, images: list[ImageInput] | None = None) -> str | list[dict[str, Any]]:
|
|
287
|
+
"""Return plain string when no images, or a list of content blocks."""
|
|
288
|
+
if not images:
|
|
289
|
+
return text
|
|
290
|
+
blocks: list[dict[str, Any]] = [{"type": "text", "text": text}]
|
|
291
|
+
for img in images:
|
|
292
|
+
ic = make_image(img)
|
|
293
|
+
blocks.append({"type": "image", "source": ic})
|
|
294
|
+
return blocks
|
|
295
|
+
|
|
296
|
+
def _build_messages(self, user_content: str, images: list[ImageInput] | None = None) -> list[dict[str, Any]]:
|
|
121
297
|
"""Build the full messages array for an API call."""
|
|
122
298
|
msgs: list[dict[str, Any]] = []
|
|
123
299
|
if self._system_prompt:
|
|
124
300
|
msgs.append({"role": "system", "content": self._system_prompt})
|
|
125
301
|
msgs.extend(self._messages)
|
|
126
|
-
|
|
302
|
+
content = self._build_content_with_images(user_content, images)
|
|
303
|
+
msgs.append({"role": "user", "content": content})
|
|
127
304
|
return msgs
|
|
128
305
|
|
|
129
306
|
def _accumulate_usage(self, meta: dict[str, Any]) -> None:
|
|
@@ -132,30 +309,48 @@ class Conversation:
|
|
|
132
309
|
self._usage["total_tokens"] += meta.get("total_tokens", 0)
|
|
133
310
|
self._usage["cost"] += meta.get("cost", 0.0)
|
|
134
311
|
self._usage["turns"] += 1
|
|
312
|
+
self._maybe_auto_save()
|
|
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
|
+
)
|
|
135
322
|
|
|
136
323
|
def ask(
|
|
137
324
|
self,
|
|
138
325
|
content: str,
|
|
139
326
|
options: dict[str, Any] | None = None,
|
|
327
|
+
images: list[ImageInput] | None = None,
|
|
140
328
|
) -> str:
|
|
141
329
|
"""Send a message and get a raw text response.
|
|
142
330
|
|
|
143
331
|
Appends the user message and assistant response to history.
|
|
144
332
|
If tools are registered and the driver supports tool use,
|
|
145
333
|
dispatches to the tool execution loop.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
content: The text message to send.
|
|
337
|
+
options: Additional options for the driver.
|
|
338
|
+
images: Optional list of images to include (bytes, path, URL,
|
|
339
|
+
base64 string, or :class:`ImageContent`).
|
|
146
340
|
"""
|
|
147
341
|
if self._tools and getattr(self._driver, "supports_tool_use", False):
|
|
148
|
-
return self._ask_with_tools(content, options)
|
|
342
|
+
return self._ask_with_tools(content, options, images=images)
|
|
149
343
|
|
|
150
344
|
merged = {**self._options, **(options or {})}
|
|
151
|
-
messages = self._build_messages(content)
|
|
345
|
+
messages = self._build_messages(content, images=images)
|
|
152
346
|
resp = self._driver.generate_messages_with_hooks(messages, merged)
|
|
153
347
|
|
|
154
348
|
text = resp.get("text", "")
|
|
155
349
|
meta = resp.get("meta", {})
|
|
156
350
|
|
|
157
|
-
# Record in history
|
|
158
|
-
self.
|
|
351
|
+
# Record in history — store content with images for context
|
|
352
|
+
user_content = self._build_content_with_images(content, images)
|
|
353
|
+
self._messages.append({"role": "user", "content": user_content})
|
|
159
354
|
self._messages.append({"role": "assistant", "content": text})
|
|
160
355
|
self._accumulate_usage(meta)
|
|
161
356
|
|
|
@@ -165,13 +360,15 @@ class Conversation:
|
|
|
165
360
|
self,
|
|
166
361
|
content: str,
|
|
167
362
|
options: dict[str, Any] | None = None,
|
|
363
|
+
images: list[ImageInput] | None = None,
|
|
168
364
|
) -> str:
|
|
169
365
|
"""Execute the tool-use loop: send -> check tool_calls -> execute -> re-send."""
|
|
170
366
|
merged = {**self._options, **(options or {})}
|
|
171
367
|
tool_defs = self._tools.to_openai_format()
|
|
172
368
|
|
|
173
369
|
# Build messages including user content
|
|
174
|
-
self.
|
|
370
|
+
user_content = self._build_content_with_images(content, images)
|
|
371
|
+
self._messages.append({"role": "user", "content": user_content})
|
|
175
372
|
msgs = self._build_messages_raw()
|
|
176
373
|
|
|
177
374
|
for _round in range(self._max_tool_rounds):
|
|
@@ -235,6 +432,7 @@ class Conversation:
|
|
|
235
432
|
self,
|
|
236
433
|
content: str,
|
|
237
434
|
options: dict[str, Any] | None = None,
|
|
435
|
+
images: list[ImageInput] | None = None,
|
|
238
436
|
) -> Iterator[str]:
|
|
239
437
|
"""Send a message and yield text chunks as they arrive.
|
|
240
438
|
|
|
@@ -243,13 +441,14 @@ class Conversation:
|
|
|
243
441
|
is recorded in history.
|
|
244
442
|
"""
|
|
245
443
|
if not getattr(self._driver, "supports_streaming", False):
|
|
246
|
-
yield self.ask(content, options)
|
|
444
|
+
yield self.ask(content, options, images=images)
|
|
247
445
|
return
|
|
248
446
|
|
|
249
447
|
merged = {**self._options, **(options or {})}
|
|
250
|
-
messages = self._build_messages(content)
|
|
448
|
+
messages = self._build_messages(content, images=images)
|
|
251
449
|
|
|
252
|
-
self.
|
|
450
|
+
user_content = self._build_content_with_images(content, images)
|
|
451
|
+
self._messages.append({"role": "user", "content": user_content})
|
|
253
452
|
|
|
254
453
|
full_text = ""
|
|
255
454
|
for chunk in self._driver.generate_messages_stream(messages, merged):
|
|
@@ -276,6 +475,7 @@ class Conversation:
|
|
|
276
475
|
options: dict[str, Any] | None = None,
|
|
277
476
|
output_format: Literal["json", "toon"] = "json",
|
|
278
477
|
json_mode: Literal["auto", "on", "off"] = "auto",
|
|
478
|
+
images: list[ImageInput] | None = None,
|
|
279
479
|
) -> dict[str, Any]:
|
|
280
480
|
"""Send a message with schema enforcement and get structured JSON back.
|
|
281
481
|
|
|
@@ -320,14 +520,16 @@ class Conversation:
|
|
|
320
520
|
|
|
321
521
|
full_user_content = f"{content}\n\n{instruct}"
|
|
322
522
|
|
|
323
|
-
messages = self._build_messages(full_user_content)
|
|
523
|
+
messages = self._build_messages(full_user_content, images=images)
|
|
324
524
|
resp = self._driver.generate_messages_with_hooks(messages, merged)
|
|
325
525
|
|
|
326
526
|
text = resp.get("text", "")
|
|
327
527
|
meta = resp.get("meta", {})
|
|
328
528
|
|
|
329
529
|
# Store original content (without schema boilerplate) for cleaner context
|
|
330
|
-
|
|
530
|
+
# Include images in history so subsequent turns can reference them
|
|
531
|
+
user_content = self._build_content_with_images(content, images)
|
|
532
|
+
self._messages.append({"role": "user", "content": user_content})
|
|
331
533
|
|
|
332
534
|
# Parse JSON
|
|
333
535
|
cleaned = clean_json_text(text)
|
|
@@ -383,6 +585,7 @@ class Conversation:
|
|
|
383
585
|
output_format: Literal["json", "toon"] = "json",
|
|
384
586
|
options: dict[str, Any] | None = None,
|
|
385
587
|
json_mode: Literal["auto", "on", "off"] = "auto",
|
|
588
|
+
images: list[ImageInput] | None = None,
|
|
386
589
|
) -> dict[str, Any]:
|
|
387
590
|
"""Extract structured information into a Pydantic model with conversation context."""
|
|
388
591
|
from .core import normalize_field_value
|
|
@@ -397,6 +600,7 @@ class Conversation:
|
|
|
397
600
|
options=options,
|
|
398
601
|
output_format=output_format,
|
|
399
602
|
json_mode=json_mode,
|
|
603
|
+
images=images,
|
|
400
604
|
)
|
|
401
605
|
|
|
402
606
|
# Normalize field values
|
prompture/core.py
CHANGED
|
@@ -21,6 +21,7 @@ from pydantic import BaseModel
|
|
|
21
21
|
from .driver import Driver
|
|
22
22
|
from .drivers import get_driver_for_model
|
|
23
23
|
from .field_definitions import get_registry_snapshot
|
|
24
|
+
from .image import ImageInput, make_image
|
|
24
25
|
from .tools import (
|
|
25
26
|
clean_json_text,
|
|
26
27
|
convert_value,
|
|
@@ -30,6 +31,29 @@ from .tools import (
|
|
|
30
31
|
logger = logging.getLogger("prompture.core")
|
|
31
32
|
|
|
32
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
|
+
|
|
46
|
+
def _build_content_with_images(text: str, images: list[ImageInput] | None = None) -> str | list[dict[str, Any]]:
|
|
47
|
+
"""Return plain string when no images, or a list of content blocks."""
|
|
48
|
+
if not images:
|
|
49
|
+
return text
|
|
50
|
+
blocks: list[dict[str, Any]] = [{"type": "text", "text": text}]
|
|
51
|
+
for img in images:
|
|
52
|
+
ic = make_image(img)
|
|
53
|
+
blocks.append({"type": "image", "source": ic})
|
|
54
|
+
return blocks
|
|
55
|
+
|
|
56
|
+
|
|
33
57
|
def normalize_field_value(value: Any, field_type: type, field_def: dict[str, Any]) -> Any:
|
|
34
58
|
"""Normalize invalid values for fields based on their type and nullable status.
|
|
35
59
|
|
|
@@ -142,6 +166,7 @@ def render_output(
|
|
|
142
166
|
model_name: str = "",
|
|
143
167
|
options: dict[str, Any] | None = None,
|
|
144
168
|
system_prompt: str | None = None,
|
|
169
|
+
images: list[ImageInput] | None = None,
|
|
145
170
|
) -> dict[str, Any]:
|
|
146
171
|
"""Sends a prompt to the driver and returns the raw output in the requested format.
|
|
147
172
|
|
|
@@ -186,12 +211,12 @@ def render_output(
|
|
|
186
211
|
|
|
187
212
|
full_prompt = f"{content_prompt}\n\nSYSTEM INSTRUCTION: {instruct}"
|
|
188
213
|
|
|
189
|
-
# Use generate_messages when system_prompt
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
214
|
+
# Use generate_messages when system_prompt or images are provided
|
|
215
|
+
user_content = _build_content_with_images(full_prompt, images)
|
|
216
|
+
if system_prompt is not None or images:
|
|
217
|
+
messages = [{"role": "user", "content": user_content}]
|
|
218
|
+
if system_prompt is not None:
|
|
219
|
+
messages.insert(0, {"role": "system", "content": system_prompt})
|
|
195
220
|
resp = driver.generate_messages(messages, options)
|
|
196
221
|
else:
|
|
197
222
|
resp = driver.generate(full_prompt, options)
|
|
@@ -218,6 +243,8 @@ def render_output(
|
|
|
218
243
|
"model_name": model_name or getattr(driver, "model", ""),
|
|
219
244
|
}
|
|
220
245
|
|
|
246
|
+
_record_usage_to_ledger(model_name, resp.get("meta", {}))
|
|
247
|
+
|
|
221
248
|
return {"text": raw, "usage": usage, "output_format": output_format}
|
|
222
249
|
|
|
223
250
|
|
|
@@ -232,6 +259,7 @@ def ask_for_json(
|
|
|
232
259
|
cache: bool | None = None,
|
|
233
260
|
json_mode: Literal["auto", "on", "off"] = "auto",
|
|
234
261
|
system_prompt: str | None = None,
|
|
262
|
+
images: list[ImageInput] | None = None,
|
|
235
263
|
) -> dict[str, Any]:
|
|
236
264
|
"""Sends a prompt to the driver and returns structured output plus usage metadata.
|
|
237
265
|
|
|
@@ -327,18 +355,20 @@ def ask_for_json(
|
|
|
327
355
|
|
|
328
356
|
full_prompt = f"{content_prompt}\n\n{instruct}"
|
|
329
357
|
|
|
330
|
-
# Use generate_messages when system_prompt
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
358
|
+
# Use generate_messages when system_prompt or images are provided
|
|
359
|
+
user_content = _build_content_with_images(full_prompt, images)
|
|
360
|
+
if system_prompt is not None or images:
|
|
361
|
+
messages = [{"role": "user", "content": user_content}]
|
|
362
|
+
if system_prompt is not None:
|
|
363
|
+
messages.insert(0, {"role": "system", "content": system_prompt})
|
|
336
364
|
resp = driver.generate_messages(messages, options)
|
|
337
365
|
else:
|
|
338
366
|
resp = driver.generate(full_prompt, options)
|
|
339
367
|
raw = resp.get("text", "")
|
|
340
368
|
cleaned = clean_json_text(raw)
|
|
341
369
|
|
|
370
|
+
_record_usage_to_ledger(model_name, resp.get("meta", {}))
|
|
371
|
+
|
|
342
372
|
try:
|
|
343
373
|
json_obj = json.loads(cleaned)
|
|
344
374
|
json_string = cleaned
|
|
@@ -411,6 +441,7 @@ def extract_and_jsonify(
|
|
|
411
441
|
options: dict[str, Any] | None = None,
|
|
412
442
|
json_mode: Literal["auto", "on", "off"] = "auto",
|
|
413
443
|
system_prompt: str | None = None,
|
|
444
|
+
images: list[ImageInput] | None = None,
|
|
414
445
|
) -> dict[str, Any]:
|
|
415
446
|
"""Extracts structured information using automatic driver selection based on model name.
|
|
416
447
|
|
|
@@ -497,6 +528,7 @@ def extract_and_jsonify(
|
|
|
497
528
|
output_format=actual_output_format,
|
|
498
529
|
json_mode=json_mode,
|
|
499
530
|
system_prompt=system_prompt,
|
|
531
|
+
images=images,
|
|
500
532
|
)
|
|
501
533
|
except (requests.exceptions.ConnectionError, requests.exceptions.HTTPError) as e:
|
|
502
534
|
if "pytest" in sys.modules:
|
|
@@ -595,6 +627,7 @@ def extract_with_model(
|
|
|
595
627
|
cache: bool | None = None,
|
|
596
628
|
json_mode: Literal["auto", "on", "off"] = "auto",
|
|
597
629
|
system_prompt: str | None = None,
|
|
630
|
+
images: list[ImageInput] | None = None,
|
|
598
631
|
) -> dict[str, Any]:
|
|
599
632
|
"""Extracts structured information into a Pydantic model instance.
|
|
600
633
|
|
|
@@ -684,6 +717,7 @@ def extract_with_model(
|
|
|
684
717
|
options=options,
|
|
685
718
|
json_mode=json_mode,
|
|
686
719
|
system_prompt=system_prompt,
|
|
720
|
+
images=images,
|
|
687
721
|
)
|
|
688
722
|
logger.debug("[extract] Extraction completed successfully")
|
|
689
723
|
|
prompture/cost_mixin.py
CHANGED
|
@@ -49,3 +49,40 @@ class CostMixin:
|
|
|
49
49
|
completion_cost = (completion_tokens / unit) * model_pricing["completion"]
|
|
50
50
|
|
|
51
51
|
return round(prompt_cost + completion_cost, 6)
|
|
52
|
+
|
|
53
|
+
def _get_model_config(self, provider: str, model: str) -> dict[str, Any]:
|
|
54
|
+
"""Merge live models.dev capabilities with hardcoded ``MODEL_PRICING``.
|
|
55
|
+
|
|
56
|
+
Returns a dict with:
|
|
57
|
+
- ``tokens_param`` — always from hardcoded ``MODEL_PRICING`` (API-specific)
|
|
58
|
+
- ``supports_temperature`` — prefers live data, falls back to hardcoded, default ``True``
|
|
59
|
+
- ``context_window`` — from live data only (``None`` if unavailable)
|
|
60
|
+
- ``max_output_tokens`` — from live data only (``None`` if unavailable)
|
|
61
|
+
"""
|
|
62
|
+
from .model_rates import get_model_capabilities
|
|
63
|
+
|
|
64
|
+
hardcoded = self.MODEL_PRICING.get(model, {})
|
|
65
|
+
|
|
66
|
+
# tokens_param is always from hardcoded config (API-specific, not in models.dev)
|
|
67
|
+
tokens_param = hardcoded.get("tokens_param", "max_tokens")
|
|
68
|
+
|
|
69
|
+
# Start with hardcoded supports_temperature, default True
|
|
70
|
+
supports_temperature = hardcoded.get("supports_temperature", True)
|
|
71
|
+
|
|
72
|
+
context_window: int | None = None
|
|
73
|
+
max_output_tokens: int | None = None
|
|
74
|
+
|
|
75
|
+
# Override with live data when available
|
|
76
|
+
caps = get_model_capabilities(provider, model)
|
|
77
|
+
if caps is not None:
|
|
78
|
+
if caps.supports_temperature is not None:
|
|
79
|
+
supports_temperature = caps.supports_temperature
|
|
80
|
+
context_window = caps.context_window
|
|
81
|
+
max_output_tokens = caps.max_output_tokens
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
"tokens_param": tokens_param,
|
|
85
|
+
"supports_temperature": supports_temperature,
|
|
86
|
+
"context_window": context_window,
|
|
87
|
+
"max_output_tokens": max_output_tokens,
|
|
88
|
+
}
|