prompture 0.0.33.dev2__py3-none-any.whl → 0.0.34.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 +112 -54
- prompture/_version.py +34 -0
- prompture/aio/__init__.py +74 -0
- prompture/async_conversation.py +484 -0
- prompture/async_core.py +803 -0
- prompture/async_driver.py +131 -0
- prompture/cache.py +469 -0
- prompture/callbacks.py +50 -0
- prompture/cli.py +7 -3
- prompture/conversation.py +504 -0
- prompture/core.py +475 -352
- prompture/cost_mixin.py +51 -0
- prompture/discovery.py +41 -36
- prompture/driver.py +125 -5
- prompture/drivers/__init__.py +63 -57
- prompture/drivers/airllm_driver.py +13 -20
- prompture/drivers/async_airllm_driver.py +26 -0
- prompture/drivers/async_azure_driver.py +117 -0
- prompture/drivers/async_claude_driver.py +107 -0
- prompture/drivers/async_google_driver.py +132 -0
- prompture/drivers/async_grok_driver.py +91 -0
- prompture/drivers/async_groq_driver.py +84 -0
- prompture/drivers/async_hugging_driver.py +61 -0
- prompture/drivers/async_lmstudio_driver.py +79 -0
- prompture/drivers/async_local_http_driver.py +44 -0
- prompture/drivers/async_ollama_driver.py +125 -0
- prompture/drivers/async_openai_driver.py +96 -0
- prompture/drivers/async_openrouter_driver.py +96 -0
- prompture/drivers/async_registry.py +80 -0
- prompture/drivers/azure_driver.py +36 -15
- prompture/drivers/claude_driver.py +86 -40
- prompture/drivers/google_driver.py +86 -58
- prompture/drivers/grok_driver.py +29 -38
- prompture/drivers/groq_driver.py +27 -32
- prompture/drivers/hugging_driver.py +6 -6
- prompture/drivers/lmstudio_driver.py +26 -13
- prompture/drivers/local_http_driver.py +6 -6
- prompture/drivers/ollama_driver.py +90 -23
- prompture/drivers/openai_driver.py +36 -15
- prompture/drivers/openrouter_driver.py +31 -31
- prompture/field_definitions.py +106 -96
- prompture/logging.py +80 -0
- prompture/model_rates.py +16 -15
- prompture/runner.py +49 -47
- prompture/session.py +117 -0
- prompture/settings.py +11 -1
- prompture/tools.py +172 -265
- prompture/validator.py +3 -3
- {prompture-0.0.33.dev2.dist-info → prompture-0.0.34.dev1.dist-info}/METADATA +18 -20
- prompture-0.0.34.dev1.dist-info/RECORD +54 -0
- prompture-0.0.33.dev2.dist-info/RECORD +0 -30
- {prompture-0.0.33.dev2.dist-info → prompture-0.0.34.dev1.dist-info}/WHEEL +0 -0
- {prompture-0.0.33.dev2.dist-info → prompture-0.0.34.dev1.dist-info}/entry_points.txt +0 -0
- {prompture-0.0.33.dev2.dist-info → prompture-0.0.34.dev1.dist-info}/licenses/LICENSE +0 -0
- {prompture-0.0.33.dev2.dist-info → prompture-0.0.34.dev1.dist-info}/top_level.txt +0 -0
prompture/model_rates.py
CHANGED
|
@@ -5,17 +5,18 @@ caches locally with TTL-based auto-refresh, and provides lookup functions
|
|
|
5
5
|
used by drivers for cost calculations.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import contextlib
|
|
8
9
|
import json
|
|
9
10
|
import logging
|
|
10
11
|
import threading
|
|
11
12
|
from datetime import datetime, timezone
|
|
12
13
|
from pathlib import Path
|
|
13
|
-
from typing import Any,
|
|
14
|
+
from typing import Any, Optional
|
|
14
15
|
|
|
15
16
|
logger = logging.getLogger(__name__)
|
|
16
17
|
|
|
17
18
|
# Maps prompture provider names to models.dev provider names
|
|
18
|
-
PROVIDER_MAP:
|
|
19
|
+
PROVIDER_MAP: dict[str, str] = {
|
|
19
20
|
"openai": "openai",
|
|
20
21
|
"claude": "anthropic",
|
|
21
22
|
"google": "google",
|
|
@@ -31,7 +32,7 @@ _CACHE_FILE = _CACHE_DIR / "models_dev.json"
|
|
|
31
32
|
_META_FILE = _CACHE_DIR / "models_dev_meta.json"
|
|
32
33
|
|
|
33
34
|
_lock = threading.Lock()
|
|
34
|
-
_data: Optional[
|
|
35
|
+
_data: Optional[dict[str, Any]] = None
|
|
35
36
|
_loaded = False
|
|
36
37
|
|
|
37
38
|
|
|
@@ -39,6 +40,7 @@ def _get_ttl_days() -> int:
|
|
|
39
40
|
"""Get TTL from settings if available, otherwise default to 7."""
|
|
40
41
|
try:
|
|
41
42
|
from .settings import settings
|
|
43
|
+
|
|
42
44
|
return getattr(settings, "model_rates_ttl_days", 7)
|
|
43
45
|
except Exception:
|
|
44
46
|
return 7
|
|
@@ -58,7 +60,7 @@ def _cache_is_valid() -> bool:
|
|
|
58
60
|
return False
|
|
59
61
|
|
|
60
62
|
|
|
61
|
-
def _write_cache(data:
|
|
63
|
+
def _write_cache(data: dict[str, Any]) -> None:
|
|
62
64
|
"""Write API data and metadata to local cache."""
|
|
63
65
|
try:
|
|
64
66
|
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
@@ -72,7 +74,7 @@ def _write_cache(data: Dict[str, Any]) -> None:
|
|
|
72
74
|
logger.debug("Failed to write model rates cache: %s", exc)
|
|
73
75
|
|
|
74
76
|
|
|
75
|
-
def _read_cache() -> Optional[
|
|
77
|
+
def _read_cache() -> Optional[dict[str, Any]]:
|
|
76
78
|
"""Read cached API data from disk."""
|
|
77
79
|
try:
|
|
78
80
|
return json.loads(_CACHE_FILE.read_text(encoding="utf-8"))
|
|
@@ -80,10 +82,11 @@ def _read_cache() -> Optional[Dict[str, Any]]:
|
|
|
80
82
|
return None
|
|
81
83
|
|
|
82
84
|
|
|
83
|
-
def _fetch_from_api() -> Optional[
|
|
85
|
+
def _fetch_from_api() -> Optional[dict[str, Any]]:
|
|
84
86
|
"""Fetch fresh data from models.dev API."""
|
|
85
87
|
try:
|
|
86
88
|
import requests
|
|
89
|
+
|
|
87
90
|
resp = requests.get(_API_URL, timeout=15)
|
|
88
91
|
resp.raise_for_status()
|
|
89
92
|
return resp.json()
|
|
@@ -92,7 +95,7 @@ def _fetch_from_api() -> Optional[Dict[str, Any]]:
|
|
|
92
95
|
return None
|
|
93
96
|
|
|
94
97
|
|
|
95
|
-
def _ensure_loaded() -> Optional[
|
|
98
|
+
def _ensure_loaded() -> Optional[dict[str, Any]]:
|
|
96
99
|
"""Lazy-load data: use cache if valid, otherwise fetch from API."""
|
|
97
100
|
global _data, _loaded
|
|
98
101
|
if _loaded:
|
|
@@ -122,7 +125,7 @@ def _ensure_loaded() -> Optional[Dict[str, Any]]:
|
|
|
122
125
|
return _data
|
|
123
126
|
|
|
124
127
|
|
|
125
|
-
def _lookup_model(provider: str, model_id: str) -> Optional[
|
|
128
|
+
def _lookup_model(provider: str, model_id: str) -> Optional[dict[str, Any]]:
|
|
126
129
|
"""Find a model entry in the cached data.
|
|
127
130
|
|
|
128
131
|
The API structure is ``{provider: {model_id: {...}, ...}, ...}``.
|
|
@@ -142,7 +145,7 @@ def _lookup_model(provider: str, model_id: str) -> Optional[Dict[str, Any]]:
|
|
|
142
145
|
# ── Public API ──────────────────────────────────────────────────────────────
|
|
143
146
|
|
|
144
147
|
|
|
145
|
-
def get_model_rates(provider: str, model_id: str) -> Optional[
|
|
148
|
+
def get_model_rates(provider: str, model_id: str) -> Optional[dict[str, float]]:
|
|
146
149
|
"""Return pricing dict for a model, or ``None`` if unavailable.
|
|
147
150
|
|
|
148
151
|
Returned keys mirror models.dev cost fields (per 1M tokens):
|
|
@@ -157,14 +160,12 @@ def get_model_rates(provider: str, model_id: str) -> Optional[Dict[str, float]]:
|
|
|
157
160
|
if not isinstance(cost, dict):
|
|
158
161
|
return None
|
|
159
162
|
|
|
160
|
-
rates:
|
|
163
|
+
rates: dict[str, float] = {}
|
|
161
164
|
for key in ("input", "output", "cache_read", "cache_write", "reasoning"):
|
|
162
165
|
val = cost.get(key)
|
|
163
166
|
if val is not None:
|
|
164
|
-
|
|
167
|
+
with contextlib.suppress(TypeError, ValueError):
|
|
165
168
|
rates[key] = float(val)
|
|
166
|
-
except (TypeError, ValueError):
|
|
167
|
-
pass
|
|
168
169
|
|
|
169
170
|
# Must have at least input and output to be useful
|
|
170
171
|
if "input" in rates and "output" in rates:
|
|
@@ -172,12 +173,12 @@ def get_model_rates(provider: str, model_id: str) -> Optional[Dict[str, float]]:
|
|
|
172
173
|
return None
|
|
173
174
|
|
|
174
175
|
|
|
175
|
-
def get_model_info(provider: str, model_id: str) -> Optional[
|
|
176
|
+
def get_model_info(provider: str, model_id: str) -> Optional[dict[str, Any]]:
|
|
176
177
|
"""Return full model metadata (cost, limits, capabilities), or ``None``."""
|
|
177
178
|
return _lookup_model(provider, model_id)
|
|
178
179
|
|
|
179
180
|
|
|
180
|
-
def get_all_provider_models(provider: str) ->
|
|
181
|
+
def get_all_provider_models(provider: str) -> list[str]:
|
|
181
182
|
"""Return list of model IDs available for a provider."""
|
|
182
183
|
data = _ensure_loaded()
|
|
183
184
|
if data is None:
|
prompture/runner.py
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
"""Test suite runner for executing JSON validation tests across multiple models."""
|
|
2
|
-
from typing import Dict, Any, List
|
|
3
2
|
|
|
4
|
-
from
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
5
|
from prompture.validator import validate_against_schema
|
|
6
6
|
|
|
7
|
+
from .core import Driver, ask_for_json
|
|
8
|
+
|
|
7
9
|
|
|
8
|
-
def run_suite_from_spec(spec:
|
|
10
|
+
def run_suite_from_spec(spec: dict[str, Any], drivers: dict[str, Driver]) -> dict[str, Any]:
|
|
9
11
|
"""Run a test suite specified by a spec dictionary across multiple models.
|
|
10
|
-
|
|
12
|
+
|
|
11
13
|
Args:
|
|
12
14
|
spec: A dictionary containing the test suite specification with the structure:
|
|
13
15
|
{
|
|
@@ -21,7 +23,7 @@ def run_suite_from_spec(spec: Dict[str, Any], drivers: Dict[str, Driver]) -> Dic
|
|
|
21
23
|
}, ...]
|
|
22
24
|
}
|
|
23
25
|
drivers: A dictionary mapping driver names to driver instances
|
|
24
|
-
|
|
26
|
+
|
|
25
27
|
Returns:
|
|
26
28
|
A dictionary containing test results with the structure:
|
|
27
29
|
{
|
|
@@ -42,67 +44,67 @@ def run_suite_from_spec(spec: Dict[str, Any], drivers: Dict[str, Driver]) -> Dic
|
|
|
42
44
|
}
|
|
43
45
|
"""
|
|
44
46
|
results = []
|
|
45
|
-
|
|
47
|
+
|
|
46
48
|
for test in spec["tests"]:
|
|
47
49
|
for model in spec["models"]:
|
|
48
50
|
driver = drivers.get(model["driver"])
|
|
49
51
|
if not driver:
|
|
50
52
|
continue
|
|
51
|
-
|
|
53
|
+
|
|
52
54
|
# Run test for each input
|
|
53
55
|
for input_data in test["inputs"]:
|
|
54
56
|
# Format prompt template with input data
|
|
55
57
|
try:
|
|
56
58
|
prompt = test["prompt_template"].format(**input_data)
|
|
57
59
|
except KeyError as e:
|
|
58
|
-
results.append(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
60
|
+
results.append(
|
|
61
|
+
{
|
|
62
|
+
"test_id": test["id"],
|
|
63
|
+
"model_id": model["id"],
|
|
64
|
+
"input": input_data,
|
|
65
|
+
"prompt": test["prompt_template"],
|
|
66
|
+
"error": f"Template formatting error: missing key {e}",
|
|
67
|
+
"validation": {"ok": False, "error": "Prompt formatting failed", "data": None},
|
|
68
|
+
"usage": {"total_tokens": 0, "cost": 0},
|
|
69
|
+
}
|
|
70
|
+
)
|
|
67
71
|
continue
|
|
68
|
-
|
|
72
|
+
|
|
69
73
|
# Get JSON response from model
|
|
70
74
|
try:
|
|
71
75
|
response = ask_for_json(
|
|
72
76
|
driver=driver,
|
|
73
77
|
content_prompt=prompt,
|
|
74
78
|
json_schema=test["schema"],
|
|
75
|
-
options=model.get("options", {})
|
|
79
|
+
options=model.get("options", {}),
|
|
76
80
|
)
|
|
77
|
-
|
|
81
|
+
|
|
78
82
|
# Validate response against schema
|
|
79
|
-
validation = validate_against_schema(
|
|
80
|
-
|
|
81
|
-
|
|
83
|
+
validation = validate_against_schema(response["json_string"], test["schema"])
|
|
84
|
+
|
|
85
|
+
results.append(
|
|
86
|
+
{
|
|
87
|
+
"test_id": test["id"],
|
|
88
|
+
"model_id": model["id"],
|
|
89
|
+
"input": input_data,
|
|
90
|
+
"prompt": prompt,
|
|
91
|
+
"response": response["json_object"],
|
|
92
|
+
"validation": validation,
|
|
93
|
+
"usage": response["usage"],
|
|
94
|
+
}
|
|
82
95
|
)
|
|
83
|
-
|
|
84
|
-
results.append({
|
|
85
|
-
"test_id": test["id"],
|
|
86
|
-
"model_id": model["id"],
|
|
87
|
-
"input": input_data,
|
|
88
|
-
"prompt": prompt,
|
|
89
|
-
"response": response["json_object"],
|
|
90
|
-
"validation": validation,
|
|
91
|
-
"usage": response["usage"]
|
|
92
|
-
})
|
|
93
|
-
|
|
96
|
+
|
|
94
97
|
except Exception as e:
|
|
95
|
-
results.append(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
98
|
+
results.append(
|
|
99
|
+
{
|
|
100
|
+
"test_id": test["id"],
|
|
101
|
+
"model_id": model["id"],
|
|
102
|
+
"input": input_data,
|
|
103
|
+
"prompt": prompt,
|
|
104
|
+
"error": str(e),
|
|
105
|
+
"validation": {"ok": False, "error": "Model response error", "data": None},
|
|
106
|
+
"usage": {"total_tokens": 0, "cost": 0},
|
|
107
|
+
}
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return {"meta": spec.get("meta", {}), "results": results}
|
prompture/session.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Usage session tracking for Prompture.
|
|
2
|
+
|
|
3
|
+
Provides :class:`UsageSession` which accumulates token counts, costs, and
|
|
4
|
+
errors across multiple driver calls. A session instance is compatible as
|
|
5
|
+
both an ``on_response`` and ``on_error`` callback, so you can wire it
|
|
6
|
+
directly into :class:`~prompture.callbacks.DriverCallbacks`.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
from prompture import UsageSession, DriverCallbacks
|
|
11
|
+
|
|
12
|
+
session = UsageSession()
|
|
13
|
+
callbacks = DriverCallbacks(
|
|
14
|
+
on_response=session.record,
|
|
15
|
+
on_error=session.record_error,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# ... pass *callbacks* to your driver / conversation ...
|
|
19
|
+
|
|
20
|
+
print(session.summary()["formatted"])
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class UsageSession:
|
|
31
|
+
"""Accumulates usage statistics across multiple driver calls."""
|
|
32
|
+
|
|
33
|
+
prompt_tokens: int = 0
|
|
34
|
+
completion_tokens: int = 0
|
|
35
|
+
total_tokens: int = 0
|
|
36
|
+
total_cost: float = 0.0
|
|
37
|
+
call_count: int = 0
|
|
38
|
+
errors: int = 0
|
|
39
|
+
_per_model: dict[str, dict[str, Any]] = field(default_factory=dict, repr=False)
|
|
40
|
+
|
|
41
|
+
# ------------------------------------------------------------------ #
|
|
42
|
+
# Recording
|
|
43
|
+
# ------------------------------------------------------------------ #
|
|
44
|
+
|
|
45
|
+
def record(self, response_info: dict[str, Any]) -> None:
|
|
46
|
+
"""Record a successful driver response.
|
|
47
|
+
|
|
48
|
+
Compatible as an ``on_response`` callback for
|
|
49
|
+
:class:`~prompture.callbacks.DriverCallbacks`.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
response_info: Payload dict with at least ``meta`` and
|
|
53
|
+
optionally ``driver`` keys.
|
|
54
|
+
"""
|
|
55
|
+
meta = response_info.get("meta", {})
|
|
56
|
+
pt = meta.get("prompt_tokens", 0)
|
|
57
|
+
ct = meta.get("completion_tokens", 0)
|
|
58
|
+
tt = meta.get("total_tokens", 0)
|
|
59
|
+
cost = meta.get("cost", 0.0)
|
|
60
|
+
|
|
61
|
+
self.prompt_tokens += pt
|
|
62
|
+
self.completion_tokens += ct
|
|
63
|
+
self.total_tokens += tt
|
|
64
|
+
self.total_cost += cost
|
|
65
|
+
self.call_count += 1
|
|
66
|
+
|
|
67
|
+
model = response_info.get("driver", "unknown")
|
|
68
|
+
bucket = self._per_model.setdefault(
|
|
69
|
+
model,
|
|
70
|
+
{"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "cost": 0.0, "calls": 0},
|
|
71
|
+
)
|
|
72
|
+
bucket["prompt_tokens"] += pt
|
|
73
|
+
bucket["completion_tokens"] += ct
|
|
74
|
+
bucket["total_tokens"] += tt
|
|
75
|
+
bucket["cost"] += cost
|
|
76
|
+
bucket["calls"] += 1
|
|
77
|
+
|
|
78
|
+
def record_error(self, error_info: dict[str, Any]) -> None:
|
|
79
|
+
"""Record a driver error.
|
|
80
|
+
|
|
81
|
+
Compatible as an ``on_error`` callback for
|
|
82
|
+
:class:`~prompture.callbacks.DriverCallbacks`.
|
|
83
|
+
"""
|
|
84
|
+
self.errors += 1
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------ #
|
|
87
|
+
# Reporting
|
|
88
|
+
# ------------------------------------------------------------------ #
|
|
89
|
+
|
|
90
|
+
def summary(self) -> dict[str, Any]:
|
|
91
|
+
"""Return a machine-readable summary with a ``formatted`` string."""
|
|
92
|
+
formatted = (
|
|
93
|
+
f"Session: {self.total_tokens:,} tokens across {self.call_count} call(s) costing ${self.total_cost:.4f}"
|
|
94
|
+
)
|
|
95
|
+
if self.errors:
|
|
96
|
+
formatted += f" ({self.errors} error(s))"
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"prompt_tokens": self.prompt_tokens,
|
|
100
|
+
"completion_tokens": self.completion_tokens,
|
|
101
|
+
"total_tokens": self.total_tokens,
|
|
102
|
+
"total_cost": self.total_cost,
|
|
103
|
+
"call_count": self.call_count,
|
|
104
|
+
"errors": self.errors,
|
|
105
|
+
"per_model": dict(self._per_model),
|
|
106
|
+
"formatted": formatted,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
def reset(self) -> None:
|
|
110
|
+
"""Clear all accumulated counters."""
|
|
111
|
+
self.prompt_tokens = 0
|
|
112
|
+
self.completion_tokens = 0
|
|
113
|
+
self.total_tokens = 0
|
|
114
|
+
self.total_cost = 0.0
|
|
115
|
+
self.call_count = 0
|
|
116
|
+
self.errors = 0
|
|
117
|
+
self._per_model.clear()
|
prompture/settings.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
2
1
|
from typing import Optional
|
|
3
2
|
|
|
3
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
4
|
+
|
|
5
|
+
|
|
4
6
|
class Settings(BaseSettings):
|
|
5
7
|
"""Application settings loaded from environment variables or .env file."""
|
|
6
8
|
|
|
@@ -55,6 +57,14 @@ class Settings(BaseSettings):
|
|
|
55
57
|
# Model rates cache
|
|
56
58
|
model_rates_ttl_days: int = 7 # How often to refresh models.dev cache
|
|
57
59
|
|
|
60
|
+
# Response cache
|
|
61
|
+
cache_enabled: bool = False
|
|
62
|
+
cache_backend: str = "memory"
|
|
63
|
+
cache_ttl_seconds: int = 3600
|
|
64
|
+
cache_memory_maxsize: int = 256
|
|
65
|
+
cache_sqlite_path: Optional[str] = None
|
|
66
|
+
cache_redis_url: Optional[str] = None
|
|
67
|
+
|
|
58
68
|
model_config = SettingsConfigDict(
|
|
59
69
|
env_file=".env",
|
|
60
70
|
extra="ignore",
|