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.
Files changed (52) hide show
  1. prompture/__init__.py +132 -3
  2. prompture/_version.py +2 -2
  3. prompture/agent.py +924 -0
  4. prompture/agent_types.py +156 -0
  5. prompture/async_agent.py +880 -0
  6. prompture/async_conversation.py +208 -17
  7. prompture/async_core.py +16 -0
  8. prompture/async_driver.py +63 -0
  9. prompture/async_groups.py +551 -0
  10. prompture/conversation.py +222 -18
  11. prompture/core.py +46 -12
  12. prompture/cost_mixin.py +37 -0
  13. prompture/discovery.py +132 -44
  14. prompture/driver.py +77 -0
  15. prompture/drivers/__init__.py +5 -1
  16. prompture/drivers/async_azure_driver.py +11 -5
  17. prompture/drivers/async_claude_driver.py +184 -9
  18. prompture/drivers/async_google_driver.py +222 -28
  19. prompture/drivers/async_grok_driver.py +11 -5
  20. prompture/drivers/async_groq_driver.py +11 -5
  21. prompture/drivers/async_lmstudio_driver.py +74 -5
  22. prompture/drivers/async_ollama_driver.py +13 -3
  23. prompture/drivers/async_openai_driver.py +162 -5
  24. prompture/drivers/async_openrouter_driver.py +11 -5
  25. prompture/drivers/async_registry.py +5 -1
  26. prompture/drivers/azure_driver.py +10 -4
  27. prompture/drivers/claude_driver.py +17 -1
  28. prompture/drivers/google_driver.py +227 -33
  29. prompture/drivers/grok_driver.py +11 -5
  30. prompture/drivers/groq_driver.py +11 -5
  31. prompture/drivers/lmstudio_driver.py +73 -8
  32. prompture/drivers/ollama_driver.py +16 -5
  33. prompture/drivers/openai_driver.py +26 -11
  34. prompture/drivers/openrouter_driver.py +11 -5
  35. prompture/drivers/vision_helpers.py +153 -0
  36. prompture/group_types.py +147 -0
  37. prompture/groups.py +530 -0
  38. prompture/image.py +180 -0
  39. prompture/ledger.py +252 -0
  40. prompture/model_rates.py +112 -2
  41. prompture/persistence.py +254 -0
  42. prompture/persona.py +482 -0
  43. prompture/serialization.py +218 -0
  44. prompture/settings.py +1 -0
  45. prompture-0.0.40.dev1.dist-info/METADATA +369 -0
  46. prompture-0.0.40.dev1.dist-info/RECORD +78 -0
  47. prompture-0.0.35.dist-info/METADATA +0 -464
  48. prompture-0.0.35.dist-info/RECORD +0 -66
  49. {prompture-0.0.35.dist-info → prompture-0.0.40.dev1.dist-info}/WHEEL +0 -0
  50. {prompture-0.0.35.dist-info → prompture-0.0.40.dev1.dist-info}/entry_points.txt +0 -0
  51. {prompture-0.0.35.dist-info → prompture-0.0.40.dev1.dist-info}/licenses/LICENSE +0 -0
  52. {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
- raise ValueError("Either model_name or driver must be provided")
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
- self._system_prompt = system_prompt
65
- self._options = dict(options) if options else {}
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._messages.append({"role": role, "content": content})
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
- def _build_messages(self, user_content: str) -> list[dict[str, Any]]:
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
- msgs.append({"role": "user", "content": user_content})
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._messages.append({"role": "user", "content": content})
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._messages.append({"role": "user", "content": content})
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._messages.append({"role": "user", "content": content})
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
- self._messages.append({"role": "user", "content": content})
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 is provided
190
- if system_prompt is not None:
191
- messages = [
192
- {"role": "system", "content": system_prompt},
193
- {"role": "user", "content": full_prompt},
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 is provided
331
- if system_prompt is not None:
332
- messages = [
333
- {"role": "system", "content": system_prompt},
334
- {"role": "user", "content": full_prompt},
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
+ }