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.
Files changed (249) hide show
  1. pw_core/__init__.py +27 -0
  2. pw_core/_env_compat.py +27 -0
  3. pw_core/_extras.py +120 -0
  4. pw_core/alembic.py +72 -0
  5. pw_core/budget/__init__.py +154 -0
  6. pw_core/budget/_aggregate.py +160 -0
  7. pw_core/budget/_schema.py +114 -0
  8. pw_core/budget/audit.py +81 -0
  9. pw_core/budget/check.py +488 -0
  10. pw_core/budget/config.py +237 -0
  11. pw_core/budget/repo.py +362 -0
  12. pw_core/budget/scheduler.py +72 -0
  13. pw_core/budget/snapshot_job.py +73 -0
  14. pw_core/cache/__init__.py +0 -0
  15. pw_core/cache/redis.py +298 -0
  16. pw_core/cli/__init__.py +198 -0
  17. pw_core/cli/export.py +86 -0
  18. pw_core/cli/jobs.py +261 -0
  19. pw_core/cli/subcommands/__init__.py +31 -0
  20. pw_core/cli/subcommands/_render.py +169 -0
  21. pw_core/cli/subcommands/baseline.py +155 -0
  22. pw_core/cli/subcommands/invalidate.py +88 -0
  23. pw_core/clients/__init__.py +38 -0
  24. pw_core/clients/_claude_code_wire.py +214 -0
  25. pw_core/clients/anthropic.py +489 -0
  26. pw_core/clients/brave.py +297 -0
  27. pw_core/clients/claude_code.py +453 -0
  28. pw_core/clients/openrouter.py +440 -0
  29. pw_core/clients/routing.py +167 -0
  30. pw_core/config.py +32 -0
  31. pw_core/db/__init__.py +89 -0
  32. pw_core/db/connection.py +239 -0
  33. pw_core/db/dialect.py +133 -0
  34. pw_core/db/helpers.py +68 -0
  35. pw_core/db/ids.py +85 -0
  36. pw_core/db/json_compat.py +216 -0
  37. pw_core/db/models.py +79 -0
  38. pw_core/db/repository.py +66 -0
  39. pw_core/db/soft_delete.py +122 -0
  40. pw_core/db/types.py +102 -0
  41. pw_core/db/upsert.py +176 -0
  42. pw_core/db/versioned_config.py +189 -0
  43. pw_core/docs/INSTALLATION.md +238 -0
  44. pw_core/docs/alembic.md +71 -0
  45. pw_core/docs/anthropic.md +223 -0
  46. pw_core/docs/article-fetch.md +211 -0
  47. pw_core/docs/brave.md +156 -0
  48. pw_core/docs/cache.md +213 -0
  49. pw_core/docs/claude-code.md +292 -0
  50. pw_core/docs/cli-subcommands.md +115 -0
  51. pw_core/docs/cli.md +107 -0
  52. pw_core/docs/config.md +71 -0
  53. pw_core/docs/cost-budget.md +309 -0
  54. pw_core/docs/database.md +357 -0
  55. pw_core/docs/db-dialect.md +133 -0
  56. pw_core/docs/db-ids.md +27 -0
  57. pw_core/docs/db-upsert.md +46 -0
  58. pw_core/docs/doctor.md +24 -0
  59. pw_core/docs/eval-harness.md +633 -0
  60. pw_core/docs/export.md +116 -0
  61. pw_core/docs/fetch-images.md +71 -0
  62. pw_core/docs/fetch.md +127 -0
  63. pw_core/docs/jobs-admin.md +60 -0
  64. pw_core/docs/jobs-runtime.md +77 -0
  65. pw_core/docs/jobs.md +395 -0
  66. pw_core/docs/json-recovery.md +149 -0
  67. pw_core/docs/llm-admin.md +178 -0
  68. pw_core/docs/llm-cache.md +315 -0
  69. pw_core/docs/llm-parse.md +146 -0
  70. pw_core/docs/llm-recording.md +97 -0
  71. pw_core/docs/llm-safe-apply.md +109 -0
  72. pw_core/docs/llm-schema-validation.md +546 -0
  73. pw_core/docs/llm-step.md +85 -0
  74. pw_core/docs/llm-tracked.md +135 -0
  75. pw_core/docs/llm-tracking.md +461 -0
  76. pw_core/docs/llm-validation.md +101 -0
  77. pw_core/docs/markdown.md +113 -0
  78. pw_core/docs/model-router.md +471 -0
  79. pw_core/docs/modules.md +195 -0
  80. pw_core/docs/openrouter.md +255 -0
  81. pw_core/docs/orchestrators.md +100 -0
  82. pw_core/docs/otel.md +167 -0
  83. pw_core/docs/pagination.md +140 -0
  84. pw_core/docs/parsers.md +88 -0
  85. pw_core/docs/phash.md +77 -0
  86. pw_core/docs/pipeline.md +299 -0
  87. pw_core/docs/pricing.md +46 -0
  88. pw_core/docs/project-portability.md +122 -0
  89. pw_core/docs/prompts.md +298 -0
  90. pw_core/docs/recipes/batch-llm-service.md +101 -0
  91. pw_core/docs/recipes/critic.md +90 -0
  92. pw_core/docs/recipes/job-refs-bridge.md +128 -0
  93. pw_core/docs/recipes/self-consistency.md +114 -0
  94. pw_core/docs/relative-dates.md +129 -0
  95. pw_core/docs/scaffold.md +44 -0
  96. pw_core/docs/similarity.md +103 -0
  97. pw_core/docs/soft-delete.md +106 -0
  98. pw_core/docs/test-migration.md +105 -0
  99. pw_core/docs/testing.md +219 -0
  100. pw_core/docs/urls.md +347 -0
  101. pw_core/docs/versioned-config.md +107 -0
  102. pw_core/docs/vocab.md +142 -0
  103. pw_core/docs/web.md +284 -0
  104. pw_core/doctor.py +237 -0
  105. pw_core/eval/__init__.py +105 -0
  106. pw_core/eval/_compare.py +214 -0
  107. pw_core/eval/_config.py +232 -0
  108. pw_core/eval/_experiments.py +147 -0
  109. pw_core/eval/_golden.py +245 -0
  110. pw_core/eval/_judge.py +171 -0
  111. pw_core/eval/_report.py +220 -0
  112. pw_core/eval/_runner.py +486 -0
  113. pw_core/eval/cli.py +273 -0
  114. pw_core/export/__init__.py +16 -0
  115. pw_core/export/markdown.py +298 -0
  116. pw_core/fetch/__init__.py +422 -0
  117. pw_core/fetch/_connect.py +174 -0
  118. pw_core/fetch/_decode.py +74 -0
  119. pw_core/fetch/images.py +384 -0
  120. pw_core/guards_rules.py +22 -0
  121. pw_core/jobs/__init__.py +69 -0
  122. pw_core/jobs/_repo_steps.py +214 -0
  123. pw_core/jobs/_repo_util.py +109 -0
  124. pw_core/jobs/_repo_worker.py +190 -0
  125. pw_core/jobs/_schema.py +143 -0
  126. pw_core/jobs/registry.py +256 -0
  127. pw_core/jobs/repo.py +361 -0
  128. pw_core/jobs/runtime.py +314 -0
  129. pw_core/jobs/submit.py +181 -0
  130. pw_core/jobs/workers.py +281 -0
  131. pw_core/llm/__init__.py +110 -0
  132. pw_core/llm/_json.py +35 -0
  133. pw_core/llm/_router_config.py +153 -0
  134. pw_core/llm/_router_loader.py +75 -0
  135. pw_core/llm/_router_schema.py +142 -0
  136. pw_core/llm/cache/__init__.py +246 -0
  137. pw_core/llm/cache/_recorder.py +121 -0
  138. pw_core/llm/cache/_schema.py +82 -0
  139. pw_core/llm/cache/config.py +201 -0
  140. pw_core/llm/cache/exact.py +247 -0
  141. pw_core/llm/cache/invalidate.py +118 -0
  142. pw_core/llm/parse.py +237 -0
  143. pw_core/llm/prompts.py +286 -0
  144. pw_core/llm/recording.py +82 -0
  145. pw_core/llm/router.py +332 -0
  146. pw_core/llm/safe_apply.py +186 -0
  147. pw_core/llm/step.py +260 -0
  148. pw_core/llm/tracked.py +468 -0
  149. pw_core/llm/tracking/__init__.py +122 -0
  150. pw_core/llm/tracking/_metadata.py +33 -0
  151. pw_core/llm/tracking/_otel_reader.py +90 -0
  152. pw_core/llm/tracking/_resolvers.py +259 -0
  153. pw_core/llm/tracking/decorator.py +198 -0
  154. pw_core/llm/tracking/otel.py +307 -0
  155. pw_core/llm/tracking/purge.py +71 -0
  156. pw_core/llm/tracking/repo.py +453 -0
  157. pw_core/llm/tracking/schema.py +400 -0
  158. pw_core/llm/tracking/stats.py +188 -0
  159. pw_core/llm/tracking/subrepos.py +274 -0
  160. pw_core/llm/url_check.py +74 -0
  161. pw_core/llm/validate/__init__.py +83 -0
  162. pw_core/llm/validate/_cross_field.py +59 -0
  163. pw_core/llm/validate/_jsonschema.py +67 -0
  164. pw_core/llm/validate/_pipeline.py +431 -0
  165. pw_core/llm/validate/_pydantic.py +65 -0
  166. pw_core/llm/validate/_registry.py +141 -0
  167. pw_core/llm/validate/_semantic.py +375 -0
  168. pw_core/orchestrators/__init__.py +5 -0
  169. pw_core/orchestrators/base.py +90 -0
  170. pw_core/parsers/__init__.py +47 -0
  171. pw_core/parsers/exceptions.py +33 -0
  172. pw_core/parsers/html.py +211 -0
  173. pw_core/parsers/types.py +31 -0
  174. pw_core/pipeline/__init__.py +13 -0
  175. pw_core/pipeline/baseline.py +244 -0
  176. pw_core/pipeline/baseline_diff.py +296 -0
  177. pw_core/pipeline/cache.py +156 -0
  178. pw_core/pipeline/resume.py +128 -0
  179. pw_core/pipeline/run_record.py +126 -0
  180. pw_core/pipeline/sequencer.py +159 -0
  181. pw_core/pricing/__init__.py +30 -0
  182. pw_core/pricing/_data.py +50 -0
  183. pw_core/pricing/_resolver.py +148 -0
  184. pw_core/pricing/_types.py +29 -0
  185. pw_core/py.typed +0 -0
  186. pw_core/rules/anti-patterns.md +112 -0
  187. pw_core/rules/layering.md +107 -0
  188. pw_core/rules/project-structure.md +127 -0
  189. pw_core/testing/__init__.py +20 -0
  190. pw_core/testing/_ddl.py +69 -0
  191. pw_core/testing/_server_db.py +129 -0
  192. pw_core/testing/db_fixtures.py +287 -0
  193. pw_core/testing/env.py +84 -0
  194. pw_core/testing/fixtures.py +69 -0
  195. pw_core/utils/__init__.py +63 -0
  196. pw_core/utils/_article_extract.py +307 -0
  197. pw_core/utils/article_fetch.py +331 -0
  198. pw_core/utils/http_tls.py +27 -0
  199. pw_core/utils/json_recovery.py +245 -0
  200. pw_core/utils/phash.py +168 -0
  201. pw_core/utils/relative_dates.py +258 -0
  202. pw_core/utils/similarity.py +71 -0
  203. pw_core/utils/url_html.py +119 -0
  204. pw_core/utils/url_liveness.py +226 -0
  205. pw_core/utils/url_parse.py +174 -0
  206. pw_core/utils/url_safety.py +155 -0
  207. pw_core/utils/urls.py +249 -0
  208. pw_core/utils/vocab.py +131 -0
  209. pw_core/web/__init__.py +15 -0
  210. pw_core/web/_error_handlers.py +154 -0
  211. pw_core/web/_error_page.py +131 -0
  212. pw_core/web/_validation_errors.py +82 -0
  213. pw_core/web/app_factory.py +169 -0
  214. pw_core/web/health.py +116 -0
  215. pw_core/web/helpers.py +30 -0
  216. pw_core/web/jobs_admin/__init__.py +194 -0
  217. pw_core/web/jobs_admin/templates/job_detail.html +122 -0
  218. pw_core/web/jobs_admin/templates/jobs_list.html +60 -0
  219. pw_core/web/json.py +55 -0
  220. pw_core/web/llm_admin/__init__.py +103 -0
  221. pw_core/web/llm_admin/_queries_jobs.py +78 -0
  222. pw_core/web/llm_admin/_queries_runs.py +229 -0
  223. pw_core/web/llm_admin/_queries_spend.py +170 -0
  224. pw_core/web/llm_admin/_queries_util.py +37 -0
  225. pw_core/web/llm_admin/api.py +179 -0
  226. pw_core/web/llm_admin/pages.py +185 -0
  227. pw_core/web/llm_admin/queries.py +62 -0
  228. pw_core/web/llm_admin/templates/base.html +60 -0
  229. pw_core/web/llm_admin/templates/budgets.html +62 -0
  230. pw_core/web/llm_admin/templates/cache.html +65 -0
  231. pw_core/web/llm_admin/templates/cost_by_agent.html +22 -0
  232. pw_core/web/llm_admin/templates/cost_by_model.html +24 -0
  233. pw_core/web/llm_admin/templates/dashboard.html +41 -0
  234. pw_core/web/llm_admin/templates/job_detail.html +60 -0
  235. pw_core/web/llm_admin/templates/jobs_list.html +47 -0
  236. pw_core/web/llm_admin/templates/macros.html +23 -0
  237. pw_core/web/llm_admin/templates/run_detail.html +114 -0
  238. pw_core/web/llm_admin/templates/runs_list.html +49 -0
  239. pw_core/web/markdown.py +245 -0
  240. pw_core/web/pagination.py +125 -0
  241. pw_core/web/rate_limit.py +115 -0
  242. pw_core/web/templates.py +59 -0
  243. pw_core-0.27.0.dist-info/METADATA +269 -0
  244. pw_core-0.27.0.dist-info/RECORD +249 -0
  245. pw_core-0.27.0.dist-info/WHEEL +5 -0
  246. pw_core-0.27.0.dist-info/entry_points.txt +7 -0
  247. pw_core-0.27.0.dist-info/licenses/LICENSE +202 -0
  248. pw_core-0.27.0.dist-info/licenses/NOTICE +4 -0
  249. 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
+ )