pf-core 0.1.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 (220) hide show
  1. pf_core/__init__.py +20 -0
  2. pf_core/_extras.py +64 -0
  3. pf_core/alembic.py +72 -0
  4. pf_core/budget/__init__.py +147 -0
  5. pf_core/budget/_schema.py +122 -0
  6. pf_core/budget/audit.py +73 -0
  7. pf_core/budget/check.py +369 -0
  8. pf_core/budget/config.py +155 -0
  9. pf_core/budget/repo.py +386 -0
  10. pf_core/budget/scheduler.py +72 -0
  11. pf_core/budget/snapshot_job.py +54 -0
  12. pf_core/cache/__init__.py +0 -0
  13. pf_core/cache/redis.py +198 -0
  14. pf_core/cli/__init__.py +103 -0
  15. pf_core/cli/jobs.py +272 -0
  16. pf_core/cli/subcommands/__init__.py +31 -0
  17. pf_core/cli/subcommands/_render.py +174 -0
  18. pf_core/cli/subcommands/baseline.py +155 -0
  19. pf_core/cli/subcommands/invalidate.py +90 -0
  20. pf_core/clients/__init__.py +38 -0
  21. pf_core/clients/anthropic.py +333 -0
  22. pf_core/clients/brave.py +296 -0
  23. pf_core/clients/claude_code.py +429 -0
  24. pf_core/clients/openrouter.py +400 -0
  25. pf_core/clients/routing.py +167 -0
  26. pf_core/config.py +149 -0
  27. pf_core/db/__init__.py +79 -0
  28. pf_core/db/connection.py +184 -0
  29. pf_core/db/helpers.py +68 -0
  30. pf_core/db/json_compat.py +202 -0
  31. pf_core/db/models.py +79 -0
  32. pf_core/db/repository.py +66 -0
  33. pf_core/db/soft_delete.py +122 -0
  34. pf_core/db/upsert.py +175 -0
  35. pf_core/db/versioned_config.py +195 -0
  36. pf_core/docs/INSTALLATION.md +218 -0
  37. pf_core/docs/alembic.md +71 -0
  38. pf_core/docs/anthropic.md +172 -0
  39. pf_core/docs/anti-hallucination.md +123 -0
  40. pf_core/docs/article-fetch.md +166 -0
  41. pf_core/docs/brave.md +154 -0
  42. pf_core/docs/cache.md +178 -0
  43. pf_core/docs/claude-code.md +211 -0
  44. pf_core/docs/cli-subcommands.md +112 -0
  45. pf_core/docs/cli.md +95 -0
  46. pf_core/docs/config.md +113 -0
  47. pf_core/docs/cost-budget.md +218 -0
  48. pf_core/docs/database.md +292 -0
  49. pf_core/docs/dates.md +130 -0
  50. pf_core/docs/db-upsert.md +41 -0
  51. pf_core/docs/env.md +108 -0
  52. pf_core/docs/eval-harness.md +560 -0
  53. pf_core/docs/exceptions.md +143 -0
  54. pf_core/docs/export.md +92 -0
  55. pf_core/docs/guards.md +57 -0
  56. pf_core/docs/hashing.md +40 -0
  57. pf_core/docs/ids.md +95 -0
  58. pf_core/docs/io.md +83 -0
  59. pf_core/docs/jobs.md +381 -0
  60. pf_core/docs/json-recovery.md +94 -0
  61. pf_core/docs/json-utils.md +112 -0
  62. pf_core/docs/linting.md +128 -0
  63. pf_core/docs/llm-admin.md +168 -0
  64. pf_core/docs/llm-cache.md +276 -0
  65. pf_core/docs/llm-parse.md +96 -0
  66. pf_core/docs/llm-safe-apply.md +107 -0
  67. pf_core/docs/llm-schema-validation.md +445 -0
  68. pf_core/docs/llm-tracked.md +98 -0
  69. pf_core/docs/llm-tracking.md +405 -0
  70. pf_core/docs/llm-validation.md +94 -0
  71. pf_core/docs/logging.md +116 -0
  72. pf_core/docs/markdown.md +111 -0
  73. pf_core/docs/model-router.md +446 -0
  74. pf_core/docs/modules.md +176 -0
  75. pf_core/docs/openrouter.md +230 -0
  76. pf_core/docs/orchestrators.md +93 -0
  77. pf_core/docs/output.md +123 -0
  78. pf_core/docs/pagination.md +122 -0
  79. pf_core/docs/parallel.md +121 -0
  80. pf_core/docs/parsers.md +99 -0
  81. pf_core/docs/periods.md +84 -0
  82. pf_core/docs/phash.md +69 -0
  83. pf_core/docs/pipeline.md +287 -0
  84. pf_core/docs/pricing.md +43 -0
  85. pf_core/docs/project-portability.md +125 -0
  86. pf_core/docs/prompts.md +277 -0
  87. pf_core/docs/relative-dates.md +130 -0
  88. pf_core/docs/scaffold.md +33 -0
  89. pf_core/docs/services.md +84 -0
  90. pf_core/docs/similarity.md +102 -0
  91. pf_core/docs/soft-delete.md +105 -0
  92. pf_core/docs/test-migration.md +105 -0
  93. pf_core/docs/testing.md +143 -0
  94. pf_core/docs/throttle.md +33 -0
  95. pf_core/docs/urls.md +301 -0
  96. pf_core/docs/versioned-config.md +99 -0
  97. pf_core/docs/vocab.md +141 -0
  98. pf_core/docs/web.md +269 -0
  99. pf_core/eval/__init__.py +104 -0
  100. pf_core/eval/_compare.py +146 -0
  101. pf_core/eval/_config.py +142 -0
  102. pf_core/eval/_golden.py +205 -0
  103. pf_core/eval/_judge.py +146 -0
  104. pf_core/eval/_report.py +214 -0
  105. pf_core/eval/_runner.py +393 -0
  106. pf_core/exceptions.py +192 -0
  107. pf_core/export/__init__.py +16 -0
  108. pf_core/export/markdown.py +264 -0
  109. pf_core/guards/__init__.py +18 -0
  110. pf_core/guards/__main__.py +9 -0
  111. pf_core/guards/structure.py +174 -0
  112. pf_core/jobs/__init__.py +69 -0
  113. pf_core/jobs/_schema.py +156 -0
  114. pf_core/jobs/registry.py +254 -0
  115. pf_core/jobs/repo.py +775 -0
  116. pf_core/jobs/runtime.py +309 -0
  117. pf_core/llm/__init__.py +107 -0
  118. pf_core/llm/_router_config.py +161 -0
  119. pf_core/llm/_router_loader.py +118 -0
  120. pf_core/llm/_router_schema.py +142 -0
  121. pf_core/llm/cache/__init__.py +234 -0
  122. pf_core/llm/cache/_recorder.py +121 -0
  123. pf_core/llm/cache/_schema.py +111 -0
  124. pf_core/llm/cache/config.py +137 -0
  125. pf_core/llm/cache/exact.py +158 -0
  126. pf_core/llm/cache/invalidate.py +123 -0
  127. pf_core/llm/parse.py +152 -0
  128. pf_core/llm/prompts.py +237 -0
  129. pf_core/llm/router.py +331 -0
  130. pf_core/llm/safe_apply.py +193 -0
  131. pf_core/llm/tracked.py +286 -0
  132. pf_core/llm/tracking/__init__.py +114 -0
  133. pf_core/llm/tracking/_resolvers.py +275 -0
  134. pf_core/llm/tracking/decorator.py +198 -0
  135. pf_core/llm/tracking/purge.py +63 -0
  136. pf_core/llm/tracking/repo.py +332 -0
  137. pf_core/llm/tracking/schema.py +482 -0
  138. pf_core/llm/tracking/stats.py +207 -0
  139. pf_core/llm/tracking/subrepos.py +230 -0
  140. pf_core/llm/url_check.py +74 -0
  141. pf_core/llm/validate/__init__.py +80 -0
  142. pf_core/llm/validate/_cross_field.py +57 -0
  143. pf_core/llm/validate/_jsonschema.py +61 -0
  144. pf_core/llm/validate/_pipeline.py +263 -0
  145. pf_core/llm/validate/_pydantic.py +51 -0
  146. pf_core/llm/validate/_registry.py +135 -0
  147. pf_core/llm/validate/_semantic.py +353 -0
  148. pf_core/log.py +239 -0
  149. pf_core/orchestrators/__init__.py +5 -0
  150. pf_core/orchestrators/base.py +90 -0
  151. pf_core/output.py +108 -0
  152. pf_core/parallel.py +249 -0
  153. pf_core/parsers/__init__.py +48 -0
  154. pf_core/parsers/exceptions.py +36 -0
  155. pf_core/parsers/html.py +193 -0
  156. pf_core/parsers/types.py +32 -0
  157. pf_core/pipeline/__init__.py +15 -0
  158. pf_core/pipeline/baseline.py +245 -0
  159. pf_core/pipeline/baseline_diff.py +297 -0
  160. pf_core/pipeline/cache.py +159 -0
  161. pf_core/pipeline/resume.py +130 -0
  162. pf_core/pipeline/run_record.py +127 -0
  163. pf_core/pipeline/sequencer.py +161 -0
  164. pf_core/pricing/__init__.py +30 -0
  165. pf_core/pricing/_data.py +35 -0
  166. pf_core/pricing/_resolver.py +112 -0
  167. pf_core/pricing/_types.py +24 -0
  168. pf_core/py.typed +0 -0
  169. pf_core/services/__init__.py +5 -0
  170. pf_core/services/base.py +70 -0
  171. pf_core/testing/__init__.py +20 -0
  172. pf_core/testing/db_fixtures.py +197 -0
  173. pf_core/testing/fixtures.py +49 -0
  174. pf_core/utils/__init__.py +58 -0
  175. pf_core/utils/article_fetch.py +527 -0
  176. pf_core/utils/dates.py +186 -0
  177. pf_core/utils/env.py +157 -0
  178. pf_core/utils/hashing.py +52 -0
  179. pf_core/utils/ids.py +112 -0
  180. pf_core/utils/io.py +117 -0
  181. pf_core/utils/json.py +123 -0
  182. pf_core/utils/json_recovery.py +188 -0
  183. pf_core/utils/periods.py +197 -0
  184. pf_core/utils/phash.py +162 -0
  185. pf_core/utils/relative_dates.py +247 -0
  186. pf_core/utils/similarity.py +71 -0
  187. pf_core/utils/throttle.py +67 -0
  188. pf_core/utils/url_liveness.py +191 -0
  189. pf_core/utils/urls.py +518 -0
  190. pf_core/utils/vocab.py +135 -0
  191. pf_core/web/__init__.py +14 -0
  192. pf_core/web/app_factory.py +391 -0
  193. pf_core/web/health.py +112 -0
  194. pf_core/web/helpers.py +30 -0
  195. pf_core/web/json.py +55 -0
  196. pf_core/web/llm_admin/__init__.py +83 -0
  197. pf_core/web/llm_admin/api.py +161 -0
  198. pf_core/web/llm_admin/pages.py +174 -0
  199. pf_core/web/llm_admin/queries.py +526 -0
  200. pf_core/web/llm_admin/templates/base.html +60 -0
  201. pf_core/web/llm_admin/templates/budgets.html +46 -0
  202. pf_core/web/llm_admin/templates/cache.html +44 -0
  203. pf_core/web/llm_admin/templates/cost_by_agent.html +22 -0
  204. pf_core/web/llm_admin/templates/cost_by_model.html +24 -0
  205. pf_core/web/llm_admin/templates/dashboard.html +41 -0
  206. pf_core/web/llm_admin/templates/job_detail.html +60 -0
  207. pf_core/web/llm_admin/templates/jobs_list.html +47 -0
  208. pf_core/web/llm_admin/templates/macros.html +23 -0
  209. pf_core/web/llm_admin/templates/run_detail.html +114 -0
  210. pf_core/web/llm_admin/templates/runs_list.html +49 -0
  211. pf_core/web/markdown.py +208 -0
  212. pf_core/web/pagination.py +124 -0
  213. pf_core/web/rate_limit.py +113 -0
  214. pf_core/web/templates.py +59 -0
  215. pf_core-0.1.0.dist-info/METADATA +152 -0
  216. pf_core-0.1.0.dist-info/RECORD +220 -0
  217. pf_core-0.1.0.dist-info/WHEEL +5 -0
  218. pf_core-0.1.0.dist-info/entry_points.txt +5 -0
  219. pf_core-0.1.0.dist-info/licenses/LICENSE +21 -0
  220. pf_core-0.1.0.dist-info/top_level.txt +1 -0
pf_core/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """pf-core: a dependency-light Python foundation.
2
+
3
+ The base install (``pip install pf-core``) is the architectural foundation
4
+ only: structured logging, an exception hierarchy, config + env resolvers,
5
+ utils, and the ``Service`` base class — five small deps (structlog,
6
+ python-dotenv, pyyaml, nanoid, rich), no httpx/pydantic/LLM stack.
7
+
8
+ Everything else ships as opt-in, orthogonally-composable extras: anti-slop
9
+ output guards (``[validate]``), LLM clients (``[llm]`` ⊇ ``[validate]``), HTTP
10
+ utils (``[http]``), CLI scaffolding (``[cli]``), and the FastAPI + SQLAlchemy
11
+ app framework (``[db]``, ``[web]``, ``[jobs]``, ``[tracking]``, ``[eval]``,
12
+ ``[admin]``). See ``docs/INSTALLATION.md`` for the extras matrix.
13
+ """
14
+
15
+ from importlib.metadata import PackageNotFoundError, version
16
+
17
+ try:
18
+ __version__ = version("pf-core")
19
+ except PackageNotFoundError: # running from a source tree with no install
20
+ __version__ = "0.0.0+unknown"
pf_core/_extras.py ADDED
@@ -0,0 +1,64 @@
1
+ """Friendly errors for missing optional-dependency extras.
2
+
3
+ The foundation install (``pip install pf-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 pf_core._extras import extra_import_error
16
+
17
+ raise extra_import_error(
18
+ "llm", "httpx", feature="pf_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 ``pf-core[<extra>]``.
26
+ _INSTALL: dict[str, str] = {
27
+ "http": "pf-core[http]",
28
+ "cli": "pf-core[cli]",
29
+ "validate": "pf-core[validate]",
30
+ "llm": "pf-core[llm]",
31
+ "db": "pf-core[db]",
32
+ "web": "pf-core[web]",
33
+ "jobs": "pf-core[jobs]",
34
+ "tracking": "pf-core[tracking]",
35
+ "eval": "pf-core[eval]",
36
+ "admin": "pf-core[admin]",
37
+ "articles": "pf-core[articles]",
38
+ "jsonschema": "pf-core[jsonschema]",
39
+ "redis": "pf-core[redis]",
40
+ "ratelimit": "pf-core[ratelimit]",
41
+ }
42
+
43
+
44
+ def install_target(extra: str) -> str:
45
+ """Return the ``pip install`` target for an extra (e.g. ``pf-core[llm]``)."""
46
+ return _INSTALL.get(extra, f"pf-core[{extra}]")
47
+
48
+
49
+ def extra_import_error(extra: str, package: str, *, feature: str) -> ImportError:
50
+ """Build an ``ImportError`` that names the missing extra and pip command.
51
+
52
+ Args:
53
+ extra: The optional-dependency extra that ships ``package`` (e.g. ``"llm"``).
54
+ package: The third-party import name that failed (e.g. ``"json_repair"``).
55
+ feature: The pf-core module or capability the caller was importing, used
56
+ in the message (e.g. ``"pf_core.llm.parse"``).
57
+
58
+ Returns:
59
+ An ``ImportError`` to ``raise ... from`` the original failure.
60
+ """
61
+ return ImportError(
62
+ f"{feature} requires the '{extra}' extra; '{package}' is not installed. "
63
+ f"Install it with: pip install {install_target(extra)}"
64
+ )
pf_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 pf_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 pf_core.alembic import run_migrations_online
10
+
11
+ run_migrations_online()
12
+
13
+ With SQLite fallback (e.g., an essay-grading app on SQLite)::
14
+
15
+ from pf_core.alembic import run_migrations_online
16
+
17
+ run_migrations_online(fallback_sqlite="grading.db")
18
+
19
+ With explicit metadata (for autogenerate)::
20
+
21
+ from myapp.models import Base
22
+ from pf_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 pf_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 ``pf_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,147 @@
1
+ """
2
+ Pre-call cost guardrails for LLM spending.
3
+
4
+ The kernel-safe surface (importable without ``pf-core[db]``) covers the
5
+ budget guard itself and YAML config loading:
6
+
7
+ from pf_core.budget import (
8
+ check_budget, project_cost, CostBudgetExceeded,
9
+ compute_period_start, compute_period_end,
10
+ load_yaml, clear_config_cache,
11
+ )
12
+
13
+ The DB-backed surface (requires ``pf-core[db]``) is loaded lazily — the
14
+ import only triggers SQLAlchemy when an attribute is first accessed:
15
+
16
+ from pf_core.budget import (
17
+ BudgetRepo, BudgetSnapshotRepo, CostRateRepo, aggregate_spent,
18
+ 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 pf-core`` (no extras).
38
+ from pf_core.budget.check import ( # noqa: F401
39
+ CostBudgetExceeded,
40
+ check_budget,
41
+ compute_period_end,
42
+ compute_period_start,
43
+ project_cost,
44
+ )
45
+ from pf_core.budget.config import ( # noqa: F401
46
+ clear_config_cache,
47
+ load_yaml,
48
+ )
49
+
50
+
51
+ # Lazy imports — these submodules pull SQLAlchemy at module top, so we
52
+ # defer them via PEP 562 ``__getattr__`` to keep the kernel install clean.
53
+ # The first attribute access triggers the import; subsequent accesses are
54
+ # free because we cache into ``globals()``.
55
+ _LAZY: dict[str, str] = {
56
+ # Repos (DB-required)
57
+ "BudgetRepo": "pf_core.budget.repo",
58
+ "BudgetSnapshotRepo": "pf_core.budget.repo",
59
+ "CostRateRepo": "pf_core.budget.repo",
60
+ "aggregate_spent": "pf_core.budget.repo",
61
+ # Audit logging (DB-required)
62
+ "record_blocked_run": "pf_core.budget.audit",
63
+ "record_override": "pf_core.budget.audit",
64
+ # Snapshot / scheduler jobs (DB-required)
65
+ "refresh_snapshots": "pf_core.budget.snapshot_job",
66
+ "start_budget_refresh_loop": "pf_core.budget.scheduler",
67
+ # YAML→DB sync (DB-required path within the otherwise-kernel config module)
68
+ "sync_budgets_from_yaml": "pf_core.budget.config",
69
+ # Schema tables (DB-required — registers on shared MetaData on first access)
70
+ "ALL_BUDGET_TABLES": "pf_core.budget._schema",
71
+ "llm_budgets": "pf_core.budget._schema",
72
+ "llm_budget_snapshots": "pf_core.budget._schema",
73
+ "llm_cost_rates": "pf_core.budget._schema",
74
+ }
75
+
76
+
77
+ def __getattr__(name: str):
78
+ """Lazy import for DB-required attributes (PEP 562)."""
79
+ target = _LAZY.get(name)
80
+ if target is None:
81
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
82
+ import importlib
83
+
84
+ mod = importlib.import_module(target)
85
+ value = getattr(mod, name)
86
+ globals()[name] = value # cache so subsequent access skips __getattr__
87
+ return value
88
+
89
+
90
+ def __dir__() -> list[str]:
91
+ """Expose lazy attributes to ``dir()`` and IDE autocomplete."""
92
+ return sorted(set(globals()) | set(_LAZY))
93
+
94
+
95
+ # Type-checker view of the lazy attributes — keeps ``mypy`` / IDEs happy
96
+ # without paying the import cost at runtime.
97
+ if TYPE_CHECKING:
98
+ from pf_core.budget._schema import ( # noqa: F401
99
+ ALL_BUDGET_TABLES,
100
+ llm_budget_snapshots,
101
+ llm_budgets,
102
+ llm_cost_rates,
103
+ )
104
+ from pf_core.budget.audit import ( # noqa: F401
105
+ record_blocked_run,
106
+ record_override,
107
+ )
108
+ from pf_core.budget.config import sync_budgets_from_yaml # noqa: F401
109
+ from pf_core.budget.repo import ( # noqa: F401
110
+ BudgetRepo,
111
+ BudgetSnapshotRepo,
112
+ CostRateRepo,
113
+ aggregate_spent,
114
+ )
115
+ from pf_core.budget.scheduler import start_budget_refresh_loop # noqa: F401
116
+ from pf_core.budget.snapshot_job import refresh_snapshots # noqa: F401
117
+
118
+
119
+ __all__ = [
120
+ # Guard (eager)
121
+ "check_budget",
122
+ "project_cost",
123
+ "CostBudgetExceeded",
124
+ "compute_period_start",
125
+ "compute_period_end",
126
+ # Config — kernel-safe (eager)
127
+ "load_yaml",
128
+ "clear_config_cache",
129
+ # Config — DB-backed (lazy)
130
+ "sync_budgets_from_yaml",
131
+ # Repos (lazy)
132
+ "BudgetRepo",
133
+ "BudgetSnapshotRepo",
134
+ "CostRateRepo",
135
+ "aggregate_spent",
136
+ # Snapshot / scheduler jobs (lazy)
137
+ "refresh_snapshots",
138
+ "start_budget_refresh_loop",
139
+ # Audit (lazy)
140
+ "record_blocked_run",
141
+ "record_override",
142
+ # Schema (lazy)
143
+ "ALL_BUDGET_TABLES",
144
+ "llm_budgets",
145
+ "llm_budget_snapshots",
146
+ "llm_cost_rates",
147
+ ]
@@ -0,0 +1,122 @@
1
+ """
2
+ SQLAlchemy schema for pf-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 ``pf_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
+ Boolean,
22
+ Column,
23
+ Date,
24
+ ForeignKey,
25
+ Index,
26
+ Integer,
27
+ Numeric,
28
+ PrimaryKeyConstraint,
29
+ String,
30
+ Table,
31
+ UniqueConstraint,
32
+ )
33
+
34
+ from pf_core.llm.tracking.schema import (
35
+ _JSON,
36
+ _PK_SMALL,
37
+ _FK_SMALL,
38
+ _TIMESTAMP_US,
39
+ _server_now,
40
+ metadata,
41
+ )
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Tables
45
+ # ---------------------------------------------------------------------------
46
+
47
+ llm_budgets = Table(
48
+ "llm_budgets",
49
+ metadata,
50
+ Column("id", _PK_SMALL, primary_key=True, autoincrement=True),
51
+ Column("scope_kind", String(32), nullable=False),
52
+ Column("scope_value", String(128), nullable=True),
53
+ Column("period", String(16), nullable=False),
54
+ Column("limit_usd", Numeric(12, 4), nullable=False),
55
+ Column("soft_thresholds", _JSON, nullable=True),
56
+ Column("hard_cap", Boolean, nullable=False, server_default="1"),
57
+ Column("action", String(32), nullable=False, server_default="block"),
58
+ Column("enabled", Boolean, nullable=False, server_default="1"),
59
+ Column("created_at", _TIMESTAMP_US, nullable=False, server_default=_server_now()),
60
+ Column("updated_at", _TIMESTAMP_US, nullable=False, server_default=_server_now()),
61
+ UniqueConstraint(
62
+ "scope_kind", "scope_value", "period", name="uq_llm_budgets_scope_period"
63
+ ),
64
+ Index("idx_llm_budgets_enabled", "enabled"),
65
+ )
66
+ """Budget definitions. scope_kind ∈ {global, agent, job_kind, job_id, tag}."""
67
+
68
+
69
+ llm_budget_snapshots = Table(
70
+ "llm_budget_snapshots",
71
+ metadata,
72
+ Column(
73
+ "budget_id",
74
+ _FK_SMALL,
75
+ ForeignKey("llm_budgets.id", ondelete="CASCADE"),
76
+ nullable=False,
77
+ ),
78
+ Column("period_start", Date, nullable=False),
79
+ Column("spent_usd", Numeric(12, 4), nullable=False, server_default="0"),
80
+ Column("run_count", Integer, nullable=False, server_default="0"),
81
+ Column(
82
+ "last_updated", _TIMESTAMP_US, nullable=False, server_default=_server_now()
83
+ ),
84
+ PrimaryKeyConstraint("budget_id", "period_start", name="pk_llm_budget_snapshots"),
85
+ Index("idx_llm_budget_snapshots_period", "period_start"),
86
+ )
87
+ """Periodic aggregate cache. Not source of truth — rebuilt from llm_runs."""
88
+
89
+
90
+ llm_cost_rates = Table(
91
+ "llm_cost_rates",
92
+ metadata,
93
+ Column(
94
+ "model_id",
95
+ _FK_SMALL,
96
+ ForeignKey("llm_models.id", ondelete="CASCADE"),
97
+ nullable=False,
98
+ ),
99
+ Column("input_per_1k", Numeric(8, 6), nullable=False),
100
+ Column("output_per_1k", Numeric(8, 6), nullable=False),
101
+ Column("cache_read_per_1k", Numeric(8, 6), nullable=True),
102
+ Column("cache_write_per_1k", Numeric(8, 6), nullable=True),
103
+ Column("reasoning_per_1k", Numeric(8, 6), nullable=True),
104
+ Column("effective_from", Date, nullable=False),
105
+ Column("effective_to", Date, nullable=True),
106
+ PrimaryKeyConstraint(
107
+ "model_id", "effective_from", name="pk_llm_cost_rates"
108
+ ),
109
+ Index("idx_llm_cost_rates_model_eff", "model_id", "effective_from"),
110
+ )
111
+ """Per-model price list. Multiple rows per model allowed (versioned pricing)."""
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Public table list (in dependency order)
116
+ # ---------------------------------------------------------------------------
117
+
118
+ ALL_BUDGET_TABLES = (
119
+ llm_budgets,
120
+ llm_budget_snapshots,
121
+ llm_cost_rates,
122
+ )
@@ -0,0 +1,73 @@
1
+ """
2
+ Audit helpers for blocked and override budget events.
3
+
4
+ ``record_blocked_run()`` writes a zero-cost ``llm_runs`` row with
5
+ ``status='budget_blocked'`` plus ``budget:blocked`` + ``budget:scope=...``
6
+ tags so analytics can answer "how many calls did the budget stop?".
7
+
8
+ ``record_override()`` attaches a ``budget:override`` tag and an
9
+ ``llm_run_outcomes`` row to an existing run.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pf_core.budget.check import CostBudgetExceeded
15
+ from pf_core.llm.tracking.repo import LlmRunRepo
16
+ from pf_core.llm.tracking.schema import llm_run_tags
17
+ from pf_core.llm.tracking.subrepos import LlmRunOutcomeRepo
18
+
19
+
20
+ def record_blocked_run(
21
+ *,
22
+ agent_type: str,
23
+ model: str,
24
+ exc: CostBudgetExceeded,
25
+ job_id: int | None = None,
26
+ ) -> int:
27
+ """Insert a ``status='budget_blocked'`` run with scope tags.
28
+
29
+ Returns the new ``llm_runs.id``.
30
+ """
31
+ descriptor = (
32
+ f"{exc.scope_kind}:{exc.scope_value}:{exc.period}"
33
+ if exc.scope_value
34
+ else f"{exc.scope_kind}:{exc.period}"
35
+ )
36
+ tags = ["budget:blocked", f"budget:scope={descriptor}"]
37
+
38
+ run_id = LlmRunRepo().record(
39
+ agent_type=agent_type,
40
+ model=model,
41
+ status="budget_blocked",
42
+ usage={
43
+ "cost_usd": 0.0,
44
+ "prompt_tokens": 0,
45
+ "completion_tokens": 0,
46
+ "duration_ms": 1,
47
+ },
48
+ error=str(exc),
49
+ error_class="CostBudgetExceeded",
50
+ job_id=job_id,
51
+ tags=tags,
52
+ )
53
+ return int(run_id)
54
+
55
+
56
+ def record_override(
57
+ *,
58
+ run_id: int,
59
+ reason: str,
60
+ operator: str | None = None,
61
+ ) -> None:
62
+ """Tag *run_id* as a budget override and write an outcome row."""
63
+ from pf_core.db.connection import transaction
64
+
65
+ with transaction() as conn:
66
+ conn.execute(
67
+ llm_run_tags.insert().values(llm_run_id=run_id, tag="budget:override")
68
+ )
69
+ LlmRunOutcomeRepo().record(
70
+ run_id,
71
+ outcome_kind="budget_override",
72
+ notes=reason if not operator else f"{reason} (operator: {operator})",
73
+ )