multi-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.
Files changed (311) hide show
  1. forge/__init__.py +3 -0
  2. forge/_extensions/agents/.gitkeep +0 -0
  3. forge/_extensions/commands/.gitkeep +0 -0
  4. forge/_extensions/skills/analyze/SKILL.md +87 -0
  5. forge/_extensions/skills/challenge/SKILL.md +91 -0
  6. forge/_extensions/skills/consensus/SKILL.md +120 -0
  7. forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
  8. forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
  9. forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
  10. forge/_extensions/skills/debate/SKILL.md +116 -0
  11. forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
  12. forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
  13. forge/_extensions/skills/panel/SKILL.md +141 -0
  14. forge/_extensions/skills/panel/resources/synthesis.md +103 -0
  15. forge/_extensions/skills/qa/SKILL.md +704 -0
  16. forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
  17. forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
  18. forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
  19. forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
  20. forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
  21. forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
  22. forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
  23. forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
  24. forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
  25. forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
  26. forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
  27. forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
  28. forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
  29. forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
  30. forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
  31. forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
  32. forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
  33. forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
  34. forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
  35. forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
  36. forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
  37. forge/_extensions/skills/qa/resources/checklist.md +103 -0
  38. forge/_extensions/skills/qa/resources/report-template.md +62 -0
  39. forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
  40. forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
  41. forge/_extensions/skills/review/SKILL.md +125 -0
  42. forge/_extensions/skills/review/references/claude-4.6.md +474 -0
  43. forge/_extensions/skills/review/references/claude-4.7.md +710 -0
  44. forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
  45. forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
  46. forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
  47. forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
  48. forge/_extensions/skills/review/resources/code-gemini.md +184 -0
  49. forge/_extensions/skills/review/resources/code-openai.md +203 -0
  50. forge/_extensions/skills/review/resources/code.md +160 -0
  51. forge/_extensions/skills/review-docs/SKILL.md +121 -0
  52. forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
  53. forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
  54. forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
  55. forge/_extensions/skills/review-docs/resources/docs.md +170 -0
  56. forge/_extensions/skills/smoke-test/SKILL.md +27 -0
  57. forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
  58. forge/_extensions/skills/understand/SKILL.md +148 -0
  59. forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
  60. forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
  61. forge/_extensions/skills/understand/resources/code-openai.md +181 -0
  62. forge/_extensions/skills/understand/resources/code.md +163 -0
  63. forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
  64. forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
  65. forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
  66. forge/_extensions/skills/understand/resources/docs.md +177 -0
  67. forge/_extensions/skills/walkthrough/SKILL.md +599 -0
  68. forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
  69. forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
  70. forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
  71. forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
  72. forge/backend/__init__.py +174 -0
  73. forge/backend/adapters/__init__.py +38 -0
  74. forge/backend/adapters/litellm.py +158 -0
  75. forge/backend/creation.py +89 -0
  76. forge/backend/registry.py +178 -0
  77. forge/cli/__init__.py +16 -0
  78. forge/cli/auth.py +483 -0
  79. forge/cli/backend.py +298 -0
  80. forge/cli/claude.py +411 -0
  81. forge/cli/config_cmd.py +303 -0
  82. forge/cli/extensions.py +1001 -0
  83. forge/cli/gc.py +165 -0
  84. forge/cli/guard.py +1018 -0
  85. forge/cli/guards.py +106 -0
  86. forge/cli/handoff.py +110 -0
  87. forge/cli/hooks/__init__.py +36 -0
  88. forge/cli/hooks/_group.py +20 -0
  89. forge/cli/hooks/_helpers.py +149 -0
  90. forge/cli/hooks/commands.py +1677 -0
  91. forge/cli/hooks/direct_commands.py +1304 -0
  92. forge/cli/hooks/install.py +232 -0
  93. forge/cli/hooks/policy.py +151 -0
  94. forge/cli/hooks/read_hygiene.py +74 -0
  95. forge/cli/hooks/verification.py +370 -0
  96. forge/cli/logs.py +406 -0
  97. forge/cli/main.py +292 -0
  98. forge/cli/proxy.py +1821 -0
  99. forge/cli/proxy_costs.py +313 -0
  100. forge/cli/search.py +416 -0
  101. forge/cli/session.py +892 -0
  102. forge/cli/session_addendum.py +81 -0
  103. forge/cli/session_fork.py +750 -0
  104. forge/cli/session_handoff.py +141 -0
  105. forge/cli/session_lifecycle.py +2053 -0
  106. forge/cli/session_manage.py +1336 -0
  107. forge/cli/session_memory.py +201 -0
  108. forge/cli/status_line.py +1398 -0
  109. forge/cli/workflow.py +1964 -0
  110. forge/config/__init__.py +110 -0
  111. forge/config/dataclass_utils.py +88 -0
  112. forge/config/defaults/__init__.py +0 -0
  113. forge/config/defaults/backends/__init__.py +0 -0
  114. forge/config/defaults/backends/litellm.yaml +196 -0
  115. forge/config/defaults/templates/__init__.py +0 -0
  116. forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
  117. forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
  118. forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
  119. forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
  120. forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
  121. forge/config/defaults/templates/litellm-gemini.yaml +21 -0
  122. forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
  123. forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
  124. forge/config/defaults/templates/litellm-openai.yaml +28 -0
  125. forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
  126. forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
  127. forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
  128. forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
  129. forge/config/defaults/templates/openrouter-glm.yaml +23 -0
  130. forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
  131. forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
  132. forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
  133. forge/config/defaults/templates/openrouter-openai.yaml +28 -0
  134. forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
  135. forge/config/loader.py +675 -0
  136. forge/config/schema.py +448 -0
  137. forge/core/__init__.py +5 -0
  138. forge/core/auth/__init__.py +67 -0
  139. forge/core/auth/capabilities.py +219 -0
  140. forge/core/auth/credentials_file.py +244 -0
  141. forge/core/auth/protocols.py +18 -0
  142. forge/core/auth/secrets.py +243 -0
  143. forge/core/auth/template_secrets.py +112 -0
  144. forge/core/data/__init__.py +5 -0
  145. forge/core/data/model_catalog.yaml +1522 -0
  146. forge/core/data/pricing.yaml +140 -0
  147. forge/core/data/system_prompt_addendums/__init__.py +0 -0
  148. forge/core/data/system_prompt_addendums/gemini.md +330 -0
  149. forge/core/data/system_prompt_addendums/openai.md +328 -0
  150. forge/core/llm/__init__.py +231 -0
  151. forge/core/llm/clients/__init__.py +14 -0
  152. forge/core/llm/clients/base.py +115 -0
  153. forge/core/llm/clients/litellm.py +619 -0
  154. forge/core/llm/clients/openai_compat.py +244 -0
  155. forge/core/llm/clients/openrouter.py +234 -0
  156. forge/core/llm/credentials.py +439 -0
  157. forge/core/llm/detection.py +86 -0
  158. forge/core/llm/errors.py +44 -0
  159. forge/core/llm/protocols.py +80 -0
  160. forge/core/llm/types.py +176 -0
  161. forge/core/logging.py +146 -0
  162. forge/core/models/__init__.py +91 -0
  163. forge/core/models/catalog.py +467 -0
  164. forge/core/models/pricing.py +165 -0
  165. forge/core/models/types.py +167 -0
  166. forge/core/naming.py +212 -0
  167. forge/core/ops/__init__.py +73 -0
  168. forge/core/ops/context.py +141 -0
  169. forge/core/ops/gc.py +802 -0
  170. forge/core/ops/proxy.py +146 -0
  171. forge/core/ops/resolution.py +135 -0
  172. forge/core/ops/session.py +344 -0
  173. forge/core/ops/session_context.py +548 -0
  174. forge/core/paths.py +38 -0
  175. forge/core/process.py +54 -0
  176. forge/core/reactive/__init__.py +38 -0
  177. forge/core/reactive/cost_tracking.py +300 -0
  178. forge/core/reactive/env.py +180 -0
  179. forge/core/reactive/proxy.py +78 -0
  180. forge/core/reactive/routing.py +622 -0
  181. forge/core/reactive/session_runner.py +185 -0
  182. forge/core/reactive/structured_output.py +62 -0
  183. forge/core/reactive/tagger.py +94 -0
  184. forge/core/reactive/throttle.py +132 -0
  185. forge/core/state/__init__.py +59 -0
  186. forge/core/state/exceptions.py +59 -0
  187. forge/core/state/io.py +140 -0
  188. forge/core/state/lock.py +99 -0
  189. forge/core/state/timestamps.py +60 -0
  190. forge/core/transcript.py +78 -0
  191. forge/core/typing_helpers.py +24 -0
  192. forge/core/workqueue/__init__.py +67 -0
  193. forge/core/workqueue/queue.py +552 -0
  194. forge/core/workqueue/types.py +63 -0
  195. forge/guard/__init__.py +26 -0
  196. forge/guard/deterministic/__init__.py +26 -0
  197. forge/guard/deterministic/base.py +158 -0
  198. forge/guard/deterministic/coding_standards.py +256 -0
  199. forge/guard/deterministic/registry.py +148 -0
  200. forge/guard/deterministic/tdd.py +171 -0
  201. forge/guard/engine.py +216 -0
  202. forge/guard/protocols.py +91 -0
  203. forge/guard/queries.py +96 -0
  204. forge/guard/semantic/__init__.py +34 -0
  205. forge/guard/semantic/promotion.py +18 -0
  206. forge/guard/semantic/supervisor.py +813 -0
  207. forge/guard/semantic/verdict.py +183 -0
  208. forge/guard/store.py +124 -0
  209. forge/guard/team/__init__.py +6 -0
  210. forge/guard/team/config.py +24 -0
  211. forge/guard/team/handlers.py +209 -0
  212. forge/guard/team/prompts.py +41 -0
  213. forge/guard/types.py +125 -0
  214. forge/guard/workflow/__init__.py +17 -0
  215. forge/guard/workflow/branches.py +67 -0
  216. forge/guard/workflow/config.py +63 -0
  217. forge/guard/workflow/divergence.py +113 -0
  218. forge/guard/workflow/policy.py +87 -0
  219. forge/guard/workflow/stages.py +205 -0
  220. forge/install/__init__.py +55 -0
  221. forge/install/cli.py +281 -0
  222. forge/install/exceptions.py +163 -0
  223. forge/install/hooks.py +109 -0
  224. forge/install/installer.py +1037 -0
  225. forge/install/models.py +321 -0
  226. forge/install/preset.py +272 -0
  227. forge/install/settings_merge.py +831 -0
  228. forge/install/tracking.py +238 -0
  229. forge/install/version.py +141 -0
  230. forge/proxy/__init__.py +0 -0
  231. forge/proxy/base_client.py +181 -0
  232. forge/proxy/client_adapter.py +476 -0
  233. forge/proxy/client_factory.py +531 -0
  234. forge/proxy/converters.py +1206 -0
  235. forge/proxy/cost_logger.py +132 -0
  236. forge/proxy/cost_tracker.py +242 -0
  237. forge/proxy/data_models.py +338 -0
  238. forge/proxy/error_hints.py +92 -0
  239. forge/proxy/metrics.py +222 -0
  240. forge/proxy/model_spec.py +158 -0
  241. forge/proxy/proxies.py +333 -0
  242. forge/proxy/proxy_identity.py +134 -0
  243. forge/proxy/proxy_orchestrator.py +1018 -0
  244. forge/proxy/proxy_startup.py +54 -0
  245. forge/proxy/server.py +1561 -0
  246. forge/proxy/utils.py +537 -0
  247. forge/review/__init__.py +6 -0
  248. forge/review/adversarial.py +111 -0
  249. forge/review/consensus.py +236 -0
  250. forge/review/engine.py +356 -0
  251. forge/review/models.py +437 -0
  252. forge/review/resources/__init__.py +5 -0
  253. forge/review/resources/codereview-performance.md +85 -0
  254. forge/review/resources/codereview-quick.md +75 -0
  255. forge/review/resources/codereview-security.md +92 -0
  256. forge/review/resources/codereview.md +85 -0
  257. forge/review/resources/docreview-quick.md +75 -0
  258. forge/review/resources/docreview.md +86 -0
  259. forge/review/resources/thinkdeep.md +89 -0
  260. forge/review/routing.py +368 -0
  261. forge/review/synthesis.py +73 -0
  262. forge/runtime_config.py +438 -0
  263. forge/search/__init__.py +55 -0
  264. forge/search/bm25_store.py +264 -0
  265. forge/search/content_store.py +197 -0
  266. forge/search/engine.py +352 -0
  267. forge/search/exceptions.py +51 -0
  268. forge/search/extractor.py +234 -0
  269. forge/search/index_state.py +295 -0
  270. forge/search/store.py +215 -0
  271. forge/search/tokenizer.py +24 -0
  272. forge/session/__init__.py +130 -0
  273. forge/session/active.py +339 -0
  274. forge/session/artifacts.py +202 -0
  275. forge/session/claude/__init__.py +50 -0
  276. forge/session/claude/cleanup.py +105 -0
  277. forge/session/claude/invoke.py +236 -0
  278. forge/session/claude/paths.py +200 -0
  279. forge/session/cleanup.py +216 -0
  280. forge/session/config.py +34 -0
  281. forge/session/direct_model.py +107 -0
  282. forge/session/effective.py +169 -0
  283. forge/session/exceptions.py +255 -0
  284. forge/session/handoff.py +881 -0
  285. forge/session/handoff_agent.py +544 -0
  286. forge/session/hooks/__init__.py +35 -0
  287. forge/session/hooks/models.py +73 -0
  288. forge/session/hooks/session_start.py +507 -0
  289. forge/session/identity.py +84 -0
  290. forge/session/index.py +553 -0
  291. forge/session/manager.py +1506 -0
  292. forge/session/models.py +572 -0
  293. forge/session/overrides.py +344 -0
  294. forge/session/plan_resolution.py +286 -0
  295. forge/session/prev_sessions.py +128 -0
  296. forge/session/store.py +431 -0
  297. forge/session/validation.py +47 -0
  298. forge/session/worktree/__init__.py +65 -0
  299. forge/session/worktree/cleanup.py +262 -0
  300. forge/session/worktree/config_copy.py +203 -0
  301. forge/session/worktree/create.py +332 -0
  302. forge/sidecar/__init__.py +29 -0
  303. forge/sidecar/container.py +161 -0
  304. forge/sidecar/docker.py +86 -0
  305. forge/sidecar/secrets.py +19 -0
  306. multi_forge-0.2.0.dist-info/METADATA +242 -0
  307. multi_forge-0.2.0.dist-info/RECORD +311 -0
  308. multi_forge-0.2.0.dist-info/WHEEL +4 -0
  309. multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
  310. multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
  311. multi_forge-0.2.0.dist-info/licenses/NOTICE +14 -0
@@ -0,0 +1,132 @@
1
+ """PID-sharded JSONL cost log writer.
2
+
3
+ Each proxy process writes to its own shard file to avoid interprocess
4
+ locking. The CLI aggregates across shards at query time.
5
+
6
+ Location: ~/.forge/costs/requests/YYYY-MM_<pid>.jsonl
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import threading
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from forge.core.paths import get_forge_home
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _lock = threading.Lock()
24
+
25
+
26
+ def _pid_suffix() -> str:
27
+ return str(os.getpid())
28
+
29
+
30
+ def _costs_dir() -> Path:
31
+ return get_forge_home() / "costs" / "requests"
32
+
33
+
34
+ def _current_log_path() -> Path:
35
+ month = datetime.now(timezone.utc).strftime("%Y-%m")
36
+ return _costs_dir() / f"{month}_{_pid_suffix()}.jsonl"
37
+
38
+
39
+ def log_request_cost(
40
+ *,
41
+ proxy_id: str,
42
+ model: str,
43
+ tier: str,
44
+ input_tokens: int,
45
+ output_tokens: int,
46
+ cached_tokens: int,
47
+ cost_micros: int,
48
+ latency_ms: float,
49
+ failed: bool,
50
+ request_id: str,
51
+ pricing_source: str = "catalog",
52
+ ) -> None:
53
+ """Append a cost record to the PID-sharded JSONL log.
54
+
55
+ Best-effort: write failures are logged but never block the request.
56
+ """
57
+ record: dict[str, Any] = {
58
+ "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
59
+ "proxy_id": proxy_id,
60
+ "model": model,
61
+ "tier": tier,
62
+ "input_tokens": input_tokens,
63
+ "output_tokens": output_tokens,
64
+ "cached_tokens": cached_tokens,
65
+ "cost_micros": cost_micros,
66
+ "estimated": True,
67
+ "pricing_source": pricing_source,
68
+ "latency_ms": round(latency_ms, 1),
69
+ "failed": failed,
70
+ "request_id": request_id,
71
+ }
72
+
73
+ try:
74
+ from forge.core.state import open_secure_append
75
+
76
+ log_path = _current_log_path()
77
+ log_path.parent.mkdir(parents=True, exist_ok=True)
78
+
79
+ with _lock:
80
+ with open_secure_append(log_path) as f:
81
+ f.write(json.dumps(record, separators=(",", ":")) + "\n")
82
+ except Exception as e:
83
+ logger.warning("Failed to write cost log: %s", e)
84
+
85
+
86
+ def read_cost_logs(
87
+ period_start: datetime | None = None,
88
+ period_end: datetime | None = None,
89
+ ) -> list[dict[str, Any]]:
90
+ """Read and aggregate cost records from all PID shards.
91
+
92
+ Args:
93
+ period_start: Only include records at or after this time (UTC).
94
+ period_end: Only include records before this time (UTC).
95
+
96
+ Returns:
97
+ List of cost record dicts, sorted by timestamp.
98
+ """
99
+ costs_dir = _costs_dir()
100
+ if not costs_dir.is_dir():
101
+ return []
102
+
103
+ records: list[dict[str, Any]] = []
104
+ for path in sorted(costs_dir.glob("*.jsonl")):
105
+ try:
106
+ with open(path) as f:
107
+ for line in f:
108
+ line = line.strip()
109
+ if not line:
110
+ continue
111
+ try:
112
+ record = json.loads(line)
113
+ except json.JSONDecodeError:
114
+ continue
115
+
116
+ if period_start or period_end:
117
+ ts_str = record.get("ts", "")
118
+ try:
119
+ ts = datetime.fromisoformat(ts_str.rstrip("Z").removesuffix("+00:00") + "+00:00")
120
+ except (ValueError, TypeError):
121
+ continue
122
+ if period_start and ts < period_start:
123
+ continue
124
+ if period_end and ts >= period_end:
125
+ continue
126
+
127
+ records.append(record)
128
+ except OSError as e:
129
+ logger.warning("Failed to read cost log %s: %s", path, e)
130
+
131
+ records.sort(key=lambda r: r.get("ts", ""))
132
+ return records
@@ -0,0 +1,242 @@
1
+ """Spend cap enforcement with JSONL-bootstrapped tracking.
2
+
3
+ On proxy startup, reads the current (and previous) month's cost JSONL
4
+ logs to initialize in-memory spend counters. Caps are enforced per
5
+ request via check_cap().
6
+
7
+ Two enforcement modes:
8
+ post -- block once accumulated spend already exceeds the cap
9
+ strict -- estimate incoming request cost and block if projected total exceeds
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import time
16
+ from collections import deque
17
+ from dataclasses import dataclass
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _MICROS_PER_DOLLAR = 1_000_000
24
+ _24H_SECONDS = 86400
25
+
26
+
27
+ @dataclass
28
+ class CapResult:
29
+ """Result of a spend cap check."""
30
+
31
+ exceeded: bool
32
+ cap_type: str | None = None # "daily" or "monthly"
33
+ current_micros: int = 0
34
+ limit_micros: int = 0
35
+ projected: bool = False # True if this is a pre-flight estimate
36
+
37
+
38
+ class CostTracker:
39
+ """In-memory spend tracking with cap enforcement.
40
+
41
+ Thread-safe via the proxy's single-threaded async event loop
42
+ (all calls happen on the main thread in FastAPI/uvicorn).
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ *,
48
+ daily_cap_usd: float | None = None,
49
+ monthly_cap_usd: float | None = None,
50
+ cap_mode: str = "post",
51
+ on_cap_hit: str = "reject",
52
+ ) -> None:
53
+ self.daily_cap_micros = int(daily_cap_usd * _MICROS_PER_DOLLAR) if daily_cap_usd is not None else None
54
+ self.monthly_cap_micros = int(monthly_cap_usd * _MICROS_PER_DOLLAR) if monthly_cap_usd is not None else None
55
+ self.cap_mode = cap_mode
56
+ self.on_cap_hit = on_cap_hit
57
+
58
+ self._daily_window: deque[tuple[float, int]] = deque()
59
+ self._monthly_total: int = 0
60
+ self._monthly_key: str = ""
61
+
62
+ @property
63
+ def has_caps(self) -> bool:
64
+ return self.daily_cap_micros is not None or self.monthly_cap_micros is not None
65
+
66
+ def bootstrap_from_logs(self, log_dir: Path, *, proxy_id: str | None = None) -> None:
67
+ """Read existing cost logs to initialize spend counters.
68
+
69
+ Reads current month + previous month (for rolling 24h window
70
+ at month boundaries). Scans all PID shards.
71
+
72
+ When proxy_id is set, only records matching that proxy are counted.
73
+ Records without a proxy_id field are skipped (pre-proxy-id logs).
74
+ When proxy_id is None, all records are counted (backward compat).
75
+ """
76
+ if not log_dir.is_dir():
77
+ return
78
+
79
+ now = datetime.now(timezone.utc)
80
+ current_month = now.strftime("%Y-%m")
81
+ self._monthly_key = current_month
82
+
83
+ if now.month == 1:
84
+ prev_month = f"{now.year - 1}-12"
85
+ else:
86
+ prev_month = f"{now.year}-{now.month - 1:02d}"
87
+
88
+ cutoff = time.time() - _24H_SECONDS
89
+
90
+ for path in sorted(log_dir.glob("*.jsonl")):
91
+ fname = path.stem # e.g., "2026-05_12345"
92
+ file_month = fname.split("_")[0] if "_" in fname else fname
93
+
94
+ if file_month not in (current_month, prev_month):
95
+ continue
96
+
97
+ try:
98
+ with open(path) as f:
99
+ for line in f:
100
+ line = line.strip()
101
+ if not line:
102
+ continue
103
+ try:
104
+ record = self._parse_record(line)
105
+ except Exception:
106
+ continue
107
+ if record is None:
108
+ continue
109
+
110
+ ts_unix, cost_micros, record_month, record_proxy_id = record
111
+
112
+ if proxy_id is not None and record_proxy_id != proxy_id:
113
+ continue
114
+
115
+ if record_month == current_month:
116
+ self._monthly_total += cost_micros
117
+
118
+ if ts_unix >= cutoff:
119
+ self._daily_window.append((ts_unix, cost_micros))
120
+ except OSError as e:
121
+ logger.warning("Failed to read cost log %s: %s", path, e)
122
+
123
+ daily_total = sum(c for _, c in self._daily_window)
124
+ logger.info(
125
+ "Cost tracker bootstrapped: daily=$%.2f, monthly=$%.2f (%d records in window)",
126
+ daily_total / _MICROS_PER_DOLLAR,
127
+ self._monthly_total / _MICROS_PER_DOLLAR,
128
+ len(self._daily_window),
129
+ )
130
+
131
+ @staticmethod
132
+ def _parse_record(line: str) -> tuple[float, int, str, str | None] | None:
133
+ """Parse a JSONL line into (unix_timestamp, cost_micros, month_key, record_proxy_id)."""
134
+ import json
135
+
136
+ data = json.loads(line)
137
+ ts_str = data.get("ts", "")
138
+ cost_micros = int(data.get("cost_micros", 0))
139
+ if cost_micros <= 0:
140
+ return None
141
+
142
+ try:
143
+ ts = datetime.fromisoformat(ts_str.rstrip("Z").removesuffix("+00:00") + "+00:00")
144
+ except (ValueError, TypeError):
145
+ return None
146
+
147
+ month_key = ts.strftime("%Y-%m")
148
+ record_proxy_id = data.get("proxy_id")
149
+ return ts.timestamp(), cost_micros, month_key, record_proxy_id
150
+
151
+ def record(self, cost_micros: int) -> None:
152
+ """Record a completed request's cost."""
153
+ if cost_micros <= 0:
154
+ return
155
+
156
+ now = time.time()
157
+ self._roll_month_if_needed()
158
+
159
+ self._monthly_total += cost_micros
160
+ self._daily_window.append((now, cost_micros))
161
+
162
+ def _roll_month_if_needed(self) -> None:
163
+ """Reset the calendar-month accumulator when UTC month changes."""
164
+ current_month = datetime.now(timezone.utc).strftime("%Y-%m")
165
+ if current_month != self._monthly_key:
166
+ self._monthly_total = 0
167
+ self._monthly_key = current_month
168
+
169
+ def _prune_daily_window(self) -> None:
170
+ """Remove entries older than 24 hours from the rolling window."""
171
+ cutoff = time.time() - _24H_SECONDS
172
+ while self._daily_window and self._daily_window[0][0] < cutoff:
173
+ self._daily_window.popleft()
174
+
175
+ def daily_spend_micros(self) -> int:
176
+ """Current rolling 24h spend in microdollars."""
177
+ self._prune_daily_window()
178
+ return sum(c for _, c in self._daily_window)
179
+
180
+ def monthly_spend_micros(self) -> int:
181
+ """Current calendar month spend in microdollars."""
182
+ self._roll_month_if_needed()
183
+ return self._monthly_total
184
+
185
+ def check_cap(self, projected_cost_micros: int = 0) -> CapResult:
186
+ """Check if spend would exceed any configured caps.
187
+
188
+ Args:
189
+ projected_cost_micros: Estimated cost of the pending request.
190
+ In strict mode, added to current spend for pre-flight check.
191
+ In post mode, ignored (only accumulated spend matters).
192
+
193
+ Returns:
194
+ CapResult indicating whether any cap is exceeded.
195
+ """
196
+ if not self.has_caps:
197
+ return CapResult(exceeded=False)
198
+
199
+ extra = projected_cost_micros if self.cap_mode == "strict" else 0
200
+
201
+ if self.daily_cap_micros is not None:
202
+ daily = self.daily_spend_micros() + extra
203
+ if daily >= self.daily_cap_micros:
204
+ return CapResult(
205
+ exceeded=True,
206
+ cap_type="daily",
207
+ current_micros=daily,
208
+ limit_micros=self.daily_cap_micros,
209
+ projected=extra > 0,
210
+ )
211
+
212
+ if self.monthly_cap_micros is not None:
213
+ monthly = self.monthly_spend_micros() + extra
214
+ if monthly >= self.monthly_cap_micros:
215
+ return CapResult(
216
+ exceeded=True,
217
+ cap_type="monthly",
218
+ current_micros=monthly,
219
+ limit_micros=self.monthly_cap_micros,
220
+ projected=extra > 0,
221
+ )
222
+
223
+ return CapResult(exceeded=False)
224
+
225
+ def cap_summary(self) -> dict[str, dict[str, float]]:
226
+ """Return current spend vs caps for CLI display."""
227
+ result: dict[str, dict[str, float]] = {}
228
+ if self.daily_cap_micros is not None:
229
+ daily = self.daily_spend_micros()
230
+ result["daily"] = {
231
+ "current_usd": daily / _MICROS_PER_DOLLAR,
232
+ "limit_usd": self.daily_cap_micros / _MICROS_PER_DOLLAR,
233
+ "percent": round(daily / self.daily_cap_micros * 100, 1) if self.daily_cap_micros > 0 else 0,
234
+ }
235
+ if self.monthly_cap_micros is not None:
236
+ monthly = self.monthly_spend_micros()
237
+ result["monthly"] = {
238
+ "current_usd": monthly / _MICROS_PER_DOLLAR,
239
+ "limit_usd": self.monthly_cap_micros / _MICROS_PER_DOLLAR,
240
+ "percent": round(monthly / self.monthly_cap_micros * 100, 1) if self.monthly_cap_micros > 0 else 0,
241
+ }
242
+ return result