pw-core 0.27.0__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.
- pw_core/__init__.py +27 -0
- pw_core/_env_compat.py +27 -0
- pw_core/_extras.py +120 -0
- pw_core/alembic.py +72 -0
- pw_core/budget/__init__.py +154 -0
- pw_core/budget/_aggregate.py +160 -0
- pw_core/budget/_schema.py +114 -0
- pw_core/budget/audit.py +81 -0
- pw_core/budget/check.py +488 -0
- pw_core/budget/config.py +237 -0
- pw_core/budget/repo.py +362 -0
- pw_core/budget/scheduler.py +72 -0
- pw_core/budget/snapshot_job.py +73 -0
- pw_core/cache/__init__.py +0 -0
- pw_core/cache/redis.py +298 -0
- pw_core/cli/__init__.py +198 -0
- pw_core/cli/export.py +86 -0
- pw_core/cli/jobs.py +261 -0
- pw_core/cli/subcommands/__init__.py +31 -0
- pw_core/cli/subcommands/_render.py +169 -0
- pw_core/cli/subcommands/baseline.py +155 -0
- pw_core/cli/subcommands/invalidate.py +88 -0
- pw_core/clients/__init__.py +38 -0
- pw_core/clients/_claude_code_wire.py +214 -0
- pw_core/clients/anthropic.py +489 -0
- pw_core/clients/brave.py +297 -0
- pw_core/clients/claude_code.py +453 -0
- pw_core/clients/openrouter.py +440 -0
- pw_core/clients/routing.py +167 -0
- pw_core/config.py +32 -0
- pw_core/db/__init__.py +89 -0
- pw_core/db/connection.py +239 -0
- pw_core/db/dialect.py +133 -0
- pw_core/db/helpers.py +68 -0
- pw_core/db/ids.py +85 -0
- pw_core/db/json_compat.py +216 -0
- pw_core/db/models.py +79 -0
- pw_core/db/repository.py +66 -0
- pw_core/db/soft_delete.py +122 -0
- pw_core/db/types.py +102 -0
- pw_core/db/upsert.py +176 -0
- pw_core/db/versioned_config.py +189 -0
- pw_core/docs/INSTALLATION.md +238 -0
- pw_core/docs/alembic.md +71 -0
- pw_core/docs/anthropic.md +223 -0
- pw_core/docs/article-fetch.md +211 -0
- pw_core/docs/brave.md +156 -0
- pw_core/docs/cache.md +213 -0
- pw_core/docs/claude-code.md +292 -0
- pw_core/docs/cli-subcommands.md +115 -0
- pw_core/docs/cli.md +107 -0
- pw_core/docs/config.md +71 -0
- pw_core/docs/cost-budget.md +309 -0
- pw_core/docs/database.md +357 -0
- pw_core/docs/db-dialect.md +133 -0
- pw_core/docs/db-ids.md +27 -0
- pw_core/docs/db-upsert.md +46 -0
- pw_core/docs/doctor.md +24 -0
- pw_core/docs/eval-harness.md +633 -0
- pw_core/docs/export.md +116 -0
- pw_core/docs/fetch-images.md +71 -0
- pw_core/docs/fetch.md +127 -0
- pw_core/docs/jobs-admin.md +60 -0
- pw_core/docs/jobs-runtime.md +77 -0
- pw_core/docs/jobs.md +395 -0
- pw_core/docs/json-recovery.md +149 -0
- pw_core/docs/llm-admin.md +178 -0
- pw_core/docs/llm-cache.md +315 -0
- pw_core/docs/llm-parse.md +146 -0
- pw_core/docs/llm-recording.md +97 -0
- pw_core/docs/llm-safe-apply.md +109 -0
- pw_core/docs/llm-schema-validation.md +546 -0
- pw_core/docs/llm-step.md +85 -0
- pw_core/docs/llm-tracked.md +135 -0
- pw_core/docs/llm-tracking.md +461 -0
- pw_core/docs/llm-validation.md +101 -0
- pw_core/docs/markdown.md +113 -0
- pw_core/docs/model-router.md +471 -0
- pw_core/docs/modules.md +195 -0
- pw_core/docs/openrouter.md +255 -0
- pw_core/docs/orchestrators.md +100 -0
- pw_core/docs/otel.md +167 -0
- pw_core/docs/pagination.md +140 -0
- pw_core/docs/parsers.md +88 -0
- pw_core/docs/phash.md +77 -0
- pw_core/docs/pipeline.md +299 -0
- pw_core/docs/pricing.md +46 -0
- pw_core/docs/project-portability.md +122 -0
- pw_core/docs/prompts.md +298 -0
- pw_core/docs/recipes/batch-llm-service.md +101 -0
- pw_core/docs/recipes/critic.md +90 -0
- pw_core/docs/recipes/job-refs-bridge.md +128 -0
- pw_core/docs/recipes/self-consistency.md +114 -0
- pw_core/docs/relative-dates.md +129 -0
- pw_core/docs/scaffold.md +44 -0
- pw_core/docs/similarity.md +103 -0
- pw_core/docs/soft-delete.md +106 -0
- pw_core/docs/test-migration.md +105 -0
- pw_core/docs/testing.md +219 -0
- pw_core/docs/urls.md +347 -0
- pw_core/docs/versioned-config.md +107 -0
- pw_core/docs/vocab.md +142 -0
- pw_core/docs/web.md +284 -0
- pw_core/doctor.py +237 -0
- pw_core/eval/__init__.py +105 -0
- pw_core/eval/_compare.py +214 -0
- pw_core/eval/_config.py +232 -0
- pw_core/eval/_experiments.py +147 -0
- pw_core/eval/_golden.py +245 -0
- pw_core/eval/_judge.py +171 -0
- pw_core/eval/_report.py +220 -0
- pw_core/eval/_runner.py +486 -0
- pw_core/eval/cli.py +273 -0
- pw_core/export/__init__.py +16 -0
- pw_core/export/markdown.py +298 -0
- pw_core/fetch/__init__.py +422 -0
- pw_core/fetch/_connect.py +174 -0
- pw_core/fetch/_decode.py +74 -0
- pw_core/fetch/images.py +384 -0
- pw_core/guards_rules.py +22 -0
- pw_core/jobs/__init__.py +69 -0
- pw_core/jobs/_repo_steps.py +214 -0
- pw_core/jobs/_repo_util.py +109 -0
- pw_core/jobs/_repo_worker.py +190 -0
- pw_core/jobs/_schema.py +143 -0
- pw_core/jobs/registry.py +256 -0
- pw_core/jobs/repo.py +361 -0
- pw_core/jobs/runtime.py +314 -0
- pw_core/jobs/submit.py +181 -0
- pw_core/jobs/workers.py +281 -0
- pw_core/llm/__init__.py +110 -0
- pw_core/llm/_json.py +35 -0
- pw_core/llm/_router_config.py +153 -0
- pw_core/llm/_router_loader.py +75 -0
- pw_core/llm/_router_schema.py +142 -0
- pw_core/llm/cache/__init__.py +246 -0
- pw_core/llm/cache/_recorder.py +121 -0
- pw_core/llm/cache/_schema.py +82 -0
- pw_core/llm/cache/config.py +201 -0
- pw_core/llm/cache/exact.py +247 -0
- pw_core/llm/cache/invalidate.py +118 -0
- pw_core/llm/parse.py +237 -0
- pw_core/llm/prompts.py +286 -0
- pw_core/llm/recording.py +82 -0
- pw_core/llm/router.py +332 -0
- pw_core/llm/safe_apply.py +186 -0
- pw_core/llm/step.py +260 -0
- pw_core/llm/tracked.py +468 -0
- pw_core/llm/tracking/__init__.py +122 -0
- pw_core/llm/tracking/_metadata.py +33 -0
- pw_core/llm/tracking/_otel_reader.py +90 -0
- pw_core/llm/tracking/_resolvers.py +259 -0
- pw_core/llm/tracking/decorator.py +198 -0
- pw_core/llm/tracking/otel.py +307 -0
- pw_core/llm/tracking/purge.py +71 -0
- pw_core/llm/tracking/repo.py +453 -0
- pw_core/llm/tracking/schema.py +400 -0
- pw_core/llm/tracking/stats.py +188 -0
- pw_core/llm/tracking/subrepos.py +274 -0
- pw_core/llm/url_check.py +74 -0
- pw_core/llm/validate/__init__.py +83 -0
- pw_core/llm/validate/_cross_field.py +59 -0
- pw_core/llm/validate/_jsonschema.py +67 -0
- pw_core/llm/validate/_pipeline.py +431 -0
- pw_core/llm/validate/_pydantic.py +65 -0
- pw_core/llm/validate/_registry.py +141 -0
- pw_core/llm/validate/_semantic.py +375 -0
- pw_core/orchestrators/__init__.py +5 -0
- pw_core/orchestrators/base.py +90 -0
- pw_core/parsers/__init__.py +47 -0
- pw_core/parsers/exceptions.py +33 -0
- pw_core/parsers/html.py +211 -0
- pw_core/parsers/types.py +31 -0
- pw_core/pipeline/__init__.py +13 -0
- pw_core/pipeline/baseline.py +244 -0
- pw_core/pipeline/baseline_diff.py +296 -0
- pw_core/pipeline/cache.py +156 -0
- pw_core/pipeline/resume.py +128 -0
- pw_core/pipeline/run_record.py +126 -0
- pw_core/pipeline/sequencer.py +159 -0
- pw_core/pricing/__init__.py +30 -0
- pw_core/pricing/_data.py +50 -0
- pw_core/pricing/_resolver.py +148 -0
- pw_core/pricing/_types.py +29 -0
- pw_core/py.typed +0 -0
- pw_core/rules/anti-patterns.md +112 -0
- pw_core/rules/layering.md +107 -0
- pw_core/rules/project-structure.md +127 -0
- pw_core/testing/__init__.py +20 -0
- pw_core/testing/_ddl.py +69 -0
- pw_core/testing/_server_db.py +129 -0
- pw_core/testing/db_fixtures.py +287 -0
- pw_core/testing/env.py +84 -0
- pw_core/testing/fixtures.py +69 -0
- pw_core/utils/__init__.py +63 -0
- pw_core/utils/_article_extract.py +307 -0
- pw_core/utils/article_fetch.py +331 -0
- pw_core/utils/http_tls.py +27 -0
- pw_core/utils/json_recovery.py +245 -0
- pw_core/utils/phash.py +168 -0
- pw_core/utils/relative_dates.py +258 -0
- pw_core/utils/similarity.py +71 -0
- pw_core/utils/url_html.py +119 -0
- pw_core/utils/url_liveness.py +226 -0
- pw_core/utils/url_parse.py +174 -0
- pw_core/utils/url_safety.py +155 -0
- pw_core/utils/urls.py +249 -0
- pw_core/utils/vocab.py +131 -0
- pw_core/web/__init__.py +15 -0
- pw_core/web/_error_handlers.py +154 -0
- pw_core/web/_error_page.py +131 -0
- pw_core/web/_validation_errors.py +82 -0
- pw_core/web/app_factory.py +169 -0
- pw_core/web/health.py +116 -0
- pw_core/web/helpers.py +30 -0
- pw_core/web/jobs_admin/__init__.py +194 -0
- pw_core/web/jobs_admin/templates/job_detail.html +122 -0
- pw_core/web/jobs_admin/templates/jobs_list.html +60 -0
- pw_core/web/json.py +55 -0
- pw_core/web/llm_admin/__init__.py +103 -0
- pw_core/web/llm_admin/_queries_jobs.py +78 -0
- pw_core/web/llm_admin/_queries_runs.py +229 -0
- pw_core/web/llm_admin/_queries_spend.py +170 -0
- pw_core/web/llm_admin/_queries_util.py +37 -0
- pw_core/web/llm_admin/api.py +179 -0
- pw_core/web/llm_admin/pages.py +185 -0
- pw_core/web/llm_admin/queries.py +62 -0
- pw_core/web/llm_admin/templates/base.html +60 -0
- pw_core/web/llm_admin/templates/budgets.html +62 -0
- pw_core/web/llm_admin/templates/cache.html +65 -0
- pw_core/web/llm_admin/templates/cost_by_agent.html +22 -0
- pw_core/web/llm_admin/templates/cost_by_model.html +24 -0
- pw_core/web/llm_admin/templates/dashboard.html +41 -0
- pw_core/web/llm_admin/templates/job_detail.html +60 -0
- pw_core/web/llm_admin/templates/jobs_list.html +47 -0
- pw_core/web/llm_admin/templates/macros.html +23 -0
- pw_core/web/llm_admin/templates/run_detail.html +114 -0
- pw_core/web/llm_admin/templates/runs_list.html +49 -0
- pw_core/web/markdown.py +245 -0
- pw_core/web/pagination.py +125 -0
- pw_core/web/rate_limit.py +115 -0
- pw_core/web/templates.py +59 -0
- pw_core-0.27.0.dist-info/METADATA +269 -0
- pw_core-0.27.0.dist-info/RECORD +249 -0
- pw_core-0.27.0.dist-info/WHEEL +5 -0
- pw_core-0.27.0.dist-info/entry_points.txt +7 -0
- pw_core-0.27.0.dist-info/licenses/LICENSE +202 -0
- pw_core-0.27.0.dist-info/licenses/NOTICE +4 -0
- pw_core-0.27.0.dist-info/top_level.txt +1 -0
pw_core/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""pw-core: the application framework on top of pw-foundation.
|
|
2
|
+
|
|
3
|
+
The base install (``pip install pw-core``) brings pw-foundation — structured logging, the
|
|
4
|
+
exception hierarchy, the config base and env resolvers, pure utils, ``Service``, the
|
|
5
|
+
``pw-guards`` gate — and adds pw-core's own dependency-light modules: the ``AppConfig``
|
|
6
|
+
subclass with the framework settings, json recovery, similarity, vocab, parsers, budget.
|
|
7
|
+
Consumers import the foundation as ``pw_foundation.*``; nothing is re-exported here.
|
|
8
|
+
|
|
9
|
+
Everything else ships as opt-in, orthogonally-composable extras: anti-slop output guards
|
|
10
|
+
(``[validate]``), LLM clients (``[llm]`` ⊇ ``[validate]``), HTTP utils (``[http]``), CLI
|
|
11
|
+
scaffolding (``[cli]``), and the FastAPI + SQLAlchemy app framework (``[db]``, ``[web]``,
|
|
12
|
+
``[jobs]``, ``[tracking]``, ``[eval]``, ``[admin]``). See ``docs/INSTALLATION.md``.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def __getattr__(name: str) -> str:
|
|
17
|
+
"""PEP 562: ``__version__`` reads package metadata on first access, not at import."""
|
|
18
|
+
if name != "__version__":
|
|
19
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
20
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
value = version("pw-core")
|
|
24
|
+
except PackageNotFoundError: # running from a source tree with no install
|
|
25
|
+
value = "0.0.0+unknown"
|
|
26
|
+
globals()["__version__"] = value
|
|
27
|
+
return value
|
pw_core/_env_compat.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""``PW_*`` env vars with the ``PF_*`` spelling honored until 1.0 (one DeprecationWarning each)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import warnings
|
|
7
|
+
|
|
8
|
+
_warned: set[str] = set()
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def env_name(pw_name: str) -> str:
|
|
12
|
+
"""The name to read: ``pw_name`` if set, else its ``PF_`` spelling if set, else ``pw_name``."""
|
|
13
|
+
if pw_name in os.environ or not pw_name.startswith("PW_"):
|
|
14
|
+
return pw_name
|
|
15
|
+
legacy = "PF_" + pw_name[3:]
|
|
16
|
+
if legacy in os.environ:
|
|
17
|
+
if legacy not in _warned:
|
|
18
|
+
_warned.add(legacy)
|
|
19
|
+
warnings.warn(
|
|
20
|
+
f"{legacy} is deprecated; set {pw_name}", DeprecationWarning, stacklevel=3
|
|
21
|
+
)
|
|
22
|
+
return legacy
|
|
23
|
+
return pw_name
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def getenv(pw_name: str, default: str | None = None) -> str | None:
|
|
27
|
+
return os.environ.get(env_name(pw_name), default)
|
pw_core/_extras.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Friendly errors for missing optional-dependency extras.
|
|
2
|
+
|
|
3
|
+
The foundation install (``pip install pw-core``) is dependency-light: it does
|
|
4
|
+
not ship httpx, pydantic, json-repair, tenacity, or typer. Modules that need
|
|
5
|
+
those live behind opt-in extras ([http], [llm], [cli], [jobs], ...). When such
|
|
6
|
+
a module is imported without its extra installed, the bare third-party
|
|
7
|
+
``ImportError`` ("No module named 'json_repair'") is opaque. This helper turns
|
|
8
|
+
it into a message that names the extra and the exact pip command.
|
|
9
|
+
|
|
10
|
+
Usage at the top of a gated leaf module::
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import httpx
|
|
14
|
+
except ImportError as e: # pragma: no cover - exercised by bare-install CI
|
|
15
|
+
from pw_core._extras import extra_import_error
|
|
16
|
+
|
|
17
|
+
raise extra_import_error(
|
|
18
|
+
"llm", "httpx", feature="pw_core.clients.openrouter"
|
|
19
|
+
) from e
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
# Extra name -> the pip target a user should install. Anything not listed
|
|
25
|
+
# falls back to ``pw-core[<extra>]``.
|
|
26
|
+
_INSTALL: dict[str, str] = {
|
|
27
|
+
"http": "pw-core[http]",
|
|
28
|
+
"cli": "pw-core[cli]",
|
|
29
|
+
"validate": "pw-core[validate]",
|
|
30
|
+
"llm": "pw-core[llm]",
|
|
31
|
+
"db": "pw-core[db]",
|
|
32
|
+
"web": "pw-core[web]",
|
|
33
|
+
"jobs": "pw-core[jobs]",
|
|
34
|
+
"tracking": "pw-core[tracking]",
|
|
35
|
+
"eval": "pw-core[eval]",
|
|
36
|
+
"otel": "pw-core[otel]",
|
|
37
|
+
"admin": "pw-core[admin]",
|
|
38
|
+
"articles": "pw-core[articles]",
|
|
39
|
+
"jsonschema": "pw-core[jsonschema]",
|
|
40
|
+
"redis": "pw-core[redis]",
|
|
41
|
+
"ratelimit": "pw-core[ratelimit]",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Module prefix -> the extra needed to import it. Longest prefix wins, so a
|
|
46
|
+
# subpackage may sit above its parent. Unlisted means base install: no extras.
|
|
47
|
+
# Enforced against the source tree by tests/test_extras_tiers.py.
|
|
48
|
+
_MODULE_EXTRA: dict[str, str] = {
|
|
49
|
+
"pw_core.alembic": "db",
|
|
50
|
+
"pw_core.budget._aggregate": "tracking",
|
|
51
|
+
"pw_core.budget._schema": "tracking",
|
|
52
|
+
"pw_core.budget.audit": "tracking",
|
|
53
|
+
"pw_core.budget.repo": "tracking",
|
|
54
|
+
"pw_core.budget.scheduler": "tracking",
|
|
55
|
+
"pw_core.budget.snapshot_job": "tracking",
|
|
56
|
+
"pw_core.cli": "cli",
|
|
57
|
+
"pw_core.cli.export": "otel",
|
|
58
|
+
"pw_core.cli.jobs": "jobs",
|
|
59
|
+
"pw_core.clients.anthropic": "anthropic",
|
|
60
|
+
"pw_core.clients.brave": "llm",
|
|
61
|
+
"pw_core.clients.openrouter": "llm",
|
|
62
|
+
"pw_core.db": "db",
|
|
63
|
+
"pw_core.eval": "eval",
|
|
64
|
+
# Minimum tier to import, not the extra you'd install for the feature:
|
|
65
|
+
# jobs modules import under [tracking]; only the CLI needs typer.
|
|
66
|
+
"pw_core.jobs": "tracking",
|
|
67
|
+
"pw_core.llm.cache": "tracking",
|
|
68
|
+
"pw_core.llm.parse": "validate",
|
|
69
|
+
"pw_core.llm.step": "tracking",
|
|
70
|
+
"pw_core.llm.tracked": "tracking",
|
|
71
|
+
"pw_core.llm.tracking": "tracking",
|
|
72
|
+
"pw_core.llm.tracking.otel": "otel",
|
|
73
|
+
"pw_core.llm.validate": "validate",
|
|
74
|
+
# [crawl] = [http,articles]: article_fetch imports utils.urls, which needs httpx.
|
|
75
|
+
"pw_core.utils.article_fetch": "crawl",
|
|
76
|
+
"pw_core.utils.phash": "image-phash",
|
|
77
|
+
"pw_core.utils.url_liveness": "http",
|
|
78
|
+
"pw_core.utils.urls": "http",
|
|
79
|
+
"pw_core.web": "web",
|
|
80
|
+
# Both dashboards need [web] + a DB tier; [admin] supplies both.
|
|
81
|
+
"pw_core.web.jobs_admin": "admin",
|
|
82
|
+
"pw_core.web.llm_admin": "admin",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def required_extra(module: str) -> str | None:
|
|
87
|
+
"""The extra needed to import *module*, or ``None`` if it is base-install.
|
|
88
|
+
|
|
89
|
+
Longest matching prefix wins, so ``pw_core.llm.tracking.repo`` resolves to
|
|
90
|
+
``tracking`` rather than to whatever ``pw_core.llm`` would give.
|
|
91
|
+
"""
|
|
92
|
+
best: str | None = None
|
|
93
|
+
best_len = -1
|
|
94
|
+
for prefix, extra in _MODULE_EXTRA.items():
|
|
95
|
+
if (module == prefix or module.startswith(prefix + ".")) and len(prefix) > best_len:
|
|
96
|
+
best, best_len = extra, len(prefix)
|
|
97
|
+
return best
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def install_target(extra: str) -> str:
|
|
101
|
+
"""Return the ``pip install`` target for an extra (e.g. ``pw-core[llm]``)."""
|
|
102
|
+
return _INSTALL.get(extra, f"pw-core[{extra}]")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def extra_import_error(extra: str, package: str, *, feature: str) -> ImportError:
|
|
106
|
+
"""Build an ``ImportError`` that names the missing extra and pip command.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
extra: The optional-dependency extra that ships ``package`` (e.g. ``"llm"``).
|
|
110
|
+
package: The third-party import name that failed (e.g. ``"json_repair"``).
|
|
111
|
+
feature: The pw-core module or capability the caller was importing, used
|
|
112
|
+
in the message (e.g. ``"pw_core.llm.parse"``).
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
An ``ImportError`` to ``raise ... from`` the original failure.
|
|
116
|
+
"""
|
|
117
|
+
return ImportError(
|
|
118
|
+
f"{feature} requires the '{extra}' extra; '{package}' is not installed. "
|
|
119
|
+
f"Install it with: pip install {install_target(extra)}"
|
|
120
|
+
)
|
pw_core/alembic.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Alembic migration helper — shared env.py logic for all projects.
|
|
3
|
+
|
|
4
|
+
Supports SQLite (batch mode), MySQL/MariaDB, and PostgreSQL. Uses pw_core.db
|
|
5
|
+
for engine management so migrations share the same connection config as the app.
|
|
6
|
+
|
|
7
|
+
Usage in a project's ``alembic/env.py``::
|
|
8
|
+
|
|
9
|
+
from pw_core.alembic import run_migrations_online
|
|
10
|
+
|
|
11
|
+
run_migrations_online()
|
|
12
|
+
|
|
13
|
+
With SQLite fallback (e.g., a small app on SQLite)::
|
|
14
|
+
|
|
15
|
+
from pw_core.alembic import run_migrations_online
|
|
16
|
+
|
|
17
|
+
run_migrations_online(fallback_sqlite="app.db")
|
|
18
|
+
|
|
19
|
+
With explicit metadata (for autogenerate)::
|
|
20
|
+
|
|
21
|
+
from myapp.models import Base
|
|
22
|
+
from pw_core.alembic import run_migrations_online
|
|
23
|
+
|
|
24
|
+
run_migrations_online(target_metadata=Base.metadata)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from alembic import context
|
|
32
|
+
|
|
33
|
+
from pw_core.db import db_url, get_engine, is_sqlite
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_migrations_online(
|
|
37
|
+
*,
|
|
38
|
+
fallback_sqlite: str = "",
|
|
39
|
+
target_metadata: Any = None,
|
|
40
|
+
compare_type: bool = False,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Run Alembic migrations in online mode.
|
|
43
|
+
|
|
44
|
+
Call this from your project's ``alembic/env.py``. Handles:
|
|
45
|
+
- SQLite batch mode (``render_as_batch=True``)
|
|
46
|
+
- MySQL/MariaDB and PostgreSQL standard mode
|
|
47
|
+
- Engine reuse via ``pw_core.db.get_engine()``
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
fallback_sqlite: Path to SQLite file if DATABASE_URL is not set.
|
|
51
|
+
target_metadata: SQLAlchemy MetaData for autogenerate support.
|
|
52
|
+
Pass ``None`` (default) for raw-SQL migrations.
|
|
53
|
+
compare_type: Whether Alembic should detect column type changes.
|
|
54
|
+
"""
|
|
55
|
+
if context.is_offline_mode():
|
|
56
|
+
raise RuntimeError(
|
|
57
|
+
"Offline mode is not supported. Set DATABASE_URL and run in online mode."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
url = db_url(fallback_sqlite=fallback_sqlite)
|
|
61
|
+
sqlite = is_sqlite(url)
|
|
62
|
+
engine = get_engine(url)
|
|
63
|
+
|
|
64
|
+
with engine.connect() as connection:
|
|
65
|
+
context.configure(
|
|
66
|
+
connection=connection,
|
|
67
|
+
target_metadata=target_metadata,
|
|
68
|
+
compare_type=compare_type,
|
|
69
|
+
render_as_batch=sqlite,
|
|
70
|
+
)
|
|
71
|
+
with context.begin_transaction():
|
|
72
|
+
context.run_migrations()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pre-call cost guardrails for LLM spending.
|
|
3
|
+
|
|
4
|
+
The kernel-safe surface (importable without ``pw-core[db]``) covers the
|
|
5
|
+
budget guard itself and YAML config loading:
|
|
6
|
+
|
|
7
|
+
from pw_core.budget import (
|
|
8
|
+
check_budget, project_cost, CostBudgetExceeded,
|
|
9
|
+
compute_period_start, compute_period_end, current_spent, current_usage,
|
|
10
|
+
load_yaml, clear_config_cache,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
The DB-backed surface (requires ``pw-core[db]``) is loaded lazily — the
|
|
14
|
+
import only triggers SQLAlchemy when an attribute is first accessed:
|
|
15
|
+
|
|
16
|
+
from pw_core.budget import (
|
|
17
|
+
BudgetRepo, BudgetSnapshotRepo, CostRateRepo, aggregate_spent,
|
|
18
|
+
aggregate_usage, sync_budgets_from_yaml,
|
|
19
|
+
refresh_snapshots, start_budget_refresh_loop,
|
|
20
|
+
record_blocked_run, record_override,
|
|
21
|
+
ALL_BUDGET_TABLES, llm_budgets, llm_budget_snapshots, llm_cost_rates,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
In a kernel-only install (no ``[db]`` extra), ``check_budget()`` and
|
|
25
|
+
``project_cost()`` still import and run, but ``project_cost()`` falls back
|
|
26
|
+
to ``0.0`` when no DB-backed cost rates are reachable, and any code path
|
|
27
|
+
that calls into the repos will surface ``ModuleNotFoundError: sqlalchemy``.
|
|
28
|
+
|
|
29
|
+
See ``docs/cost-budget.md``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from typing import TYPE_CHECKING
|
|
35
|
+
|
|
36
|
+
# Eager imports — kernel-safe modules with no top-level sqlalchemy coupling.
|
|
37
|
+
# These remain importable in a base ``pip install pw-core`` (no extras).
|
|
38
|
+
from pw_core.budget.check import ( # noqa: F401
|
|
39
|
+
CostBudgetExceeded,
|
|
40
|
+
check_budget,
|
|
41
|
+
compute_period_end,
|
|
42
|
+
compute_period_start,
|
|
43
|
+
current_spent,
|
|
44
|
+
current_usage,
|
|
45
|
+
project_cost,
|
|
46
|
+
)
|
|
47
|
+
from pw_core.budget.config import ( # noqa: F401
|
|
48
|
+
clear_config_cache,
|
|
49
|
+
load_yaml,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Lazy imports — these submodules pull SQLAlchemy at module top, so we
|
|
54
|
+
# defer them via PEP 562 ``__getattr__`` to keep the kernel install clean.
|
|
55
|
+
# The first attribute access triggers the import; subsequent accesses are
|
|
56
|
+
# free because we cache into ``globals()``.
|
|
57
|
+
_LAZY: dict[str, str] = {
|
|
58
|
+
# Repos (DB-required)
|
|
59
|
+
"BudgetRepo": "pw_core.budget.repo",
|
|
60
|
+
"BudgetSnapshotRepo": "pw_core.budget.repo",
|
|
61
|
+
"CostRateRepo": "pw_core.budget.repo",
|
|
62
|
+
"aggregate_spent": "pw_core.budget._aggregate",
|
|
63
|
+
"aggregate_usage": "pw_core.budget._aggregate",
|
|
64
|
+
# Audit logging (DB-required)
|
|
65
|
+
"record_blocked_run": "pw_core.budget.audit",
|
|
66
|
+
"record_override": "pw_core.budget.audit",
|
|
67
|
+
# Snapshot / scheduler jobs (DB-required)
|
|
68
|
+
"refresh_snapshots": "pw_core.budget.snapshot_job",
|
|
69
|
+
"start_budget_refresh_loop": "pw_core.budget.scheduler",
|
|
70
|
+
# YAML→DB sync (DB-required path within the otherwise-kernel config module)
|
|
71
|
+
"sync_budgets_from_yaml": "pw_core.budget.config",
|
|
72
|
+
# Schema tables (DB-required — registers on shared MetaData on first access)
|
|
73
|
+
"ALL_BUDGET_TABLES": "pw_core.budget._schema",
|
|
74
|
+
"llm_budgets": "pw_core.budget._schema",
|
|
75
|
+
"llm_budget_snapshots": "pw_core.budget._schema",
|
|
76
|
+
"llm_cost_rates": "pw_core.budget._schema",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def __getattr__(name: str):
|
|
81
|
+
"""Lazy import for DB-required attributes (PEP 562)."""
|
|
82
|
+
target = _LAZY.get(name)
|
|
83
|
+
if target is None:
|
|
84
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
85
|
+
import importlib
|
|
86
|
+
|
|
87
|
+
mod = importlib.import_module(target)
|
|
88
|
+
value = getattr(mod, name)
|
|
89
|
+
globals()[name] = value # cache so subsequent access skips __getattr__
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def __dir__() -> list[str]:
|
|
94
|
+
"""Expose lazy attributes to ``dir()`` and IDE autocomplete."""
|
|
95
|
+
return sorted(set(globals()) | set(_LAZY))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Type-checker view of the lazy attributes — keeps ``mypy`` / IDEs happy
|
|
99
|
+
# without paying the import cost at runtime.
|
|
100
|
+
if TYPE_CHECKING:
|
|
101
|
+
from pw_core.budget._schema import ( # noqa: F401
|
|
102
|
+
ALL_BUDGET_TABLES,
|
|
103
|
+
llm_budget_snapshots,
|
|
104
|
+
llm_budgets,
|
|
105
|
+
llm_cost_rates,
|
|
106
|
+
)
|
|
107
|
+
from pw_core.budget.audit import ( # noqa: F401
|
|
108
|
+
record_blocked_run,
|
|
109
|
+
record_override,
|
|
110
|
+
)
|
|
111
|
+
from pw_core.budget.config import sync_budgets_from_yaml # noqa: F401
|
|
112
|
+
from pw_core.budget.repo import ( # noqa: F401
|
|
113
|
+
BudgetRepo,
|
|
114
|
+
BudgetSnapshotRepo,
|
|
115
|
+
CostRateRepo,
|
|
116
|
+
aggregate_spent,
|
|
117
|
+
aggregate_usage,
|
|
118
|
+
)
|
|
119
|
+
from pw_core.budget.scheduler import start_budget_refresh_loop # noqa: F401
|
|
120
|
+
from pw_core.budget.snapshot_job import refresh_snapshots # noqa: F401
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
__all__ = [
|
|
124
|
+
# Guard (eager)
|
|
125
|
+
"check_budget",
|
|
126
|
+
"project_cost",
|
|
127
|
+
"CostBudgetExceeded",
|
|
128
|
+
"compute_period_start",
|
|
129
|
+
"compute_period_end",
|
|
130
|
+
"current_spent",
|
|
131
|
+
"current_usage",
|
|
132
|
+
# Config — kernel-safe (eager)
|
|
133
|
+
"load_yaml",
|
|
134
|
+
"clear_config_cache",
|
|
135
|
+
# Config — DB-backed (lazy)
|
|
136
|
+
"sync_budgets_from_yaml",
|
|
137
|
+
# Repos (lazy)
|
|
138
|
+
"BudgetRepo",
|
|
139
|
+
"BudgetSnapshotRepo",
|
|
140
|
+
"CostRateRepo",
|
|
141
|
+
"aggregate_spent",
|
|
142
|
+
"aggregate_usage",
|
|
143
|
+
# Snapshot / scheduler jobs (lazy)
|
|
144
|
+
"refresh_snapshots",
|
|
145
|
+
"start_budget_refresh_loop",
|
|
146
|
+
# Audit (lazy)
|
|
147
|
+
"record_blocked_run",
|
|
148
|
+
"record_override",
|
|
149
|
+
# Schema (lazy)
|
|
150
|
+
"ALL_BUDGET_TABLES",
|
|
151
|
+
"llm_budgets",
|
|
152
|
+
"llm_budget_snapshots",
|
|
153
|
+
"llm_cost_rates",
|
|
154
|
+
]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Scope filtering and usage aggregation over ``llm_runs``.
|
|
2
|
+
|
|
3
|
+
Re-exported from :mod:`pw_core.budget.repo` and ``pw_core.budget``.
|
|
4
|
+
See ``docs/cost-budget.md``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as dt
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import and_, func, select
|
|
13
|
+
|
|
14
|
+
from pw_core.db.types import server_now
|
|
15
|
+
from pw_foundation.exceptions import DataError, InvalidInputError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Spent aggregation
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def apply_scope_filter(q, budget: dict):
|
|
24
|
+
"""Restrict an ``llm_runs`` query to the runs a budget's scope covers.
|
|
25
|
+
|
|
26
|
+
The single definition — snapshot aggregate and live delta must not
|
|
27
|
+
disagree about what a budget counts.
|
|
28
|
+
|
|
29
|
+
Raises:
|
|
30
|
+
InvalidInputError: unrecognised ``scope_kind``.
|
|
31
|
+
"""
|
|
32
|
+
from pw_core.llm.tracking.schema import (
|
|
33
|
+
llm_agent_types,
|
|
34
|
+
llm_run_tags,
|
|
35
|
+
llm_runs,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
scope_kind = budget["scope_kind"]
|
|
39
|
+
scope_value = budget.get("scope_value")
|
|
40
|
+
|
|
41
|
+
if scope_kind == "global":
|
|
42
|
+
return q
|
|
43
|
+
if scope_kind == "agent":
|
|
44
|
+
return q.join(llm_agent_types, llm_runs.c.agent_type_id == llm_agent_types.c.id).where(
|
|
45
|
+
llm_agent_types.c.slug == scope_value
|
|
46
|
+
)
|
|
47
|
+
if scope_kind == "job_kind":
|
|
48
|
+
from pw_core.jobs._schema import jobs
|
|
49
|
+
|
|
50
|
+
return q.join(jobs, llm_runs.c.job_id == jobs.c.id).where(jobs.c.kind == scope_value)
|
|
51
|
+
if scope_kind == "job_id":
|
|
52
|
+
if scope_value is None:
|
|
53
|
+
raise InvalidInputError("budget scope 'job_id' requires a scope_value")
|
|
54
|
+
return q.where(llm_runs.c.job_id == int(scope_value))
|
|
55
|
+
if scope_kind == "tag":
|
|
56
|
+
return q.join(llm_run_tags, llm_runs.c.id == llm_run_tags.c.llm_run_id).where(
|
|
57
|
+
llm_run_tags.c.tag == scope_value
|
|
58
|
+
)
|
|
59
|
+
raise InvalidInputError(f"unknown budget scope_kind: {scope_kind!r}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def db_now(conn=None) -> dt.datetime:
|
|
63
|
+
"""Read the clock ``llm_runs.created_at`` is stamped from.
|
|
64
|
+
|
|
65
|
+
Cutoffs compared against server-stamped rows must come from the same
|
|
66
|
+
clock — a Python-side UTC value skews on MySQL TIMESTAMP columns.
|
|
67
|
+
"""
|
|
68
|
+
if conn is not None:
|
|
69
|
+
return _read_now(conn)
|
|
70
|
+
from pw_core.db.connection import transaction
|
|
71
|
+
|
|
72
|
+
with transaction() as c:
|
|
73
|
+
return _read_now(c)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _read_now(conn: Any) -> dt.datetime:
|
|
77
|
+
value = conn.execute(select(server_now())).scalar()
|
|
78
|
+
if value is None: # pragma: no cover - a scalar clock select always yields a row
|
|
79
|
+
raise DataError("database clock query returned no row")
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def usage_columns() -> tuple[Any, Any, Any]:
|
|
84
|
+
"""The three usage aggregates in (usd, tokens, calls) order — one definition
|
|
85
|
+
for the snapshot aggregate and the live delta.
|
|
86
|
+
|
|
87
|
+
``prompt_tokens`` excludes cached input, so the token total adds the cache
|
|
88
|
+
columns back to count what the call actually consumed.
|
|
89
|
+
"""
|
|
90
|
+
from pw_core.llm.tracking.schema import llm_runs
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
func.coalesce(func.sum(llm_runs.c.cost_usd), 0),
|
|
94
|
+
func.coalesce(
|
|
95
|
+
func.sum(
|
|
96
|
+
func.coalesce(llm_runs.c.prompt_tokens, 0)
|
|
97
|
+
+ func.coalesce(llm_runs.c.completion_tokens, 0)
|
|
98
|
+
+ func.coalesce(llm_runs.c.cache_read_tokens, 0)
|
|
99
|
+
+ func.coalesce(llm_runs.c.cache_write_tokens, 0)
|
|
100
|
+
),
|
|
101
|
+
0,
|
|
102
|
+
),
|
|
103
|
+
func.count(llm_runs.c.id),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def aggregate_usage(
|
|
108
|
+
*,
|
|
109
|
+
budget: dict,
|
|
110
|
+
period_start: dt.date,
|
|
111
|
+
period_end: dt.date,
|
|
112
|
+
cutoff: dt.datetime | None = None,
|
|
113
|
+
conn=None,
|
|
114
|
+
) -> dict[str, float | int]:
|
|
115
|
+
"""Aggregate the runs matching a budget's scope in [period_start, period_end).
|
|
116
|
+
|
|
117
|
+
Returns ``{"usd": float, "tokens": int, "calls": int}`` — cost sum, token
|
|
118
|
+
sum over all four token columns (uncached prompt, completion, cache read,
|
|
119
|
+
cache write), and run count. Excludes rows with
|
|
120
|
+
``status IN ('cache_hit', 'budget_blocked')``. ``cutoff`` bounds the
|
|
121
|
+
aggregate to ``created_at < cutoff``; store it as the snapshot's
|
|
122
|
+
``last_updated`` so the live delta resumes exactly there.
|
|
123
|
+
"""
|
|
124
|
+
from pw_core.llm.tracking.schema import llm_runs
|
|
125
|
+
|
|
126
|
+
def _run(c):
|
|
127
|
+
q = select(*usage_columns()).where(
|
|
128
|
+
and_(
|
|
129
|
+
llm_runs.c.created_at >= period_start,
|
|
130
|
+
llm_runs.c.created_at < period_end,
|
|
131
|
+
llm_runs.c.status.notin_(["cache_hit", "budget_blocked"]),
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
if cutoff is not None:
|
|
135
|
+
q = q.where(llm_runs.c.created_at < cutoff)
|
|
136
|
+
|
|
137
|
+
usd, tokens, n = c.execute(apply_scope_filter(q, budget)).fetchone()
|
|
138
|
+
return {"usd": float(usd or 0), "tokens": int(tokens or 0), "calls": int(n or 0)}
|
|
139
|
+
|
|
140
|
+
if conn is not None:
|
|
141
|
+
return _run(conn)
|
|
142
|
+
from pw_core.db.connection import transaction
|
|
143
|
+
|
|
144
|
+
with transaction() as c:
|
|
145
|
+
return _run(c)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def aggregate_spent(
|
|
149
|
+
*,
|
|
150
|
+
budget: dict,
|
|
151
|
+
period_start: dt.date,
|
|
152
|
+
period_end: dt.date,
|
|
153
|
+
cutoff: dt.datetime | None = None,
|
|
154
|
+
conn=None,
|
|
155
|
+
) -> tuple[float, int]:
|
|
156
|
+
"""Backward-compatible ``(cost_usd sum, run count)`` view of :func:`aggregate_usage`."""
|
|
157
|
+
usage = aggregate_usage(
|
|
158
|
+
budget=budget, period_start=period_start, period_end=period_end, cutoff=cutoff, conn=conn
|
|
159
|
+
)
|
|
160
|
+
return (float(usage["usd"]), int(usage["calls"]))
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLAlchemy schema for pw-core cost budgets.
|
|
3
|
+
|
|
4
|
+
Defines three framework-owned tables that enforce per-agent, per-job, and
|
|
5
|
+
per-tag cost caps on LLM calls:
|
|
6
|
+
|
|
7
|
+
- ``llm_budgets`` — authoritative budget definitions (one row per scope+period)
|
|
8
|
+
- ``llm_budget_snapshots`` — periodic aggregate cache for fast pre-call checks
|
|
9
|
+
- ``llm_cost_rates`` — per-model price list for projecting call cost
|
|
10
|
+
|
|
11
|
+
Shares the ``metadata`` object from ``pw_core.llm.tracking.schema`` so a
|
|
12
|
+
single ``metadata.create_all()`` creates tracking, jobs, cache, and budget
|
|
13
|
+
tables in one pass.
|
|
14
|
+
|
|
15
|
+
See ``docs/cost-budget.md`` for the full reference.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from sqlalchemy import (
|
|
21
|
+
BigInteger,
|
|
22
|
+
Boolean,
|
|
23
|
+
Column,
|
|
24
|
+
Date,
|
|
25
|
+
ForeignKey,
|
|
26
|
+
Index,
|
|
27
|
+
Integer,
|
|
28
|
+
Numeric,
|
|
29
|
+
PrimaryKeyConstraint,
|
|
30
|
+
String,
|
|
31
|
+
Table,
|
|
32
|
+
UniqueConstraint,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
from pw_core.db.types import FK_SMALL, JSON_, PK_SMALL, TIMESTAMP_US, server_now
|
|
36
|
+
from pw_core.llm.tracking.schema import metadata
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Tables
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
llm_budgets = Table(
|
|
43
|
+
"llm_budgets",
|
|
44
|
+
metadata,
|
|
45
|
+
Column("id", PK_SMALL, primary_key=True, autoincrement=True),
|
|
46
|
+
Column("scope_kind", String(32), nullable=False),
|
|
47
|
+
Column("scope_value", String(128), nullable=True),
|
|
48
|
+
Column("period", String(16), nullable=False),
|
|
49
|
+
Column("limit_usd", Numeric(12, 4), nullable=True),
|
|
50
|
+
Column("limit_tokens", BigInteger, nullable=True),
|
|
51
|
+
Column("limit_calls", Integer, nullable=True),
|
|
52
|
+
Column("soft_thresholds", JSON_, nullable=True),
|
|
53
|
+
Column("hard_cap", Boolean, nullable=False, server_default="1"),
|
|
54
|
+
Column("action", String(32), nullable=False, server_default="block"),
|
|
55
|
+
Column("enabled", Boolean, nullable=False, server_default="1"),
|
|
56
|
+
Column("created_at", TIMESTAMP_US, nullable=False, server_default=server_now()),
|
|
57
|
+
Column("updated_at", TIMESTAMP_US, nullable=False, server_default=server_now()),
|
|
58
|
+
UniqueConstraint("scope_kind", "scope_value", "period", name="uq_llm_budgets_scope_period"),
|
|
59
|
+
Index("idx_llm_budgets_enabled", "enabled"),
|
|
60
|
+
)
|
|
61
|
+
"""Budget definitions. scope_kind ∈ {global, agent, job_kind, job_id, tag}."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
llm_budget_snapshots = Table(
|
|
65
|
+
"llm_budget_snapshots",
|
|
66
|
+
metadata,
|
|
67
|
+
Column(
|
|
68
|
+
"budget_id",
|
|
69
|
+
FK_SMALL,
|
|
70
|
+
ForeignKey("llm_budgets.id", ondelete="CASCADE"),
|
|
71
|
+
nullable=False,
|
|
72
|
+
),
|
|
73
|
+
Column("period_start", Date, nullable=False),
|
|
74
|
+
Column("spent_usd", Numeric(12, 4), nullable=False, server_default="0"),
|
|
75
|
+
Column("spent_tokens", BigInteger, nullable=False, server_default="0"),
|
|
76
|
+
Column("run_count", Integer, nullable=False, server_default="0"),
|
|
77
|
+
Column("last_updated", TIMESTAMP_US, nullable=False, server_default=server_now()),
|
|
78
|
+
PrimaryKeyConstraint("budget_id", "period_start", name="pk_llm_budget_snapshots"),
|
|
79
|
+
Index("idx_llm_budget_snapshots_period", "period_start"),
|
|
80
|
+
)
|
|
81
|
+
"""Periodic aggregate cache. Not source of truth — rebuilt from llm_runs."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
llm_cost_rates = Table(
|
|
85
|
+
"llm_cost_rates",
|
|
86
|
+
metadata,
|
|
87
|
+
Column(
|
|
88
|
+
"model_id",
|
|
89
|
+
FK_SMALL,
|
|
90
|
+
ForeignKey("llm_models.id", ondelete="CASCADE"),
|
|
91
|
+
nullable=False,
|
|
92
|
+
),
|
|
93
|
+
Column("input_per_1k", Numeric(8, 6), nullable=False),
|
|
94
|
+
Column("output_per_1k", Numeric(8, 6), nullable=False),
|
|
95
|
+
Column("cache_read_per_1k", Numeric(8, 6), nullable=True),
|
|
96
|
+
Column("cache_write_per_1k", Numeric(8, 6), nullable=True),
|
|
97
|
+
Column("reasoning_per_1k", Numeric(8, 6), nullable=True),
|
|
98
|
+
Column("effective_from", Date, nullable=False),
|
|
99
|
+
Column("effective_to", Date, nullable=True),
|
|
100
|
+
PrimaryKeyConstraint("model_id", "effective_from", name="pk_llm_cost_rates"),
|
|
101
|
+
Index("idx_llm_cost_rates_model_eff", "model_id", "effective_from"),
|
|
102
|
+
)
|
|
103
|
+
"""Per-model price list. Multiple rows per model allowed (versioned pricing)."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Public table list (in dependency order)
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
ALL_BUDGET_TABLES = (
|
|
111
|
+
llm_budgets,
|
|
112
|
+
llm_budget_snapshots,
|
|
113
|
+
llm_cost_rates,
|
|
114
|
+
)
|