product-forge 0.2.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.
- forge_agent_bridge/__init__.py +11 -0
- forge_agent_bridge/acp.py +171 -0
- forge_agent_bridge/adapter.py +57 -0
- forge_agent_bridge/approvals.py +113 -0
- forge_agent_bridge/backup.py +67 -0
- forge_agent_bridge/broker.py +103 -0
- forge_agent_bridge/budgets.py +104 -0
- forge_agent_bridge/cli.py +191 -0
- forge_agent_bridge/collab.py +133 -0
- forge_agent_bridge/context.py +254 -0
- forge_agent_bridge/contracts.py +113 -0
- forge_agent_bridge/events.py +209 -0
- forge_agent_bridge/files.py +134 -0
- forge_agent_bridge/install/__init__.py +1 -0
- forge_agent_bridge/install/cli.py +99 -0
- forge_agent_bridge/install/lifecycle.py +254 -0
- forge_agent_bridge/integrity.py +112 -0
- forge_agent_bridge/intelligence.py +261 -0
- forge_agent_bridge/local_bridge.py +1430 -0
- forge_agent_bridge/metadata.py +72 -0
- forge_agent_bridge/orchestrator.py +126 -0
- forge_agent_bridge/pairing.py +144 -0
- forge_agent_bridge/pf_context.py +122 -0
- forge_agent_bridge/providers/__init__.py +0 -0
- forge_agent_bridge/providers/acp_adapter.py +233 -0
- forge_agent_bridge/providers/claude.py +292 -0
- forge_agent_bridge/providers/codex.py +223 -0
- forge_agent_bridge/providers/copilot.py +116 -0
- forge_agent_bridge/providers/fake.py +88 -0
- forge_agent_bridge/recovery.py +44 -0
- forge_agent_bridge/registry.py +189 -0
- forge_agent_bridge/runners.py +111 -0
- forge_agent_bridge/secrets.py +116 -0
- forge_agent_bridge/ssrf.py +108 -0
- forge_agent_bridge/ui/__init__.py +29 -0
- forge_agent_bridge/ui/i18n.py +143 -0
- forge_agent_bridge/ui/kit.py +689 -0
- forge_agent_bridge/ui/wizard.py +317 -0
- product_forge/__init__.py +13 -0
- product_forge/__main__.py +4 -0
- product_forge/_forge_portal.py +1165 -0
- product_forge/_installkit.py +1825 -0
- product_forge/agents/__init__.py +0 -0
- product_forge/agents/agent-ops-coordinator.md +17 -0
- product_forge/agents/bedrock_provider.py +120 -0
- product_forge/agents/data-analyst-coordinator.md +18 -0
- product_forge/agents/evals.py +53 -0
- product_forge/agents/executors/pf-extractor.md +16 -0
- product_forge/agents/executors/pf-judge.md +16 -0
- product_forge/agents/executors/pf-synthesizer.md +14 -0
- product_forge/agents/executors/pf-verifier.md +14 -0
- product_forge/agents/integration-coordinator.md +16 -0
- product_forge/agents/llm_runtime.py +143 -0
- product_forge/agents/planning-coordinator.md +17 -0
- product_forge/agents/policy.py +108 -0
- product_forge/agents/product-intel-coordinator.md +19 -0
- product_forge/agents/routing.py +54 -0
- product_forge/agents/runtime.py +139 -0
- product_forge/agents/tools.py +155 -0
- product_forge/agents/workflows.py +98 -0
- product_forge/analytics/__init__.py +0 -0
- product_forge/analytics/connectors.py +204 -0
- product_forge/analytics/engine.py +163 -0
- product_forge/analytics/metrics.py +70 -0
- product_forge/analytics/sqlvalidate.py +150 -0
- product_forge/api/__init__.py +0 -0
- product_forge/api/app.py +1625 -0
- product_forge/cli/__init__.py +706 -0
- product_forge/config.py +120 -0
- product_forge/dashboards/__init__.py +0 -0
- product_forge/dashboards/render.py +70 -0
- product_forge/delegates/__init__.py +1 -0
- product_forge/delegates/catalog.py +45 -0
- product_forge/delegates/runner.py +66 -0
- product_forge/domain/__init__.py +1 -0
- product_forge/domain/models.py +365 -0
- product_forge/errors.py +68 -0
- product_forge/gateway/__init__.py +1 -0
- product_forge/gateway/synthesis.py +85 -0
- product_forge/install/__init__.py +1 -0
- product_forge/install/cli.py +89 -0
- product_forge/install/lifecycle.py +348 -0
- product_forge/install/managed_runtime.py +192 -0
- product_forge/integrations/__init__.py +0 -0
- product_forge/integrations/apispec.py +115 -0
- product_forge/integrations/aws.py +155 -0
- product_forge/integrations/azure.py +100 -0
- product_forge/integrations/datacontract.py +71 -0
- product_forge/intel/__init__.py +1 -0
- product_forge/intel/experiments.py +74 -0
- product_forge/intel/health.py +116 -0
- product_forge/intel/notebooks.py +58 -0
- product_forge/jobs/__init__.py +1 -0
- product_forge/jobs/registry.py +140 -0
- product_forge/knowledge/__init__.py +0 -0
- product_forge/knowledge/extract.py +88 -0
- product_forge/knowledge/graph.py +41 -0
- product_forge/knowledge/ingest.py +46 -0
- product_forge/mcp/__init__.py +0 -0
- product_forge/mcp/server.py +197 -0
- product_forge/notifications.py +86 -0
- product_forge/operations/__init__.py +0 -0
- product_forge/operations/approvals.py +46 -0
- product_forge/operations/proposals.py +90 -0
- product_forge/operations/reports.py +57 -0
- product_forge/operations/watches.py +98 -0
- product_forge/planning/__init__.py +0 -0
- product_forge/planning/prd.py +54 -0
- product_forge/planning/prioritize.py +41 -0
- product_forge/planning/roadmap.py +34 -0
- product_forge/portal/__init__.py +0 -0
- product_forge/portal/adapter.py +1030 -0
- product_forge/portal/runtime.py +519 -0
- product_forge/portal_assets/angular/3rdpartylicenses.txt +378 -0
- product_forge/portal_assets/angular/browser/chunk-1Rc4lGsF.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-3CbjAJ_e.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-6Q7VGqCu.js +5 -0
- product_forge/portal_assets/angular/browser/chunk-7Pu1Blam.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-9tQEpXMK.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-B3Nny4Hq.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-B57_NLXQ.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-BB5JZCTs.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-BE-Wy_fS.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-BF7hAyQJ.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-BUHV7CDc.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-BXHyUfJw.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-BYYLUO5t.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-BabKVLA2.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-BaebHyDv.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-BcvxjRzt.js +6 -0
- product_forge/portal_assets/angular/browser/chunk-BpaNj9LV.js +5 -0
- product_forge/portal_assets/angular/browser/chunk-Bsd27yxw.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-BuoTaa1i.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-BvumviSm.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-Bx56UW4B.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-C-syDP2A.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-C0Y1QJAk.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-C0nXO1-7.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-C18uha2k.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-CBAf-gZm.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-CFk9O-Id.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-CI7NSb1M.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-CLPLzFYs.js +322 -0
- product_forge/portal_assets/angular/browser/chunk-COrc5dSH.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-CSXp8raW.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-CTSic6yw.js +5 -0
- product_forge/portal_assets/angular/browser/chunk-C_7Rm93G.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-Cbyie2QS.js +3 -0
- product_forge/portal_assets/angular/browser/chunk-Ci5zXqV6.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-CkDgYK04.js +5 -0
- product_forge/portal_assets/angular/browser/chunk-CsgUuQ0S.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-Ct1MVd7z.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-Cut2Ourb.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-D-7Jr8A3.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-DJsvMftW.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-DUq33ZpV.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-DaUeWbRk.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-DfrvIVuy.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-DicAb5_-.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-DixPCieV.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-DtpQQeVl.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-DuZy_X7l.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-DvwzUgSZ.js +3 -0
- product_forge/portal_assets/angular/browser/chunk-EiSuk_kZ.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-H_dZuloj.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-JrAEILC6.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-S0yVTZM9.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-Tfobdq9w.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-Wd_5yY1t.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-X_IBVudt.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-XtegKBNb.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-jdQ8jbI8.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-k_4TaJG-.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-kwRnGzdM.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-nGNTa8fb.js +1 -0
- product_forge/portal_assets/angular/browser/chunk-nf7vIxR7.js +1 -0
- product_forge/portal_assets/angular/browser/favicon.ico +0 -0
- product_forge/portal_assets/angular/browser/index.html +13 -0
- product_forge/portal_assets/angular/browser/main-SCGIMYBN.js +1 -0
- product_forge/portal_assets/angular/browser/manifest.json +11 -0
- product_forge/portal_assets/angular/browser/styles-WNQNXSWU.css +1 -0
- product_forge/portal_assets/app.js +16 -0
- product_forge/portal_assets/index.html +28 -0
- product_forge/portal_assets/style.css +7 -0
- product_forge/reports/__init__.py +0 -0
- product_forge/security.py +158 -0
- product_forge/services/__init__.py +0 -0
- product_forge/skills/index.json +70 -0
- product_forge/skills/pf-agent-factory/SKILL.md +45 -0
- product_forge/skills/pf-agent-factory/agents/openai.yaml +4 -0
- product_forge/skills/pf-analytics/SKILL.md +43 -0
- product_forge/skills/pf-analytics/agents/openai.yaml +4 -0
- product_forge/skills/pf-apis/SKILL.md +40 -0
- product_forge/skills/pf-apis/agents/openai.yaml +4 -0
- product_forge/skills/pf-aws/SKILL.md +39 -0
- product_forge/skills/pf-aws/agents/openai.yaml +4 -0
- product_forge/skills/pf-azure/SKILL.md +38 -0
- product_forge/skills/pf-azure/agents/openai.yaml +4 -0
- product_forge/skills/pf-dashboards/SKILL.md +42 -0
- product_forge/skills/pf-dashboards/agents/openai.yaml +4 -0
- product_forge/skills/pf-data-products/SKILL.md +39 -0
- product_forge/skills/pf-data-products/agents/openai.yaml +4 -0
- product_forge/skills/pf-finops/SKILL.md +39 -0
- product_forge/skills/pf-finops/agents/openai.yaml +4 -0
- product_forge/skills/pf-governance/SKILL.md +39 -0
- product_forge/skills/pf-governance/agents/openai.yaml +4 -0
- product_forge/skills/pf-knowledge/SKILL.md +41 -0
- product_forge/skills/pf-knowledge/agents/openai.yaml +4 -0
- product_forge/skills/pf-prioritization/SKILL.md +39 -0
- product_forge/skills/pf-prioritization/agents/openai.yaml +4 -0
- product_forge/skills/pf-product-discovery/SKILL.md +42 -0
- product_forge/skills/pf-product-discovery/agents/openai.yaml +4 -0
- product_forge/skills/pf-product-strategy/SKILL.md +44 -0
- product_forge/skills/pf-product-strategy/agents/openai.yaml +4 -0
- product_forge/skills/pf-reporting/SKILL.md +39 -0
- product_forge/skills/pf-reporting/agents/openai.yaml +4 -0
- product_forge/skills/pf-requirements/SKILL.md +39 -0
- product_forge/skills/pf-requirements/agents/openai.yaml +4 -0
- product_forge/skills/pf-sql/SKILL.md +45 -0
- product_forge/skills/pf-sql/agents/openai.yaml +4 -0
- product_forge/store/__init__.py +3 -0
- product_forge/store/postgres.py +265 -0
- product_forge/store/sqlite.py +373 -0
- product_forge/ui/__init__.py +29 -0
- product_forge/ui/i18n.py +143 -0
- product_forge/ui/kit.py +689 -0
- product_forge/ui/wizard.py +282 -0
- product_forge-0.2.0.dist-info/METADATA +175 -0
- product_forge-0.2.0.dist-info/RECORD +232 -0
- product_forge-0.2.0.dist-info/WHEEL +4 -0
- product_forge-0.2.0.dist-info/entry_points.txt +2 -0
- product_forge-0.2.0.dist-info/licenses/LICENSE +182 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Forge Agent Bridge — universal agent session layer.
|
|
2
|
+
|
|
3
|
+
The Bridge owns provider connections, session lifecycle, normalized
|
|
4
|
+
events and approval routing. The Portal owns experience; the agent
|
|
5
|
+
runtime executes; policies authorize. Public surface only — consumers
|
|
6
|
+
(product-forge, forge-portal) import these contracts, never internals.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
EVENT_SCHEMA = "forge/AgentBridgeEvent/v1"
|
|
11
|
+
CONTRACT_SCHEMA = "forge/AgentBridgeContract/v1"
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""ACP client (§19.2) — JSON-RPC over stdio: handshake, version
|
|
2
|
+
negotiation, session lifecycle, prompt, streaming, cancel.
|
|
3
|
+
|
|
4
|
+
Transport: newline-delimited JSON-RPC 2.0 on the agent's stdin/stdout.
|
|
5
|
+
No SDK is vendored; the wire contract is documented ACP semantics.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import itertools
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
import threading
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .adapter import BridgeError, ProviderUnavailableError
|
|
18
|
+
|
|
19
|
+
_ids = itertools.count(1)
|
|
20
|
+
PROTOCOL_VERSION = 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AcpClient:
|
|
24
|
+
"""Minimal ACP stdio client. One client per spawned agent process."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, cmd: list[str], timeout_s: float = 30.0):
|
|
27
|
+
self.cmd, self.timeout_s = cmd, timeout_s
|
|
28
|
+
self.proc: subprocess.Popen[bytes] | None = None
|
|
29
|
+
self._pending: dict[int, dict[str, Any]] = {}
|
|
30
|
+
self._notifications: list[dict[str, Any]] = []
|
|
31
|
+
self._lock = threading.Lock()
|
|
32
|
+
self._reader: threading.Thread | None = None
|
|
33
|
+
self.protocol_version: int | None = None
|
|
34
|
+
|
|
35
|
+
# -- transport ---------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def start(self) -> None:
|
|
38
|
+
try:
|
|
39
|
+
self.proc = subprocess.Popen(
|
|
40
|
+
self.cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
41
|
+
stderr=subprocess.DEVNULL)
|
|
42
|
+
except FileNotFoundError as exc:
|
|
43
|
+
raise ProviderUnavailableError(f"agent executable not found: {self.cmd[0]}") from exc
|
|
44
|
+
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
|
45
|
+
self._reader.start()
|
|
46
|
+
|
|
47
|
+
def _read_loop(self) -> None:
|
|
48
|
+
assert self.proc and self.proc.stdout
|
|
49
|
+
for line in self.proc.stdout:
|
|
50
|
+
try:
|
|
51
|
+
msg = json.loads(line)
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
continue
|
|
54
|
+
mid = msg.get("id")
|
|
55
|
+
if mid in self._pending:
|
|
56
|
+
self._pending[mid] = msg
|
|
57
|
+
else:
|
|
58
|
+
self._notifications.append(msg)
|
|
59
|
+
|
|
60
|
+
def request_async(self, method: str,
|
|
61
|
+
params: dict[str, Any] | None = None) -> int:
|
|
62
|
+
"""Send a request without waiting; returns the request id."""
|
|
63
|
+
if not self.proc or not self.proc.stdin:
|
|
64
|
+
raise BridgeError("FAB-TRANSPORT", "client not started")
|
|
65
|
+
rid = next(_ids)
|
|
66
|
+
self._pending[rid] = {}
|
|
67
|
+
self.proc.stdin.write(
|
|
68
|
+
json.dumps({"jsonrpc": "2.0", "id": rid, "method": method,
|
|
69
|
+
"params": params or {}}).encode() + b"\n")
|
|
70
|
+
self.proc.stdin.flush()
|
|
71
|
+
return rid
|
|
72
|
+
|
|
73
|
+
def response_ready(self, rid: int) -> dict[str, Any] | None:
|
|
74
|
+
return self._pending.get(rid) or None
|
|
75
|
+
|
|
76
|
+
def resolve(self, rid: int) -> dict[str, Any]:
|
|
77
|
+
"""Pop a ready response; raises on RPC error."""
|
|
78
|
+
resp = self._pending.pop(rid, {})
|
|
79
|
+
if "error" in resp:
|
|
80
|
+
e = resp["error"]
|
|
81
|
+
raise BridgeError(f"FAB-RPC-{e.get('code', 'ERR')}",
|
|
82
|
+
str(e.get("message", e)))
|
|
83
|
+
return resp.get("result", {})
|
|
84
|
+
|
|
85
|
+
def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
86
|
+
rid = self.request_async(method, params)
|
|
87
|
+
import time
|
|
88
|
+
deadline = time.monotonic() + self.timeout_s
|
|
89
|
+
while time.monotonic() < deadline:
|
|
90
|
+
if self.response_ready(rid):
|
|
91
|
+
return self.resolve(rid)
|
|
92
|
+
time.sleep(0.01)
|
|
93
|
+
self._pending.pop(rid, None)
|
|
94
|
+
raise BridgeError("FAB-TIMEOUT", f"{method} timed out after {self.timeout_s}s")
|
|
95
|
+
|
|
96
|
+
def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
97
|
+
if self.proc and self.proc.stdin:
|
|
98
|
+
self.proc.stdin.write(
|
|
99
|
+
json.dumps({"jsonrpc": "2.0", "method": method,
|
|
100
|
+
"params": params or {}}).encode() + b"\n")
|
|
101
|
+
self.proc.stdin.flush()
|
|
102
|
+
|
|
103
|
+
def drain_notifications(self) -> list[dict[str, Any]]:
|
|
104
|
+
"""Non-blocking: pop all queued notifications (turn already done)."""
|
|
105
|
+
with self._lock:
|
|
106
|
+
out, self._notifications = self._notifications, []
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
def notifications(self) -> Iterator[dict[str, Any]]:
|
|
110
|
+
while True:
|
|
111
|
+
if self._notifications:
|
|
112
|
+
yield self._notifications.pop(0)
|
|
113
|
+
else:
|
|
114
|
+
import time
|
|
115
|
+
time.sleep(0.02)
|
|
116
|
+
if self.proc and self.proc.poll() is not None and not self._notifications:
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
# -- ACP semantics -------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def initialize(self) -> dict[str, Any]:
|
|
122
|
+
res = self.request("initialize", {
|
|
123
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
124
|
+
"clientCapabilities": {"fs": {"readTextFile": True, "writeTextFile": True},
|
|
125
|
+
"terminal": False}})
|
|
126
|
+
self.protocol_version = res.get("protocolVersion", PROTOCOL_VERSION)
|
|
127
|
+
return res
|
|
128
|
+
|
|
129
|
+
def new_session(self, cwd: str = ".", mcp_servers: list[dict] | None = None) -> str:
|
|
130
|
+
res = self.request("session/new", {"cwd": cwd, "mcpServers": mcp_servers or []})
|
|
131
|
+
sid = res.get("sessionId")
|
|
132
|
+
if not sid:
|
|
133
|
+
raise BridgeError("FAB-SESSION", "session/new returned no sessionId")
|
|
134
|
+
return str(sid)
|
|
135
|
+
|
|
136
|
+
def prompt(self, session_id: str, text: str) -> dict[str, Any]:
|
|
137
|
+
return self.request("session/prompt", {
|
|
138
|
+
"sessionId": session_id,
|
|
139
|
+
"prompt": [{"type": "text", "text": text}]})
|
|
140
|
+
|
|
141
|
+
def prompt_async(self, session_id: str, text: str) -> int:
|
|
142
|
+
"""Start a turn without blocking — notifications stream while the
|
|
143
|
+
response is in flight. Resolve via resolve(rid)."""
|
|
144
|
+
return self.request_async("session/prompt", {
|
|
145
|
+
"sessionId": session_id,
|
|
146
|
+
"prompt": [{"type": "text", "text": text}]})
|
|
147
|
+
|
|
148
|
+
def load_session(self, session_id: str, cwd: str = ".",
|
|
149
|
+
mcp_servers: list[dict] | None = None) -> dict[str, Any]:
|
|
150
|
+
"""ACP session/load — resumes a previous session, replaying its
|
|
151
|
+
transcript as session/update notifications."""
|
|
152
|
+
return self.request("session/load", {
|
|
153
|
+
"sessionId": session_id, "cwd": cwd,
|
|
154
|
+
"mcpServers": mcp_servers or []})
|
|
155
|
+
|
|
156
|
+
def cancel(self, session_id: str) -> None:
|
|
157
|
+
self.notify("session/cancel", {"sessionId": session_id})
|
|
158
|
+
|
|
159
|
+
def close(self) -> None:
|
|
160
|
+
if self.proc:
|
|
161
|
+
with _suppress_all():
|
|
162
|
+
self.proc.terminate()
|
|
163
|
+
self.proc.wait(timeout=3)
|
|
164
|
+
if self.proc.poll() is None:
|
|
165
|
+
self.proc.kill()
|
|
166
|
+
self.proc = None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class _suppress_all:
|
|
170
|
+
def __enter__(self): return None
|
|
171
|
+
def __exit__(self, *a): return True
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Provider adapter interface (§5.2) — typed errors, no simulated capability."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import Any, Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from .contracts import AgentSession, ProviderDescriptor
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BridgeError(Exception):
|
|
12
|
+
"""Typed bridge error — code is part of the contract."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, code: str, message: str, *, provider: str | None = None):
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.code, self.message, self.provider = code, message, provider
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProviderUnavailableError(BridgeError):
|
|
20
|
+
def __init__(self, msg: str, provider: str | None = None):
|
|
21
|
+
super().__init__("FAB-UNAVAILABLE", msg, provider=provider)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UnsupportedCapabilityError(BridgeError):
|
|
25
|
+
def __init__(self, capability: str, provider: str | None = None):
|
|
26
|
+
super().__init__("FAB-UNSUPPORTED",
|
|
27
|
+
f"capability '{capability}' not supported", provider=provider)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AuthenticationError(BridgeError):
|
|
31
|
+
def __init__(self, msg: str, provider: str | None = None):
|
|
32
|
+
super().__init__("FAB-AUTH", msg, provider=provider)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SessionError(BridgeError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@runtime_checkable
|
|
40
|
+
class ProviderAdapter(Protocol):
|
|
41
|
+
"""§5.2 contract — unsupported features raise UnsupportedCapabilityError
|
|
42
|
+
or report unavailable state; never simulated behavior."""
|
|
43
|
+
|
|
44
|
+
def detect(self) -> ProviderDescriptor: ...
|
|
45
|
+
def get_version(self) -> str | None: ...
|
|
46
|
+
def get_auth_status(self) -> str: ...
|
|
47
|
+
def initialize(self) -> None: ...
|
|
48
|
+
def create_session(self, **kwargs: Any) -> AgentSession: ...
|
|
49
|
+
def resume_session(self, session_ref: str) -> AgentSession: ...
|
|
50
|
+
def list_sessions(self) -> list[AgentSession]: ...
|
|
51
|
+
def send_prompt(self, session_id: str, prompt: str,
|
|
52
|
+
attachments: list[dict[str, Any]] | None = None) -> None: ...
|
|
53
|
+
def stream_events(self, session_id: str) -> Iterator[dict[str, Any]]: ...
|
|
54
|
+
def respond_to_approval(self, approval_id: str, approved: bool) -> None: ...
|
|
55
|
+
def cancel(self, session_id: str) -> None: ...
|
|
56
|
+
def close_session(self, session_id: str) -> None: ...
|
|
57
|
+
def dispose(self) -> None: ...
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Approval tokens — bound, expiring, single-use authorizations (§25.4).
|
|
2
|
+
|
|
3
|
+
Replaces client-supplied ``approved: true`` booleans. The server mints an
|
|
4
|
+
approval request when a mutating op is attempted; the actor resolves it;
|
|
5
|
+
the resolved token's argument digest must match the retried operation
|
|
6
|
+
exactly — argument tampering invalidates it. Tokens are one-time.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import secrets as _secrets
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .adapter import BridgeError
|
|
18
|
+
|
|
19
|
+
TTL_S = 600.0 # 10 min
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def digest_args(args: dict[str, Any]) -> str:
|
|
23
|
+
"""Stable digest of the operation arguments being approved."""
|
|
24
|
+
canon = json.dumps(args, sort_keys=True, separators=(",", ":"))
|
|
25
|
+
return hashlib.sha256(canon.encode()).hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApprovalStore:
|
|
29
|
+
"""Pending and resolved approvals; resolved grants are one-time."""
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
self._pending: dict[str, dict[str, Any]] = {}
|
|
33
|
+
self._grants: dict[str, dict[str, Any]] = {}
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
|
|
36
|
+
def request(self, *, session_id: str, actor: str, resource: str,
|
|
37
|
+
action: str, args: dict[str, Any], risk: str = "medium",
|
|
38
|
+
scope: str = "session", turn_id: str = "") -> dict[str, Any]:
|
|
39
|
+
"""Create a pending approval bound to op+args digest."""
|
|
40
|
+
ap = {
|
|
41
|
+
"approval_id": f"ap_{_secrets.token_hex(8)}",
|
|
42
|
+
"session_id": session_id, "turn_id": turn_id,
|
|
43
|
+
"actor": actor, "resource": resource, "action": action,
|
|
44
|
+
"args_digest": digest_args(args), "scope": scope, "risk": risk,
|
|
45
|
+
"requested_at": time.time(), "expires_at": time.time() + TTL_S,
|
|
46
|
+
"state": "pending",
|
|
47
|
+
}
|
|
48
|
+
self._pending[ap["approval_id"]] = ap
|
|
49
|
+
return dict(ap)
|
|
50
|
+
|
|
51
|
+
def resolve(self, approval_id: str, *, approved: bool,
|
|
52
|
+
actor: str) -> dict[str, Any]:
|
|
53
|
+
with self._lock:
|
|
54
|
+
ap = self._pending.pop(approval_id, None)
|
|
55
|
+
if not ap:
|
|
56
|
+
raise BridgeError("FAB-NOT-FOUND", "unknown approval_id")
|
|
57
|
+
if time.time() > ap["expires_at"]:
|
|
58
|
+
raise BridgeError("FAB-VALIDATION", "approval request expired")
|
|
59
|
+
if not approved:
|
|
60
|
+
return {"resolved": True, "granted": False,
|
|
61
|
+
"approval_id": approval_id}
|
|
62
|
+
token = _secrets.token_urlsafe(24)
|
|
63
|
+
self._grants[token] = {
|
|
64
|
+
"approval_id": approval_id, "session_id": ap["session_id"],
|
|
65
|
+
"actor": actor, "requester": ap["actor"],
|
|
66
|
+
"resource": ap["resource"], "action": ap["action"],
|
|
67
|
+
"args_digest": ap["args_digest"], "scope": ap["scope"],
|
|
68
|
+
"risk": ap["risk"], "decided_at": time.time(),
|
|
69
|
+
"expires_at": time.time() + TTL_S, "consumed": False,
|
|
70
|
+
}
|
|
71
|
+
return {"resolved": True, "granted": True,
|
|
72
|
+
"approval_id": approval_id, "approval_token": token,
|
|
73
|
+
"expires_at": self._grants[token]["expires_at"]}
|
|
74
|
+
|
|
75
|
+
def consume(self, token: str, *, session_id: str, resource: str,
|
|
76
|
+
action: str, args: dict[str, Any],
|
|
77
|
+
actor: str | None = None) -> dict[str, Any]:
|
|
78
|
+
"""Verify + consume a grant: op must match digest exactly, once.
|
|
79
|
+
|
|
80
|
+
Consumption is atomic under the store lock: two simultaneous
|
|
81
|
+
requests cannot both observe ``consumed == False``.
|
|
82
|
+
"""
|
|
83
|
+
with self._lock:
|
|
84
|
+
g = self._grants.get(token)
|
|
85
|
+
if not g:
|
|
86
|
+
raise BridgeError("FAB-FORBIDDEN", "invalid approval token")
|
|
87
|
+
if g["consumed"]:
|
|
88
|
+
raise BridgeError("FAB-FORBIDDEN",
|
|
89
|
+
"approval token already consumed")
|
|
90
|
+
if time.time() > g["expires_at"]:
|
|
91
|
+
raise BridgeError("FAB-FORBIDDEN", "approval token expired")
|
|
92
|
+
if g["session_id"] != session_id or g["resource"] != resource \
|
|
93
|
+
or g["action"] != action:
|
|
94
|
+
raise BridgeError("FAB-FORBIDDEN",
|
|
95
|
+
"approval token does not cover this operation")
|
|
96
|
+
if g["args_digest"] != digest_args(args):
|
|
97
|
+
raise BridgeError("FAB-FORBIDDEN",
|
|
98
|
+
"operation arguments differ from approved")
|
|
99
|
+
if actor is not None and g["requester"] and \
|
|
100
|
+
actor != g["requester"]:
|
|
101
|
+
raise BridgeError(
|
|
102
|
+
"FAB-FORBIDDEN",
|
|
103
|
+
"approval was requested by a different actor")
|
|
104
|
+
g["consumed"] = True
|
|
105
|
+
return {"consumed": True, "approval_id": g["approval_id"],
|
|
106
|
+
"actor": g["actor"]}
|
|
107
|
+
|
|
108
|
+
def pending_for(self, session_id: str) -> list[dict[str, Any]]:
|
|
109
|
+
return [dict(a) for a in self._pending.values()
|
|
110
|
+
if a["session_id"] == session_id]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
APPROVALS = ApprovalStore()
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Cycle 28.6 — backup/restore of bridge state.
|
|
2
|
+
|
|
3
|
+
Backs up: session metadata, event history, context bindings, MCP registry,
|
|
4
|
+
budgets, collab, audit. **Never** backs up secrets: pair.token, pairing
|
|
5
|
+
device tokens, and any *.secret/keyring files are excluded by construction.
|
|
6
|
+
Manifest records what was captured so restore is verifiable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import tarfile
|
|
13
|
+
import tempfile
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
# never exported — credentials must not leave the device (§25.8, §28.6)
|
|
19
|
+
EXCLUDED = {"pair.token", "pairing.json", "secrets.json", "keyring.json"}
|
|
20
|
+
INCLUDED_SUFFIXES = (".json", ".jsonl")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_backup(state_dir: Path, out: Path | None = None) -> dict[str, Any]:
|
|
24
|
+
"""Tarball of non-secret state under `state_dir` → out (or state_dir/)."""
|
|
25
|
+
out = out or (state_dir / f"backup-{int(time.time())}.tar.gz")
|
|
26
|
+
manifest: dict[str, Any] = {"created_at": time.time(),
|
|
27
|
+
"files": [], "excluded": []}
|
|
28
|
+
with tempfile.TemporaryDirectory() as td, \
|
|
29
|
+
tarfile.open(out, "w:gz") as tar:
|
|
30
|
+
for f in sorted(state_dir.rglob("*")):
|
|
31
|
+
if not f.is_file():
|
|
32
|
+
continue
|
|
33
|
+
rel = f.relative_to(state_dir)
|
|
34
|
+
if f.name in EXCLUDED or any(
|
|
35
|
+
p.name in EXCLUDED for p in rel.parents):
|
|
36
|
+
manifest["excluded"].append(str(rel))
|
|
37
|
+
continue
|
|
38
|
+
if f.suffix not in INCLUDED_SUFFIXES:
|
|
39
|
+
manifest["excluded"].append(str(rel))
|
|
40
|
+
continue
|
|
41
|
+
tar.add(f, arcname=str(rel))
|
|
42
|
+
manifest["files"].append(
|
|
43
|
+
{"path": str(rel), "bytes": f.stat().st_size})
|
|
44
|
+
mf = Path(td) / "manifest.json"
|
|
45
|
+
mf.write_text(json.dumps(manifest, indent=1))
|
|
46
|
+
tar.add(mf, arcname="manifest.json")
|
|
47
|
+
manifest["out"] = str(out)
|
|
48
|
+
return manifest
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def restore_backup(backup: Path, state_dir: Path) -> dict[str, Any]:
|
|
52
|
+
"""Extract a backup into `state_dir`. Refuses secret paths — a backup
|
|
53
|
+
must never inject credentials (they are re-paired, not restored)."""
|
|
54
|
+
restored, refused = [], []
|
|
55
|
+
with tarfile.open(backup) as tar:
|
|
56
|
+
for m in tar.getmembers():
|
|
57
|
+
name = Path(m.name).name
|
|
58
|
+
if m.name.startswith("/") or ".." in Path(m.name).parts:
|
|
59
|
+
refused.append(m.name) # traversal guard
|
|
60
|
+
continue
|
|
61
|
+
if name in EXCLUDED:
|
|
62
|
+
refused.append(m.name) # no secrets restore
|
|
63
|
+
continue
|
|
64
|
+
if m.isfile():
|
|
65
|
+
tar.extract(m, state_dir, filter="data")
|
|
66
|
+
restored.append(m.name)
|
|
67
|
+
return {"restored": restored, "refused": refused}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Permission broker — central authorization decisions (§25.3).
|
|
2
|
+
|
|
3
|
+
Validates identity, session, product/workspace, operation, tool, policy;
|
|
4
|
+
decides allow / deny / require-approval; records the decision. The broker
|
|
5
|
+
complements — never replaces — the owning service's own authorization.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .adapter import BridgeError
|
|
13
|
+
|
|
14
|
+
# Operations that always require a bound approval token before execution.
|
|
15
|
+
APPROVAL_REQUIRED: dict[tuple[str, str], str] = {
|
|
16
|
+
("files", "upload"): "file-write",
|
|
17
|
+
("files", "restore"): "file-write",
|
|
18
|
+
("sessions", "spawn"): "process-spawn",
|
|
19
|
+
("runners", "register"): "runner-registration",
|
|
20
|
+
}
|
|
21
|
+
# Risk classification per action family.
|
|
22
|
+
RISK = {"file-write": "high", "process-spawn": "high",
|
|
23
|
+
"runner-registration": "medium", "prompt": "low",
|
|
24
|
+
"share": "medium", "approve": "medium", "handoff": "high"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def require_scope(actor: dict[str, Any], required: str) -> None:
|
|
28
|
+
"""Enforce route permissions; a conversational device is not admin."""
|
|
29
|
+
scopes = {str(s) for s in (actor.get("scopes") or [])}
|
|
30
|
+
if actor.get("kind") == "master" or "admin" in scopes or required in scopes:
|
|
31
|
+
return
|
|
32
|
+
grants = {
|
|
33
|
+
"agent:read": {"agent", "agent:read", "session:read", "provider:read"},
|
|
34
|
+
"agent:execute": {"agent", "agent:execute", "session:manage"},
|
|
35
|
+
"session:read": {"agent", "agent:read", "session:read"},
|
|
36
|
+
"session:manage": {"agent", "agent:execute", "session:manage"},
|
|
37
|
+
"provider:read": {"agent", "agent:read", "provider:read"},
|
|
38
|
+
"approval:read": {"agent", "agent:read", "approval:read"},
|
|
39
|
+
"approval:resolve": {"agent", "agent:execute", "approval:resolve"},
|
|
40
|
+
"files:read": {"agent", "agent:read", "files:read"},
|
|
41
|
+
}
|
|
42
|
+
if not scopes.intersection(grants.get(required, set())):
|
|
43
|
+
raise BridgeError("FAB-FORBIDDEN", f"scope required: {required}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Decision(dict):
|
|
47
|
+
"""Authorization decision record — auditable."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Broker:
|
|
51
|
+
"""Central op authorization; decisions are recorded, never silent."""
|
|
52
|
+
|
|
53
|
+
def __init__(self) -> None:
|
|
54
|
+
self.log: list[dict[str, Any]] = []
|
|
55
|
+
|
|
56
|
+
def authorize(self, *, actor: dict[str, Any] | None, op: str,
|
|
57
|
+
resource: str, session_id: str = "",
|
|
58
|
+
product_id: str = "", workspace_id: str = "",
|
|
59
|
+
tool: str = "", args: dict[str, Any] | None = None,
|
|
60
|
+
principal: str | None = None,
|
|
61
|
+
collab: Any = None) -> dict[str, Any]:
|
|
62
|
+
"""Return {allow, require_approval, risk, reason} — or raise FORBIDDEN.
|
|
63
|
+
|
|
64
|
+
Order: identity → operation risk → ownership/observer → approval need.
|
|
65
|
+
"""
|
|
66
|
+
args = args or {}
|
|
67
|
+
if actor is None:
|
|
68
|
+
self._record("deny", op, resource, "", "no-identity")
|
|
69
|
+
raise BridgeError("FAB-AUTH", "authentication required")
|
|
70
|
+
|
|
71
|
+
# identity: session tokens carry scopes; master carries 'admin'
|
|
72
|
+
scopes = actor.get("scopes") or ["admin"]
|
|
73
|
+
risk = RISK.get(op.split(":")[-1] if ":" in op else op, "medium")
|
|
74
|
+
|
|
75
|
+
# session-scope authorization via collab when a session is bound —
|
|
76
|
+
# ``principal`` is the caller-resolved actor (session tokens can't
|
|
77
|
+
# impersonate; master may assert for local dev).
|
|
78
|
+
if session_id and collab is not None:
|
|
79
|
+
who = principal or actor.get("actor", "local")
|
|
80
|
+
if op not in ("read", "comment"):
|
|
81
|
+
collab.assert_can_act(session_id, who) # raises
|
|
82
|
+
|
|
83
|
+
key = (resource.split(":")[0], op.split(":")[-1])
|
|
84
|
+
needs = APPROVAL_REQUIRED.get(key)
|
|
85
|
+
dec = {
|
|
86
|
+
"allow": True, "require_approval": needs is not None,
|
|
87
|
+
"approval_kind": needs, "risk": risk,
|
|
88
|
+
"actor": actor.get("actor", "?"), "op": op,
|
|
89
|
+
"resource": resource, "session_id": session_id,
|
|
90
|
+
"product_id": product_id, "workspace_id": workspace_id,
|
|
91
|
+
"tool": tool, "scopes": scopes,
|
|
92
|
+
}
|
|
93
|
+
self._record("allow" if not needs else "require-approval",
|
|
94
|
+
op, resource, dec["actor"], dec["approval_kind"] or "-")
|
|
95
|
+
return Decision(dec)
|
|
96
|
+
|
|
97
|
+
def _record(self, verdict: str, op: str, res: str,
|
|
98
|
+
actor: str, detail: str) -> None:
|
|
99
|
+
self.log.append({"ts": time.time(), "verdict": verdict, "op": op,
|
|
100
|
+
"resource": res, "actor": actor, "detail": detail})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
BROKER = Broker()
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Budget ledger (§23.4) — limits per scope/window over real usage.
|
|
2
|
+
|
|
3
|
+
Records token usage from usage.updated events; checks limits BEFORE a
|
|
4
|
+
prompt is accepted. Provider-reported tokens are real data; when a
|
|
5
|
+
provider reports nothing the entry is flagged estimated.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .adapter import BridgeError
|
|
16
|
+
from .contracts import utcnow
|
|
17
|
+
from .local_bridge import state_dir
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _day() -> str:
|
|
21
|
+
return datetime.now(UTC).strftime("%Y-%m-%d")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _month() -> str:
|
|
25
|
+
return datetime.now(UTC).strftime("%Y-%m")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BudgetLedger:
|
|
29
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
30
|
+
self.path = path or (state_dir() / "budget.json")
|
|
31
|
+
|
|
32
|
+
def _load(self) -> dict[str, Any]:
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(self.path.read_text())
|
|
35
|
+
except (OSError, json.JSONDecodeError):
|
|
36
|
+
return {"limits": {}, "usage": {}, "entries": {}}
|
|
37
|
+
|
|
38
|
+
def _save(self, data: dict[str, Any]) -> None:
|
|
39
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
self.path.write_text(json.dumps(data, indent=1))
|
|
41
|
+
self.path.chmod(0o600)
|
|
42
|
+
|
|
43
|
+
# -- limits --------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
def set_limit(self, scope: str, key: str, window: str,
|
|
46
|
+
tokens: int) -> dict[str, Any]:
|
|
47
|
+
"""scope: user|workspace|product|provider|session · window: day|month."""
|
|
48
|
+
if window not in ("day", "month"):
|
|
49
|
+
raise BridgeError("FAB-VALIDATION", "window must be day|month")
|
|
50
|
+
data = self._load()
|
|
51
|
+
data["limits"][f"{scope}:{key}:{window}"] = tokens
|
|
52
|
+
self._save(data)
|
|
53
|
+
return {"scope": scope, "key": key, "window": window, "tokens": tokens}
|
|
54
|
+
|
|
55
|
+
def limits(self) -> dict[str, Any]:
|
|
56
|
+
return dict(self._load()["limits"])
|
|
57
|
+
|
|
58
|
+
# -- usage ---------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
def record(self, scope_map: dict[str, str], tokens_in: int,
|
|
61
|
+
tokens_out: int, *, estimated: bool = False,
|
|
62
|
+
idempotency_key: str | None = None) -> bool:
|
|
63
|
+
"""scope_map: {user:.., workspace:.., product:.., provider:.., session:..}."""
|
|
64
|
+
data = self._load()
|
|
65
|
+
entries = data.setdefault("entries", {})
|
|
66
|
+
if idempotency_key and idempotency_key in entries:
|
|
67
|
+
return False
|
|
68
|
+
for scope, key in scope_map.items():
|
|
69
|
+
if not key:
|
|
70
|
+
continue
|
|
71
|
+
for window, wkey in (("day", _day()), ("month", _month())):
|
|
72
|
+
k = f"{scope}:{key}:{window}:{wkey}"
|
|
73
|
+
u = data["usage"].setdefault(
|
|
74
|
+
k, {"in": 0, "out": 0, "estimated": estimated, "n": 0})
|
|
75
|
+
u["in"] += tokens_in
|
|
76
|
+
u["out"] += tokens_out
|
|
77
|
+
u["n"] += 1
|
|
78
|
+
u["estimated"] = u["estimated"] or estimated
|
|
79
|
+
u["updated_at"] = utcnow()
|
|
80
|
+
if idempotency_key:
|
|
81
|
+
entries[idempotency_key] = {"in": tokens_in, "out": tokens_out,
|
|
82
|
+
"estimated": estimated, "at": utcnow()}
|
|
83
|
+
self._save(data)
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
def check(self, scope_map: dict[str, str]) -> None:
|
|
87
|
+
"""Raise FAB-BUDGET when any configured limit is already exceeded."""
|
|
88
|
+
data = self._load()
|
|
89
|
+
for scope, key in scope_map.items():
|
|
90
|
+
if not key:
|
|
91
|
+
continue
|
|
92
|
+
for window, wkey in (("day", _day()), ("month", _month())):
|
|
93
|
+
lim = data["limits"].get(f"{scope}:{key}:{window}")
|
|
94
|
+
if lim is None:
|
|
95
|
+
continue
|
|
96
|
+
u = data["usage"].get(f"{scope}:{key}:{window}:{wkey}")
|
|
97
|
+
used = (u["in"] + u["out"]) if u else 0
|
|
98
|
+
if used >= lim:
|
|
99
|
+
raise BridgeError(
|
|
100
|
+
"FAB-BUDGET",
|
|
101
|
+
f"{scope}:{key} {window} budget {lim} tokens exhausted")
|
|
102
|
+
|
|
103
|
+
def summary(self) -> dict[str, Any]:
|
|
104
|
+
return self._load()
|