prompture 0.0.39.dev1__tar.gz → 0.0.40__tar.gz
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-0.0.39.dev1/prompture.egg-info → prompture-0.0.40}/PKG-INFO +1 -1
- prompture-0.0.40/VERSION +1 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/__init__.py +3 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/_version.py +2 -2
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/async_conversation.py +9 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/async_core.py +16 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/conversation.py +9 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/core.py +16 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/discovery.py +66 -16
- prompture-0.0.40/prompture/ledger.py +252 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40/prompture.egg-info}/PKG-INFO +1 -1
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture.egg-info/SOURCES.txt +2 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/.claude/skills/add-driver/SKILL.md +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/.claude/skills/add-driver/references/driver-template.md +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/.claude/skills/add-example/SKILL.md +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/.claude/skills/add-field/SKILL.md +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/.claude/skills/add-test/SKILL.md +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/.claude/skills/run-tests/SKILL.md +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/.claude/skills/scaffold-extraction/SKILL.md +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/.claude/skills/update-pricing/SKILL.md +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/.env.copy +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/.github/FUNDING.yml +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/.github/scripts/update_docs_version.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/.github/scripts/update_wrapper_version.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/.github/workflows/dev.yml +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/.github/workflows/documentation.yml +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/.github/workflows/publish.yml +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/CLAUDE.md +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/LICENSE +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/MANIFEST.in +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/README.md +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/ROADMAP.md +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/docs/source/_static/custom.css +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/docs/source/_templates/footer.html +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/docs/source/api/core.rst +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/docs/source/api/drivers.rst +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/docs/source/api/field_definitions.rst +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/docs/source/api/index.rst +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/docs/source/api/runner.rst +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/docs/source/api/tools.rst +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/docs/source/api/validator.rst +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/docs/source/conf.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/docs/source/contributing.rst +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/docs/source/examples.rst +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/docs/source/field_definitions_reference.rst +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/docs/source/index.rst +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/docs/source/installation.rst +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/docs/source/quickstart.rst +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/docs/source/toon_input_guide.rst +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/packages/README.md +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/packages/llm_to_json/README.md +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/packages/llm_to_json/llm_to_json/__init__.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/packages/llm_to_json/pyproject.toml +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/packages/llm_to_json/test.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/packages/llm_to_toon/README.md +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/packages/llm_to_toon/llm_to_toon/__init__.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/packages/llm_to_toon/pyproject.toml +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/packages/llm_to_toon/test.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/agent.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/agent_types.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/aio/__init__.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/async_agent.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/async_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/async_groups.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/cache.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/callbacks.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/cli.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/cost_mixin.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/__init__.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/airllm_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/async_airllm_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/async_azure_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/async_claude_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/async_google_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/async_grok_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/async_groq_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/async_hugging_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/async_lmstudio_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/async_local_http_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/async_ollama_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/async_openai_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/async_openrouter_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/async_registry.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/azure_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/claude_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/google_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/grok_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/groq_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/hugging_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/lmstudio_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/local_http_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/ollama_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/openai_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/openrouter_driver.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/registry.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/drivers/vision_helpers.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/field_definitions.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/group_types.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/groups.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/image.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/logging.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/model_rates.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/persistence.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/persona.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/runner.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/scaffold/__init__.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/scaffold/generator.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/scaffold/templates/Dockerfile.j2 +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/scaffold/templates/README.md.j2 +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/scaffold/templates/config.py.j2 +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/scaffold/templates/env.example.j2 +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/scaffold/templates/main.py.j2 +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/scaffold/templates/models.py.j2 +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/scaffold/templates/requirements.txt.j2 +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/serialization.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/server.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/session.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/settings.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/tools.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/tools_schema.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture/validator.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture.egg-info/dependency_links.txt +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture.egg-info/entry_points.txt +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture.egg-info/requires.txt +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/prompture.egg-info/top_level.txt +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/pyproject.toml +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/requirements.txt +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/setup.cfg +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/test.py +0 -0
- {prompture-0.0.39.dev1 → prompture-0.0.40}/test_version_diagnosis.py +0 -0
prompture-0.0.40/VERSION
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.0.40
|
|
@@ -110,6 +110,7 @@ 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
115
|
from .model_rates import (
|
|
115
116
|
ModelCapabilities,
|
|
@@ -221,6 +222,7 @@ __all__ = [
|
|
|
221
222
|
"MemoryCacheBackend",
|
|
222
223
|
"ModelCapabilities",
|
|
223
224
|
"ModelRetry",
|
|
225
|
+
"ModelUsageLedger",
|
|
224
226
|
"OllamaDriver",
|
|
225
227
|
"OpenAIDriver",
|
|
226
228
|
"OpenRouterDriver",
|
|
@@ -268,6 +270,7 @@ __all__ = [
|
|
|
268
270
|
"get_persona",
|
|
269
271
|
"get_persona_names",
|
|
270
272
|
"get_persona_registry_snapshot",
|
|
273
|
+
"get_recently_used_models",
|
|
271
274
|
"get_registry_snapshot",
|
|
272
275
|
"get_required_fields",
|
|
273
276
|
"get_trait",
|
|
@@ -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.40'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 0, 40)
|
|
33
33
|
|
|
34
34
|
__commit_id__ = commit_id = None
|
|
@@ -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,
|
|
@@ -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
|
|
@@ -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,
|
|
@@ -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
|
|
@@ -27,14 +27,18 @@ logger = logging.getLogger(__name__)
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
@overload
|
|
30
|
-
def get_available_models(*, include_capabilities: bool = False) -> list[str]: ...
|
|
30
|
+
def get_available_models(*, include_capabilities: bool = False, verified_only: bool = False) -> list[str]: ...
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
@overload
|
|
34
|
-
def get_available_models(*, include_capabilities: bool = True) -> list[dict[str, Any]]: ...
|
|
34
|
+
def get_available_models(*, include_capabilities: bool = True, verified_only: bool = False) -> list[dict[str, Any]]: ...
|
|
35
35
|
|
|
36
36
|
|
|
37
|
-
def get_available_models(
|
|
37
|
+
def get_available_models(
|
|
38
|
+
*,
|
|
39
|
+
include_capabilities: bool = False,
|
|
40
|
+
verified_only: bool = False,
|
|
41
|
+
) -> list[str] | list[dict[str, Any]]:
|
|
38
42
|
"""Auto-detect available models based on configured drivers and environment variables.
|
|
39
43
|
|
|
40
44
|
Iterates through supported providers and checks if they are configured
|
|
@@ -46,6 +50,8 @@ def get_available_models(*, include_capabilities: bool = False) -> list[str] | l
|
|
|
46
50
|
include_capabilities: When ``True``, return enriched dicts with
|
|
47
51
|
``model``, ``provider``, ``model_id``, and ``capabilities``
|
|
48
52
|
fields instead of plain ``"provider/model_id"`` strings.
|
|
53
|
+
verified_only: When ``True``, only return models that have been
|
|
54
|
+
successfully used (as recorded by the usage ledger).
|
|
49
55
|
|
|
50
56
|
Returns:
|
|
51
57
|
A sorted list of unique model strings (default) or enriched dicts.
|
|
@@ -96,11 +102,11 @@ def get_available_models(*, include_capabilities: bool = False) -> list[str] | l
|
|
|
96
102
|
elif provider == "grok":
|
|
97
103
|
if settings.grok_api_key or os.getenv("GROK_API_KEY"):
|
|
98
104
|
is_configured = True
|
|
99
|
-
elif
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
105
|
+
elif (
|
|
106
|
+
provider == "ollama"
|
|
107
|
+
or provider == "lmstudio"
|
|
108
|
+
or (provider == "local_http" and os.getenv("LOCAL_HTTP_ENDPOINT"))
|
|
109
|
+
):
|
|
104
110
|
is_configured = True
|
|
105
111
|
|
|
106
112
|
if not is_configured:
|
|
@@ -175,12 +181,47 @@ def get_available_models(*, include_capabilities: bool = False) -> list[str] | l
|
|
|
175
181
|
|
|
176
182
|
sorted_models = sorted(available_models)
|
|
177
183
|
|
|
184
|
+
# --- verified_only filtering ---
|
|
185
|
+
verified_set: set[str] | None = None
|
|
186
|
+
if verified_only or include_capabilities:
|
|
187
|
+
try:
|
|
188
|
+
from .ledger import _get_ledger
|
|
189
|
+
|
|
190
|
+
ledger = _get_ledger()
|
|
191
|
+
verified_set = ledger.get_verified_models()
|
|
192
|
+
except Exception:
|
|
193
|
+
logger.debug("Could not load ledger for verified models", exc_info=True)
|
|
194
|
+
verified_set = set()
|
|
195
|
+
|
|
196
|
+
if verified_only and verified_set is not None:
|
|
197
|
+
sorted_models = [m for m in sorted_models if m in verified_set]
|
|
198
|
+
|
|
178
199
|
if not include_capabilities:
|
|
179
200
|
return sorted_models
|
|
180
201
|
|
|
181
202
|
# Build enriched dicts with capabilities from models.dev
|
|
182
203
|
from .model_rates import get_model_capabilities
|
|
183
204
|
|
|
205
|
+
# Fetch all ledger stats for annotation (keyed by model_name)
|
|
206
|
+
ledger_stats: dict[str, dict[str, Any]] = {}
|
|
207
|
+
try:
|
|
208
|
+
from .ledger import _get_ledger
|
|
209
|
+
|
|
210
|
+
for row in _get_ledger().get_all_stats():
|
|
211
|
+
name = row["model_name"]
|
|
212
|
+
if name not in ledger_stats:
|
|
213
|
+
ledger_stats[name] = row
|
|
214
|
+
else:
|
|
215
|
+
# Aggregate across API key hashes
|
|
216
|
+
existing = ledger_stats[name]
|
|
217
|
+
existing["use_count"] += row["use_count"]
|
|
218
|
+
existing["total_tokens"] += row["total_tokens"]
|
|
219
|
+
existing["total_cost"] += row["total_cost"]
|
|
220
|
+
if row["last_used"] > existing["last_used"]:
|
|
221
|
+
existing["last_used"] = row["last_used"]
|
|
222
|
+
except Exception:
|
|
223
|
+
logger.debug("Could not load ledger stats for enrichment", exc_info=True)
|
|
224
|
+
|
|
184
225
|
enriched: list[dict[str, Any]] = []
|
|
185
226
|
for model_str in sorted_models:
|
|
186
227
|
parts = model_str.split("/", 1)
|
|
@@ -190,13 +231,22 @@ def get_available_models(*, include_capabilities: bool = False) -> list[str] | l
|
|
|
190
231
|
caps = get_model_capabilities(provider, model_id)
|
|
191
232
|
caps_dict = dataclasses.asdict(caps) if caps is not None else None
|
|
192
233
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
234
|
+
entry: dict[str, Any] = {
|
|
235
|
+
"model": model_str,
|
|
236
|
+
"provider": provider,
|
|
237
|
+
"model_id": model_id,
|
|
238
|
+
"capabilities": caps_dict,
|
|
239
|
+
"verified": verified_set is not None and model_str in verified_set,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
stats = ledger_stats.get(model_str)
|
|
243
|
+
if stats:
|
|
244
|
+
entry["last_used"] = stats["last_used"]
|
|
245
|
+
entry["use_count"] = stats["use_count"]
|
|
246
|
+
else:
|
|
247
|
+
entry["last_used"] = None
|
|
248
|
+
entry["use_count"] = 0
|
|
249
|
+
|
|
250
|
+
enriched.append(entry)
|
|
201
251
|
|
|
202
252
|
return enriched
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Persistent model usage ledger — tracks which LLM models have been used.
|
|
2
|
+
|
|
3
|
+
Stores per-model usage stats (call count, tokens, cost, timestamps) in a
|
|
4
|
+
SQLite database at ``~/.prompture/usage/model_ledger.db``. The public
|
|
5
|
+
convenience functions are fire-and-forget: they never raise exceptions so
|
|
6
|
+
they cannot break existing extraction/conversation flows.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import logging
|
|
13
|
+
import sqlite3
|
|
14
|
+
import threading
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("prompture.ledger")
|
|
20
|
+
|
|
21
|
+
_DEFAULT_DB_DIR = Path.home() / ".prompture" / "usage"
|
|
22
|
+
_DEFAULT_DB_PATH = _DEFAULT_DB_DIR / "model_ledger.db"
|
|
23
|
+
|
|
24
|
+
_SCHEMA_SQL = """
|
|
25
|
+
CREATE TABLE IF NOT EXISTS model_usage (
|
|
26
|
+
model_name TEXT NOT NULL,
|
|
27
|
+
api_key_hash TEXT NOT NULL,
|
|
28
|
+
use_count INTEGER NOT NULL DEFAULT 1,
|
|
29
|
+
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
total_cost REAL NOT NULL DEFAULT 0.0,
|
|
31
|
+
first_used TEXT NOT NULL,
|
|
32
|
+
last_used TEXT NOT NULL,
|
|
33
|
+
last_status TEXT NOT NULL DEFAULT 'success',
|
|
34
|
+
PRIMARY KEY (model_name, api_key_hash)
|
|
35
|
+
);
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ModelUsageLedger:
|
|
40
|
+
"""SQLite-backed model usage tracker.
|
|
41
|
+
|
|
42
|
+
Thread-safe via an internal :class:`threading.Lock`.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
db_path: Path to the SQLite database file. Defaults to
|
|
46
|
+
``~/.prompture/usage/model_ledger.db``.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, db_path: str | Path | None = None) -> None:
|
|
50
|
+
self._db_path = Path(db_path) if db_path else _DEFAULT_DB_PATH
|
|
51
|
+
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
self._lock = threading.Lock()
|
|
53
|
+
self._init_db()
|
|
54
|
+
|
|
55
|
+
def _init_db(self) -> None:
|
|
56
|
+
with self._lock:
|
|
57
|
+
conn = sqlite3.connect(str(self._db_path))
|
|
58
|
+
try:
|
|
59
|
+
conn.executescript(_SCHEMA_SQL)
|
|
60
|
+
conn.commit()
|
|
61
|
+
finally:
|
|
62
|
+
conn.close()
|
|
63
|
+
|
|
64
|
+
def _connect(self) -> sqlite3.Connection:
|
|
65
|
+
conn = sqlite3.connect(str(self._db_path))
|
|
66
|
+
conn.row_factory = sqlite3.Row
|
|
67
|
+
return conn
|
|
68
|
+
|
|
69
|
+
# ------------------------------------------------------------------ #
|
|
70
|
+
# Recording
|
|
71
|
+
# ------------------------------------------------------------------ #
|
|
72
|
+
|
|
73
|
+
def record_usage(
|
|
74
|
+
self,
|
|
75
|
+
model_name: str,
|
|
76
|
+
*,
|
|
77
|
+
api_key_hash: str = "",
|
|
78
|
+
tokens: int = 0,
|
|
79
|
+
cost: float = 0.0,
|
|
80
|
+
status: str = "success",
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Record a model usage event (upsert).
|
|
83
|
+
|
|
84
|
+
On conflict the row's counters are incremented and ``last_used``
|
|
85
|
+
is updated.
|
|
86
|
+
"""
|
|
87
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
88
|
+
with self._lock:
|
|
89
|
+
conn = self._connect()
|
|
90
|
+
try:
|
|
91
|
+
conn.execute(
|
|
92
|
+
"""
|
|
93
|
+
INSERT INTO model_usage
|
|
94
|
+
(model_name, api_key_hash, use_count, total_tokens, total_cost,
|
|
95
|
+
first_used, last_used, last_status)
|
|
96
|
+
VALUES (?, ?, 1, ?, ?, ?, ?, ?)
|
|
97
|
+
ON CONFLICT(model_name, api_key_hash) DO UPDATE SET
|
|
98
|
+
use_count = use_count + 1,
|
|
99
|
+
total_tokens = total_tokens + excluded.total_tokens,
|
|
100
|
+
total_cost = total_cost + excluded.total_cost,
|
|
101
|
+
last_used = excluded.last_used,
|
|
102
|
+
last_status = excluded.last_status
|
|
103
|
+
""",
|
|
104
|
+
(model_name, api_key_hash, tokens, cost, now, now, status),
|
|
105
|
+
)
|
|
106
|
+
conn.commit()
|
|
107
|
+
finally:
|
|
108
|
+
conn.close()
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------ #
|
|
111
|
+
# Queries
|
|
112
|
+
# ------------------------------------------------------------------ #
|
|
113
|
+
|
|
114
|
+
def get_model_stats(self, model_name: str, api_key_hash: str = "") -> dict[str, Any] | None:
|
|
115
|
+
"""Return stats for a specific model + key combination, or ``None``."""
|
|
116
|
+
with self._lock:
|
|
117
|
+
conn = self._connect()
|
|
118
|
+
try:
|
|
119
|
+
row = conn.execute(
|
|
120
|
+
"SELECT * FROM model_usage WHERE model_name = ? AND api_key_hash = ?",
|
|
121
|
+
(model_name, api_key_hash),
|
|
122
|
+
).fetchone()
|
|
123
|
+
if row is None:
|
|
124
|
+
return None
|
|
125
|
+
return dict(row)
|
|
126
|
+
finally:
|
|
127
|
+
conn.close()
|
|
128
|
+
|
|
129
|
+
def get_verified_models(self) -> set[str]:
|
|
130
|
+
"""Return model names that have at least one successful usage."""
|
|
131
|
+
with self._lock:
|
|
132
|
+
conn = self._connect()
|
|
133
|
+
try:
|
|
134
|
+
rows = conn.execute(
|
|
135
|
+
"SELECT DISTINCT model_name FROM model_usage WHERE last_status = 'success'"
|
|
136
|
+
).fetchall()
|
|
137
|
+
return {r["model_name"] for r in rows}
|
|
138
|
+
finally:
|
|
139
|
+
conn.close()
|
|
140
|
+
|
|
141
|
+
def get_recently_used(self, limit: int = 10) -> list[dict[str, Any]]:
|
|
142
|
+
"""Return recent model usage rows ordered by ``last_used`` descending."""
|
|
143
|
+
with self._lock:
|
|
144
|
+
conn = self._connect()
|
|
145
|
+
try:
|
|
146
|
+
rows = conn.execute(
|
|
147
|
+
"SELECT * FROM model_usage ORDER BY last_used DESC LIMIT ?",
|
|
148
|
+
(limit,),
|
|
149
|
+
).fetchall()
|
|
150
|
+
return [dict(r) for r in rows]
|
|
151
|
+
finally:
|
|
152
|
+
conn.close()
|
|
153
|
+
|
|
154
|
+
def get_all_stats(self) -> list[dict[str, Any]]:
|
|
155
|
+
"""Return all usage rows."""
|
|
156
|
+
with self._lock:
|
|
157
|
+
conn = self._connect()
|
|
158
|
+
try:
|
|
159
|
+
rows = conn.execute("SELECT * FROM model_usage ORDER BY last_used DESC").fetchall()
|
|
160
|
+
return [dict(r) for r in rows]
|
|
161
|
+
finally:
|
|
162
|
+
conn.close()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
# Module-level singleton
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
_ledger: ModelUsageLedger | None = None
|
|
170
|
+
_ledger_lock = threading.Lock()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _get_ledger() -> ModelUsageLedger:
|
|
174
|
+
"""Return (and lazily create) the module-level singleton ledger."""
|
|
175
|
+
global _ledger
|
|
176
|
+
if _ledger is None:
|
|
177
|
+
with _ledger_lock:
|
|
178
|
+
if _ledger is None:
|
|
179
|
+
_ledger = ModelUsageLedger()
|
|
180
|
+
return _ledger
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
# Public convenience functions (fire-and-forget)
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def record_model_usage(
|
|
189
|
+
model_name: str,
|
|
190
|
+
*,
|
|
191
|
+
api_key_hash: str = "",
|
|
192
|
+
tokens: int = 0,
|
|
193
|
+
cost: float = 0.0,
|
|
194
|
+
status: str = "success",
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Record a model usage event. Never raises — all exceptions are swallowed."""
|
|
197
|
+
try:
|
|
198
|
+
_get_ledger().record_usage(
|
|
199
|
+
model_name,
|
|
200
|
+
api_key_hash=api_key_hash,
|
|
201
|
+
tokens=tokens,
|
|
202
|
+
cost=cost,
|
|
203
|
+
status=status,
|
|
204
|
+
)
|
|
205
|
+
except Exception:
|
|
206
|
+
logger.debug("Failed to record model usage for %s", model_name, exc_info=True)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def get_recently_used_models(limit: int = 10) -> list[dict[str, Any]]:
|
|
210
|
+
"""Return recently used models. Returns empty list on error."""
|
|
211
|
+
try:
|
|
212
|
+
return _get_ledger().get_recently_used(limit)
|
|
213
|
+
except Exception:
|
|
214
|
+
logger.debug("Failed to get recently used models", exc_info=True)
|
|
215
|
+
return []
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ------------------------------------------------------------------
|
|
219
|
+
# API key hash helper
|
|
220
|
+
# ------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
_LOCAL_PROVIDERS = frozenset({"ollama", "lmstudio", "local_http", "airllm"})
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _resolve_api_key_hash(model_name: str) -> str:
|
|
226
|
+
"""Derive an 8-char hex hash of the API key for the given model's provider.
|
|
227
|
+
|
|
228
|
+
Local providers (ollama, lmstudio, etc.) return ``""``.
|
|
229
|
+
"""
|
|
230
|
+
try:
|
|
231
|
+
provider = model_name.split("/", 1)[0].lower() if "/" in model_name else model_name.lower()
|
|
232
|
+
if provider in _LOCAL_PROVIDERS:
|
|
233
|
+
return ""
|
|
234
|
+
|
|
235
|
+
from .settings import settings
|
|
236
|
+
|
|
237
|
+
key_map: dict[str, str | None] = {
|
|
238
|
+
"openai": settings.openai_api_key,
|
|
239
|
+
"claude": settings.claude_api_key,
|
|
240
|
+
"google": settings.google_api_key,
|
|
241
|
+
"groq": settings.groq_api_key,
|
|
242
|
+
"grok": settings.grok_api_key,
|
|
243
|
+
"openrouter": settings.openrouter_api_key,
|
|
244
|
+
"azure": settings.azure_api_key,
|
|
245
|
+
"huggingface": settings.hf_token,
|
|
246
|
+
}
|
|
247
|
+
api_key = key_map.get(provider)
|
|
248
|
+
if not api_key:
|
|
249
|
+
return ""
|
|
250
|
+
return hashlib.sha256(api_key.encode()).hexdigest()[:8]
|
|
251
|
+
except Exception:
|
|
252
|
+
return ""
|
|
@@ -4,6 +4,7 @@ LICENSE
|
|
|
4
4
|
MANIFEST.in
|
|
5
5
|
README.md
|
|
6
6
|
ROADMAP.md
|
|
7
|
+
VERSION
|
|
7
8
|
pyproject.toml
|
|
8
9
|
requirements.txt
|
|
9
10
|
test.py
|
|
@@ -69,6 +70,7 @@ prompture/field_definitions.py
|
|
|
69
70
|
prompture/group_types.py
|
|
70
71
|
prompture/groups.py
|
|
71
72
|
prompture/image.py
|
|
73
|
+
prompture/ledger.py
|
|
72
74
|
prompture/logging.py
|
|
73
75
|
prompture/model_rates.py
|
|
74
76
|
prompture/persistence.py
|
|
File without changes
|
{prompture-0.0.39.dev1 → prompture-0.0.40}/.claude/skills/add-driver/references/driver-template.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|