agent-tokenops 0.1.2__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. {agent_tokenops-0.1.2/src/agent_tokenops.egg-info → agent_tokenops-0.2.0}/PKG-INFO +6 -2
  2. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/README.md +5 -1
  3. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/pyproject.toml +1 -1
  4. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0/src/agent_tokenops.egg-info}/PKG-INFO +6 -2
  5. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/agent_tokenops.egg-info/SOURCES.txt +1 -0
  6. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/__init__.py +1 -1
  7. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/client.py +19 -15
  8. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/http.py +12 -2
  9. agent_tokenops-0.2.0/src/tokenops/control/http_store.py +217 -0
  10. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/store.py +37 -2
  11. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_store.py +61 -1
  12. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/LICENSE.txt +0 -0
  13. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/setup.cfg +0 -0
  14. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/agent_tokenops.egg-info/dependency_links.txt +0 -0
  15. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/agent_tokenops.egg-info/requires.txt +0 -0
  16. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/agent_tokenops.egg-info/top_level.txt +0 -0
  17. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/adapters/__init__.py +0 -0
  18. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/adapters/langchain.py +0 -0
  19. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/config/__init__.py +0 -0
  20. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/config/default.yaml +0 -0
  21. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/config/loader.py +0 -0
  22. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/__init__.py +0 -0
  23. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/attribution.py +0 -0
  24. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/boundary.py +0 -0
  25. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/config.py +0 -0
  26. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/context.py +0 -0
  27. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/core.py +0 -0
  28. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/crossing.py +0 -0
  29. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/engine.py +0 -0
  30. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/governance_cache.py +0 -0
  31. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/instrument.py +0 -0
  32. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/integration.py +0 -0
  33. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/ledger.py +0 -0
  34. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/models.py +0 -0
  35. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/policies/__init__.py +0 -0
  36. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/policies/_util.py +0 -0
  37. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/policies/concurrency_cap.py +0 -0
  38. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/policies/context_compaction.py +0 -0
  39. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/policies/cost_budget.py +0 -0
  40. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/policies/cost_guard.py +0 -0
  41. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/policies/output_runaway.py +0 -0
  42. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/policies/pre_call_worst_case.py +0 -0
  43. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/policies/progress_guard.py +0 -0
  44. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/policies/step_cap.py +0 -0
  45. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/policies/tool_fix.py +0 -0
  46. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/policies/tool_output_cap.py +0 -0
  47. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/policies/trajectory_hint.py +0 -0
  48. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/pricing.py +0 -0
  49. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/propagate.py +0 -0
  50. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/request_context.py +0 -0
  51. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/run.py +0 -0
  52. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/trajectory/__init__.py +0 -0
  53. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/trajectory/compress.py +0 -0
  54. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/trajectory/enqueue.py +0 -0
  55. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/trajectory/gates.py +0 -0
  56. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/trajectory/hint.py +0 -0
  57. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/trajectory/scope.py +0 -0
  58. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/trajectory/serialize.py +0 -0
  59. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/control/trajectory/worker.py +0 -0
  60. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/env.py +0 -0
  61. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/providers/__init__.py +0 -0
  62. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/providers/anthropic.py +0 -0
  63. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/providers/factory.py +0 -0
  64. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/providers/openai.py +0 -0
  65. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/providers/types.py +0 -0
  66. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/server/__init__.py +0 -0
  67. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/server/__main__.py +0 -0
  68. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/server/app.py +0 -0
  69. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/ui/__init__.py +0 -0
  70. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/ui/app.py +0 -0
  71. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/ui/run_detail.py +0 -0
  72. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/ui/store_client.py +0 -0
  73. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/ui/theme.py +0 -0
  74. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/ui/views/__init__.py +0 -0
  75. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/ui/views/admin.py +0 -0
  76. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/src/tokenops/ui/views/dashboard.py +0 -0
  77. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_actuators.py +0 -0
  78. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_apply.py +0 -0
  79. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_attribution.py +0 -0
  80. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_attribution_ledger_policies_e2e.py +0 -0
  81. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_boundary.py +0 -0
  82. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_chronicle_boundary.py +0 -0
  83. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_concurrency_cap.py +0 -0
  84. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_config.py +0 -0
  85. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_context_compaction.py +0 -0
  86. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_control_plane_app.py +0 -0
  87. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_control_plane_client.py +0 -0
  88. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_cost_budget.py +0 -0
  89. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_cost_guard.py +0 -0
  90. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_cross_process_budget_gating.py +0 -0
  91. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_crossing_hook.py +0 -0
  92. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_governance_config_cache.py +0 -0
  93. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_governance_per_agent.py +0 -0
  94. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_integration.py +0 -0
  95. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_ledger.py +0 -0
  96. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_llm_boundary_precall.py +0 -0
  97. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_output_runaway.py +0 -0
  98. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_policies_wrap_integration.py +0 -0
  99. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_pre_call_worst_case.py +0 -0
  100. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_preview_mode.py +0 -0
  101. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_pricing.py +0 -0
  102. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_progress_guard.py +0 -0
  103. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_propagate.py +0 -0
  104. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_server_enforcement.py +0 -0
  105. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_step_cap.py +0 -0
  106. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_thread_safety.py +0 -0
  107. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_tokenops_run.py +0 -0
  108. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_tool_fix.py +0 -0
  109. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_tool_output_cap.py +0 -0
  110. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_trajectory_hint.py +0 -0
  111. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_trajectory_hint_e2e.py +0 -0
  112. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_wrap_adapters.py +0 -0
  113. {agent_tokenops-0.1.2 → agent_tokenops-0.2.0}/tests/test_wrap_complete_chronicle.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-tokenops
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: TokenOps control plane and SDK for run-aware agent token governance
5
5
  Author: Susheem Koul, Tisha Chawla
6
6
  License-Expression: MIT
@@ -60,7 +60,11 @@ Cap spend and steer behavior across a whole agent workflow — not per request
60
60
 
61
61
  <br>
62
62
 
63
- <img src="https://raw.githubusercontent.com/theagentplane/tokenops/main/examples/demo-assets/videos/02_governance_on_budget_cap.png" alt="TokenOps Dashboard: governance halt when worst-case cost exceeds remaining run budget" width="720" />
63
+ <a href="https://github.com/theagentplane/tokenops/raw/main/examples/demo-assets/videos/02_governance_on_budget_cap.webm">
64
+ <img src="https://raw.githubusercontent.com/theagentplane/tokenops/main/examples/demo-assets/videos/02_governance_on_budget_cap.gif" alt="TokenOps demo: a governed Research to Summarize run halts when worst-case cost exceeds the remaining run budget, then the Dashboard shows spend and governance per agent" width="720" />
65
+ </a>
66
+
67
+ <sub><i>Governed Research → Summarize run: the budget cap halts spend mid-run, then the Dashboard attributes cost per agent. <a href="https://github.com/theagentplane/tokenops/raw/main/examples/demo-assets/videos/02_governance_on_budget_cap.webm">Full video</a>.</i></sub>
64
68
 
65
69
  </div>
66
70
 
@@ -14,7 +14,11 @@ Cap spend and steer behavior across a whole agent workflow — not per request
14
14
 
15
15
  <br>
16
16
 
17
- <img src="https://raw.githubusercontent.com/theagentplane/tokenops/main/examples/demo-assets/videos/02_governance_on_budget_cap.png" alt="TokenOps Dashboard: governance halt when worst-case cost exceeds remaining run budget" width="720" />
17
+ <a href="https://github.com/theagentplane/tokenops/raw/main/examples/demo-assets/videos/02_governance_on_budget_cap.webm">
18
+ <img src="https://raw.githubusercontent.com/theagentplane/tokenops/main/examples/demo-assets/videos/02_governance_on_budget_cap.gif" alt="TokenOps demo: a governed Research to Summarize run halts when worst-case cost exceeds the remaining run budget, then the Dashboard shows spend and governance per agent" width="720" />
19
+ </a>
20
+
21
+ <sub><i>Governed Research → Summarize run: the budget cap halts spend mid-run, then the Dashboard attributes cost per agent. <a href="https://github.com/theagentplane/tokenops/raw/main/examples/demo-assets/videos/02_governance_on_budget_cap.webm">Full video</a>.</i></sub>
18
22
 
19
23
  </div>
20
24
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "agent-tokenops"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  description = "TokenOps control plane and SDK for run-aware agent token governance"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-tokenops
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: TokenOps control plane and SDK for run-aware agent token governance
5
5
  Author: Susheem Koul, Tisha Chawla
6
6
  License-Expression: MIT
@@ -60,7 +60,11 @@ Cap spend and steer behavior across a whole agent workflow — not per request
60
60
 
61
61
  <br>
62
62
 
63
- <img src="https://raw.githubusercontent.com/theagentplane/tokenops/main/examples/demo-assets/videos/02_governance_on_budget_cap.png" alt="TokenOps Dashboard: governance halt when worst-case cost exceeds remaining run budget" width="720" />
63
+ <a href="https://github.com/theagentplane/tokenops/raw/main/examples/demo-assets/videos/02_governance_on_budget_cap.webm">
64
+ <img src="https://raw.githubusercontent.com/theagentplane/tokenops/main/examples/demo-assets/videos/02_governance_on_budget_cap.gif" alt="TokenOps demo: a governed Research to Summarize run halts when worst-case cost exceeds the remaining run budget, then the Dashboard shows spend and governance per agent" width="720" />
65
+ </a>
66
+
67
+ <sub><i>Governed Research → Summarize run: the budget cap halts spend mid-run, then the Dashboard attributes cost per agent. <a href="https://github.com/theagentplane/tokenops/raw/main/examples/demo-assets/videos/02_governance_on_budget_cap.webm">Full video</a>.</i></sub>
64
68
 
65
69
  </div>
66
70
 
@@ -24,6 +24,7 @@ src/tokenops/control/crossing.py
24
24
  src/tokenops/control/engine.py
25
25
  src/tokenops/control/governance_cache.py
26
26
  src/tokenops/control/http.py
27
+ src/tokenops/control/http_store.py
27
28
  src/tokenops/control/instrument.py
28
29
  src/tokenops/control/integration.py
29
30
  src/tokenops/control/ledger.py
@@ -4,7 +4,7 @@ from tokenops.env import load_env
4
4
 
5
5
  load_env()
6
6
 
7
- __version__ = "0.1.2"
7
+ __version__ = "0.2.0"
8
8
 
9
9
 
10
10
  def init() -> None:
@@ -15,9 +15,10 @@ from __future__ import annotations
15
15
  import logging
16
16
  import os
17
17
  from collections.abc import Mapping
18
- from typing import Any
18
+ from typing import Any, cast
19
19
 
20
20
  from tokenops.control.http import post_run, post_run_sync
21
+ from tokenops.control.http_store import HttpStore
21
22
  from tokenops.control.models import (
22
23
  GovernanceMode,
23
24
  RunAlreadyRegisteredError,
@@ -58,23 +59,27 @@ class ControlPlaneClient:
58
59
  raise ValueError("exactly one of url or store is required")
59
60
  self._url = url.rstrip("/") if url else None
60
61
  self._store = store
61
- self._hybrid_store: Store | None = None
62
+ self._hybrid_store: Any = None
62
63
  self._timeout = timeout
63
64
 
64
65
  @classmethod
65
66
  def from_env(cls, *, timeout: float = 30.0) -> ControlPlaneClient:
66
67
  """Build a client from ``TOKENOPS_URL`` / ``TOKENOPS_EMBEDDED`` / ``TOKENOPS_DB``.
67
68
 
68
- * ``TOKENOPS_URL`` set and ``TOKENOPS_EMBEDDED`` not ``1`` → HTTP to the plane.
69
- * otherwise → embedded :class:`Store` at ``TOKENOPS_DB`` (default ``tokenops.db``).
70
-
71
- Also installs the Chronicle crossing hook (idempotent; design notes §17).
69
+ * ``TOKENOPS_URL`` or ``CONTROL_PLANE_URL`` set and ``TOKENOPS_EMBEDDED`` not ``1``
70
+ → HTTP to the plane (no local SQLite).
71
+ * otherwise → embedded :class:`Store` at ``TOKENOPS_DB``.
72
72
  """
73
73
  from tokenops.control.crossing import install_crossing_hook
74
74
 
75
75
  install_crossing_hook()
76
76
  embedded = os.environ.get("TOKENOPS_EMBEDDED", "").strip() == "1"
77
- url = (os.environ.get("TOKENOPS_URL") or "").strip()
77
+ url = (
78
+ os.environ.get("CONTROL_PLANE_URL")
79
+ or os.environ.get("TOKENOPS_URL")
80
+ or os.environ.get("TOKENOPS_CONTROL_PLANE_URL")
81
+ or ""
82
+ ).strip()
78
83
  if url and not embedded:
79
84
  return cls(url=url, timeout=timeout)
80
85
  db = os.environ.get("TOKENOPS_DB", "tokenops.db")
@@ -98,19 +103,18 @@ class ControlPlaneClient:
98
103
  return self._store
99
104
 
100
105
  def require_store(self) -> Store:
101
- """Backing Store for ledger / config / dashboard rows (escape hatch).
106
+ """Backing store for ledger / config / dashboard rows.
102
107
 
103
- Embedded mode returns the in-process Store. Remote registration mode
104
- lazily opens shared ``TOKENOPS_DB`` for ledger and config until those
105
- plane APIs are HTTP (§4 / later waves).
108
+ Embedded mode returns the in-process SQLite Store. Remote mode returns
109
+ :class:`HttpStore` — never a local DB file.
106
110
  """
107
111
  if self._store is not None:
108
112
  return self._store
109
113
  if self._hybrid_store is None:
110
- db = os.environ.get("TOKENOPS_DB", "tokenops.db")
111
- logger.debug("tokenops.client hybrid_store path=%s", db)
112
- self._hybrid_store = Store(db)
113
- return self._hybrid_store
114
+ assert self._url is not None
115
+ key = os.environ.get("CONTROL_PLANE_API_KEY") or os.environ.get("TOKENOPS_API_KEY")
116
+ self._hybrid_store = HttpStore(self._url, api_key=key, timeout=self._timeout)
117
+ return cast(Store, self._hybrid_store)
114
118
 
115
119
  def register_run(
116
120
  self,
@@ -6,6 +6,7 @@ wrap handlers here so Halt/Throttled map to HTTP responses.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import os
9
10
  from collections.abc import Awaitable, Callable, Mapping
10
11
  from typing import Any
11
12
 
@@ -107,6 +108,15 @@ def _raise_for_response(response: httpx.Response) -> None:
107
108
  ) from exc
108
109
 
109
110
 
111
+ def _plane_auth_headers() -> dict[str, str]:
112
+ key = (
113
+ os.environ.get("CONTROL_PLANE_API_KEY") or os.environ.get("TOKENOPS_API_KEY") or ""
114
+ ).strip()
115
+ if not key:
116
+ return {}
117
+ return {"Authorization": f"Bearer {key}"}
118
+
119
+
110
120
  async def post_run(
111
121
  url: str,
112
122
  payload: dict[str, Any],
@@ -116,7 +126,7 @@ async def post_run(
116
126
  """POST ``/v1/runs`` at *url*. Prefer :class:`~tokenops.control.client.ControlPlaneClient`."""
117
127
  base = url.rstrip("/")
118
128
  async with httpx.AsyncClient(timeout=timeout) as client:
119
- response = await client.post(f"{base}/v1/runs", json=payload)
129
+ response = await client.post(f"{base}/v1/runs", json=payload, headers=_plane_auth_headers())
120
130
  _raise_for_response(response)
121
131
  return response.json()
122
132
 
@@ -130,6 +140,6 @@ def post_run_sync(
130
140
  """Sync POST ``/v1/runs`` at *url*. Prefer :class:`~tokenops.control.client.ControlPlaneClient`."""
131
141
  base = url.rstrip("/")
132
142
  with httpx.Client(timeout=timeout) as client:
133
- response = client.post(f"{base}/v1/runs", json=payload)
143
+ response = client.post(f"{base}/v1/runs", json=payload, headers=_plane_auth_headers())
134
144
  _raise_for_response(response)
135
145
  return response.json()
@@ -0,0 +1,217 @@
1
+ """HTTP-backed Store used when TOKENOPS_URL / CONTROL_PLANE_URL is set.
2
+
3
+ Does not open SQLite. All ledger, governance, and run-history traffic goes to
4
+ the control plane process.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from tokenops.control.models import (
14
+ BudgetSpec,
15
+ PolicyInstance,
16
+ RunAlreadyRegisteredError,
17
+ RunNotRegisteredError,
18
+ RunRecord,
19
+ RunRegistration,
20
+ Segment,
21
+ parse_governance_mode,
22
+ )
23
+
24
+
25
+ class HttpStore:
26
+ def __init__(self, base_url: str, *, api_key: str | None = None, timeout: float = 30.0) -> None:
27
+ self.path = base_url.rstrip("/")
28
+ headers = {}
29
+ if api_key:
30
+ headers["Authorization"] = f"Bearer {api_key}"
31
+ self._client = httpx.Client(base_url=self.path, timeout=timeout, headers=headers)
32
+
33
+ def close(self) -> None:
34
+ self._client.close()
35
+
36
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
37
+ response = self._client.request(method, path, **kwargs)
38
+ if response.status_code == 409:
39
+ body = response.json()
40
+ raise RunAlreadyRegisteredError(body.get("error", "run already registered"))
41
+ if response.status_code == 404:
42
+ return response
43
+ response.raise_for_status()
44
+ return response
45
+
46
+ def register_run(self, reg: RunRegistration) -> RunRegistration:
47
+ r = self._request(
48
+ "POST",
49
+ "/v1/runs",
50
+ json={
51
+ "run_id": reg.run_id,
52
+ "intent": reg.intent,
53
+ "user_dims": dict(reg.user_dims),
54
+ "mode": reg.mode.value,
55
+ },
56
+ )
57
+ body = r.json()
58
+ return RunRegistration(
59
+ run_id=str(body["run_id"]),
60
+ intent=str(body.get("intent") or reg.intent),
61
+ user_dims={str(k): str(v) for k, v in (body.get("user_dims") or reg.user_dims).items()},
62
+ mode=parse_governance_mode(body.get("mode") or reg.mode),
63
+ )
64
+
65
+ def resolve_run(self, run_id: str) -> RunRegistration:
66
+ r = self._client.get(f"/v1/runs/{run_id}/registration")
67
+ if r.status_code == 404:
68
+ raise RunNotRegisteredError(f"run {run_id!r} is not registered")
69
+ r.raise_for_status()
70
+ body = r.json()
71
+ return RunRegistration(
72
+ run_id=str(body["run_id"]),
73
+ intent=str(body.get("intent", "")),
74
+ user_dims={str(k): str(v) for k, v in (body.get("user_dims") or {}).items()},
75
+ mode=parse_governance_mode(body.get("mode")),
76
+ )
77
+
78
+ def get_run_registration(self, run_id: str) -> RunRegistration | None:
79
+ try:
80
+ return self.resolve_run(run_id)
81
+ except RunNotRegisteredError:
82
+ return None
83
+
84
+ def governance_config_for(self, agent: str) -> dict:
85
+ return self._request("GET", f"/v1/governance/{agent}").json()
86
+
87
+ def create_run(self, rec: RunRecord) -> RunRecord:
88
+ payload = {
89
+ "run_id": rec.run_id,
90
+ "agent": rec.agent,
91
+ "status": rec.status,
92
+ "parent_run": rec.parent_run,
93
+ "parent_span": rec.parent_span,
94
+ "halt_reason": rec.halt_reason,
95
+ "detector": rec.detector,
96
+ "cost_micros": rec.cost_micros,
97
+ "steps": rec.steps,
98
+ "started_at": rec.started_at,
99
+ "ended_at": rec.ended_at,
100
+ "task": rec.task,
101
+ "dims": dict(rec.dims),
102
+ "governance_events": list(rec.governance_events or []),
103
+ }
104
+ self._request("PUT", "/v1/run-records", json=payload)
105
+ return rec
106
+
107
+ def update_run(self, run_id: str, **fields: Any) -> None:
108
+ self._request("PATCH", f"/v1/run-records/{run_id}", json=fields)
109
+
110
+ def get_run(self, run_id: str) -> RunRecord | None:
111
+ r = self._client.get(f"/v1/run-records/{run_id}")
112
+ if r.status_code == 404:
113
+ return None
114
+ r.raise_for_status()
115
+ return _run(r.json())
116
+
117
+ def list_runs(self, *, problematic_only: bool = False, limit: int = 200) -> list[RunRecord]:
118
+ r = self._request(
119
+ "GET",
120
+ "/v1/run-records",
121
+ params={"problematic_only": problematic_only, "limit": limit},
122
+ )
123
+ return [_run(x) for x in r.json()]
124
+
125
+ def ledger_add_spent(self, budget_id: str, segment_key: str, period: str, delta: int) -> int:
126
+ r = self._request(
127
+ "POST",
128
+ "/v1/ledger/spent/add",
129
+ json={
130
+ "budget_id": budget_id,
131
+ "segment_key": segment_key,
132
+ "period": period,
133
+ "delta": delta,
134
+ },
135
+ )
136
+ return int(r.json()["spent_micros"])
137
+
138
+ def ledger_get_spent(self, budget_id: str, segment_key: str, period: str) -> int:
139
+ r = self._request(
140
+ "GET",
141
+ "/v1/ledger/spent",
142
+ params={"budget_id": budget_id, "segment_key": segment_key, "period": period},
143
+ )
144
+ return int(r.json()["spent_micros"])
145
+
146
+ def ledger_admit(self, segment_key: str) -> int:
147
+ r = self._request("POST", "/v1/ledger/inflight/admit", json={"segment_key": segment_key})
148
+ return int(r.json()["count"])
149
+
150
+ def ledger_complete(self, segment_key: str) -> int:
151
+ r = self._request("POST", "/v1/ledger/inflight/complete", json={"segment_key": segment_key})
152
+ return int(r.json()["count"])
153
+
154
+ def ledger_inflight(self, segment_key: str) -> int:
155
+ r = self._request("GET", "/v1/ledger/inflight", params={"segment_key": segment_key})
156
+ return int(r.json()["count"])
157
+
158
+ def ledger_mark_halted(self, run_id: str, reason: str = "") -> None:
159
+ self._request("POST", "/v1/ledger/halt/mark", json={"run_id": run_id, "reason": reason})
160
+
161
+ def ledger_is_halted(self, run_id: str) -> bool:
162
+ r = self._request("GET", f"/v1/ledger/halt/{run_id}")
163
+ return bool(r.json().get("halted"))
164
+
165
+ def ledger_halt_reason(self, run_id: str) -> str | None:
166
+ r = self._client.get(f"/v1/ledger/halt/{run_id}")
167
+ r.raise_for_status()
168
+ return r.json().get("halt_reason")
169
+
170
+ def ledger_clear_halt(self, run_id: str) -> None:
171
+ self._request("POST", "/v1/ledger/halt/clear", json={"run_id": run_id})
172
+
173
+ # Trajectory index is plane-local for now; no-ops keep policies from crashing.
174
+ def save_trajectory_snapshot(self, *args: Any, **kwargs: Any) -> None:
175
+ return None
176
+
177
+ def enqueue_trajectory_build(self, *args: Any, **kwargs: Any) -> None:
178
+ return None
179
+
180
+ def lookup_trajectory_index(self, **kwargs: Any) -> dict[str, Any] | None:
181
+ return None
182
+
183
+ def upsert_segment(self, seg: Segment) -> Segment:
184
+ self._request("PUT", "/v1/segments", json=seg.__dict__)
185
+ return seg
186
+
187
+ def list_segments(self) -> list[Segment]:
188
+ return [Segment(**x) for x in self._request("GET", "/v1/segments").json()]
189
+
190
+ def upsert_budget(self, b: BudgetSpec) -> BudgetSpec:
191
+ self._request("PUT", "/v1/budgets", json=b.__dict__)
192
+ return b
193
+
194
+ def list_budgets(self) -> list[BudgetSpec]:
195
+ return [BudgetSpec(**x) for x in self._request("GET", "/v1/budgets").json()]
196
+
197
+ def list_policy_instances(self) -> list[PolicyInstance]:
198
+ return [PolicyInstance(**x) for x in self._request("GET", "/v1/policies").json()]
199
+
200
+
201
+ def _run(data: dict[str, Any]) -> RunRecord:
202
+ return RunRecord(
203
+ run_id=str(data["run_id"]),
204
+ agent=str(data["agent"]),
205
+ status=data.get("status", "running"),
206
+ parent_run=data.get("parent_run"),
207
+ parent_span=data.get("parent_span"),
208
+ halt_reason=data.get("halt_reason"),
209
+ detector=data.get("detector"),
210
+ cost_micros=int(data.get("cost_micros", 0)),
211
+ steps=int(data.get("steps", 0)),
212
+ started_at=float(data.get("started_at", 0.0)),
213
+ ended_at=data.get("ended_at"),
214
+ task=data.get("task"),
215
+ dims={str(k): str(v) for k, v in (data.get("dims") or {}).items()},
216
+ governance_events=list(data.get("governance_events") or []),
217
+ )
@@ -32,6 +32,7 @@ import uuid
32
32
  from collections.abc import Callable
33
33
  from typing import Any, TypeVar
34
34
 
35
+ from tokenops.control.ledger import LIFETIME, RUN_TOTAL_BUDGET
35
36
  from tokenops.control.models import (
36
37
  BudgetSpec,
37
38
  PolicyInstance,
@@ -377,6 +378,18 @@ class Store:
377
378
  (reg.run_id, reg.intent, json.dumps(reg.user_dims), reg.mode.value, time.time()),
378
379
  )
379
380
  self._db.commit()
381
+ # Dashboard reads ``runs``; registration alone must make the run visible.
382
+ # Explicit create_run/update_run remain for richer status/cost/steps (REPLACE-safe).
383
+ agent = reg.intent or "agent"
384
+ self.create_run(
385
+ RunRecord(
386
+ run_id=reg.run_id,
387
+ agent=agent,
388
+ status="running",
389
+ dims=dict(reg.user_dims),
390
+ task=reg.intent or None,
391
+ )
392
+ )
380
393
  return reg
381
394
 
382
395
  @_locked
@@ -484,7 +497,7 @@ class Store:
484
497
  @_locked
485
498
  def get_run(self, run_id: str) -> RunRecord | None:
486
499
  row = self._db.execute("SELECT * FROM runs WHERE run_id=?", (run_id,)).fetchone()
487
- return _run(row) if row else None
500
+ return self._run_with_ledger_cost(row) if row else None
488
501
 
489
502
  @_locked
490
503
  def list_runs(self, *, problematic_only: bool = False, limit: int = 200) -> list[RunRecord]:
@@ -492,7 +505,19 @@ class Store:
492
505
  if problematic_only:
493
506
  sql += " WHERE status IN ('halted','throttled','error')"
494
507
  sql += " ORDER BY started_at DESC LIMIT ?"
495
- return [_run(r) for r in self._db.execute(sql, (limit,))]
508
+ return [self._run_with_ledger_cost(r) for r in self._db.execute(sql, (limit,))]
509
+
510
+ def _run_with_ledger_cost(self, row: sqlite3.Row) -> RunRecord:
511
+ """Build a RunRecord; prefer ``__run_total__`` ledger spend when present."""
512
+ rec = _run(row)
513
+ spent_row = self._db.execute(
514
+ "SELECT spent_micros FROM ledger_spent "
515
+ "WHERE budget_id=? AND segment_key=? AND period=?",
516
+ (RUN_TOTAL_BUDGET.budget_id, f"run:{rec.run_id}", LIFETIME),
517
+ ).fetchone()
518
+ if spent_row is not None:
519
+ rec.cost_micros = int(spent_row[0])
520
+ return rec
496
521
 
497
522
  @_locked
498
523
  def run_tag_keys(self, *, limit: int = 500) -> list[str]:
@@ -581,6 +606,11 @@ class Store:
581
606
  "halt_reason=COALESCE(excluded.halt_reason, ledger_halt.halt_reason)",
582
607
  (run_id, reason or None),
583
608
  )
609
+ # Keep dashboard row in sync when present; ignore missing runs rows.
610
+ self._db.execute(
611
+ "UPDATE runs SET status='halted', halt_reason=? WHERE run_id=?",
612
+ (reason or None, run_id),
613
+ )
584
614
  self._db.commit()
585
615
 
586
616
  @_locked
@@ -606,6 +636,11 @@ class Store:
606
636
  "ON CONFLICT(run_id) DO UPDATE SET halted=0, halt_reason=NULL",
607
637
  (run_id,),
608
638
  )
639
+ # Clear halt_reason only — do not invent a completed/running status.
640
+ self._db.execute(
641
+ "UPDATE runs SET halt_reason=NULL WHERE run_id=?",
642
+ (run_id,),
643
+ )
609
644
  self._db.commit()
610
645
 
611
646
  # ---- trajectory hint index -------------------------------------------- #
@@ -6,7 +6,14 @@ import pytest
6
6
 
7
7
  from conftest import toy_price
8
8
  from tokenops.control import build_governor
9
- from tokenops.control.models import BudgetSpec, PolicyInstance, RunRecord, Segment
9
+ from tokenops.control.ledger import LIFETIME, RUN_TOTAL_BUDGET
10
+ from tokenops.control.models import (
11
+ BudgetSpec,
12
+ PolicyInstance,
13
+ RunRecord,
14
+ RunRegistration,
15
+ Segment,
16
+ )
10
17
  from tokenops.control.store import Store
11
18
 
12
19
 
@@ -77,6 +84,59 @@ def test_run_records_and_problematic_filter(store):
77
84
  assert problematic[0].halt_reason == "budget exhausted"
78
85
 
79
86
 
87
+ def test_register_run_creates_dashboard_row(store):
88
+ """Registration alone must surface on the Admin Dashboard (list_runs / get_run)."""
89
+ store.register_run(
90
+ RunRegistration(
91
+ run_id="reg-only",
92
+ intent="summarize",
93
+ user_dims={"team": "growth"},
94
+ )
95
+ )
96
+ got = store.get_run("reg-only")
97
+ assert got is not None
98
+ assert got.agent == "summarize"
99
+ assert got.status == "running"
100
+ assert got.task == "summarize"
101
+ assert got.dims == {"team": "growth"}
102
+ assert got.cost_micros == 0
103
+ listed = store.list_runs()
104
+ assert [r.run_id for r in listed] == ["reg-only"]
105
+
106
+ store.ledger_add_spent(RUN_TOTAL_BUDGET.budget_id, "run:reg-only", LIFETIME, 12_345)
107
+ assert store.get_run("reg-only").cost_micros == 12_345
108
+ assert store.list_runs()[0].cost_micros == 12_345
109
+
110
+ # Explicit create_run after register remains REPLACE-safe.
111
+ store.create_run(
112
+ RunRecord(
113
+ run_id="reg-only",
114
+ agent="summarize",
115
+ status="completed",
116
+ cost_micros=99,
117
+ dims={"team": "growth"},
118
+ task="summarize",
119
+ )
120
+ )
121
+ after = store.get_run("reg-only")
122
+ assert after.status == "completed"
123
+ assert after.cost_micros == 12_345 # ledger still overlays
124
+
125
+ store.ledger_mark_halted("reg-only", reason="budget exhausted")
126
+ halted = store.get_run("reg-only")
127
+ assert halted.status == "halted"
128
+ assert halted.halt_reason == "budget exhausted"
129
+
130
+ store.ledger_clear_halt("reg-only")
131
+ cleared = store.get_run("reg-only")
132
+ assert cleared.halt_reason is None
133
+ assert cleared.status == "halted" # clear does not invent a new status
134
+
135
+ store.register_run(RunRegistration(run_id="no-intent"))
136
+ assert store.get_run("no-intent").agent == "agent"
137
+ assert store.get_run("no-intent").task is None
138
+
139
+
80
140
  def test_seed_default_governance_if_empty(tmp_path, monkeypatch):
81
141
  monkeypatch.delenv("TOKENOPS_SKIP_GOVERNANCE_SEED", raising=False)
82
142
  s = Store(str(tmp_path / "seed.db"), auto_seed=False)
File without changes