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,300 @@
1
+ """Verb-level cost attribution via proxy metric snapshot deltas.
2
+
3
+ Wraps subprocess invocations (panel, supervisor, handoff, etc.) to
4
+ measure cost by snapshotting proxy metrics before and after execution.
5
+ Results are logged to PID-sharded verb JSONL files.
6
+
7
+ All verb costs are marked ``estimated`` because concurrent proxy traffic
8
+ (e.g., the main interactive session) may share the same proxy during
9
+ the measurement window.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import os
17
+ import threading
18
+ import time
19
+ import urllib.request
20
+ from contextlib import contextmanager
21
+ from dataclasses import asdict, dataclass, field
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from forge.core.paths import get_forge_home
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ _verb_lock = threading.Lock()
31
+
32
+
33
+ @dataclass
34
+ class ProxyCostDelta:
35
+ """Cost delta for a single proxy between two snapshots."""
36
+
37
+ base_url: str
38
+ cost_micros: int = 0
39
+ input_tokens: int = 0
40
+ output_tokens: int = 0
41
+ cached_tokens: int = 0
42
+ request_count: int = 0
43
+
44
+
45
+ @dataclass
46
+ class VerbCostResult:
47
+ """Aggregated cost attribution for one verb invocation."""
48
+
49
+ verb: str
50
+ total_cost_micros: int = 0
51
+ input_tokens: int = 0
52
+ output_tokens: int = 0
53
+ cached_tokens: int = 0
54
+ request_count: int = 0
55
+ duration_ms: float = 0.0
56
+ estimated: bool = True
57
+ per_proxy: list[ProxyCostDelta] = field(default_factory=list)
58
+
59
+
60
+ def _fetch_snapshot(base_url: str, timeout: float = 2.0) -> dict[str, Any] | None:
61
+ """Fetch proxy metrics via GET /. Returns None on failure."""
62
+ try:
63
+ normalized = base_url if "://" in base_url else f"http://{base_url}"
64
+ url = normalized.rstrip("/") + "/"
65
+ with urllib.request.urlopen(url, timeout=timeout) as resp:
66
+ data = json.loads(resp.read())
67
+ if data.get("is_proxy") and "metrics" in data:
68
+ return data["metrics"]
69
+ except Exception as e:
70
+ logger.debug("Failed to fetch proxy snapshot from %s: %s", base_url, e)
71
+ return None
72
+
73
+
74
+ def _compute_delta(before: dict[str, Any], after: dict[str, Any], base_url: str) -> ProxyCostDelta:
75
+ """Compute the difference between two proxy metric snapshots."""
76
+ b_tokens = before.get("tokens", {})
77
+ a_tokens = after.get("tokens", {})
78
+ b_costs = before.get("costs", {})
79
+ a_costs = after.get("costs", {})
80
+
81
+ return ProxyCostDelta(
82
+ base_url=base_url,
83
+ cost_micros=a_costs.get("total_micros", 0) - b_costs.get("total_micros", 0),
84
+ input_tokens=a_tokens.get("input", 0) - b_tokens.get("input", 0),
85
+ output_tokens=a_tokens.get("output", 0) - b_tokens.get("output", 0),
86
+ cached_tokens=a_tokens.get("cached", 0) - b_tokens.get("cached", 0),
87
+ request_count=after.get("total_requests", 0) - before.get("total_requests", 0),
88
+ )
89
+
90
+
91
+ def _verb_log_dir() -> Path:
92
+ return get_forge_home() / "costs" / "verbs"
93
+
94
+
95
+ def _log_verb_cost(result: VerbCostResult) -> None:
96
+ """Append a verb cost record to the PID-sharded JSONL log."""
97
+ record: dict[str, Any] = {
98
+ "ts": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
99
+ "verb": result.verb,
100
+ "total_cost_micros": result.total_cost_micros,
101
+ "estimated": result.estimated,
102
+ "input_tokens": result.input_tokens,
103
+ "output_tokens": result.output_tokens,
104
+ "cached_tokens": result.cached_tokens,
105
+ "request_count": result.request_count,
106
+ "duration_ms": round(result.duration_ms, 1),
107
+ "per_proxy": [asdict(p) for p in result.per_proxy],
108
+ }
109
+
110
+ try:
111
+ from forge.core.state import open_secure_append
112
+
113
+ log_dir = _verb_log_dir()
114
+ log_dir.mkdir(parents=True, exist_ok=True)
115
+ month = datetime.now(timezone.utc).strftime("%Y-%m")
116
+ path = log_dir / f"{month}_{os.getpid()}.jsonl"
117
+
118
+ with _verb_lock:
119
+ with open_secure_append(path) as f:
120
+ f.write(json.dumps(record, separators=(",", ":")) + "\n")
121
+ except Exception as e:
122
+ logger.warning("Failed to write verb cost log: %s", e)
123
+
124
+
125
+ def resolve_subprocess_proxy_url() -> str | None:
126
+ """Resolve the current FORGE_SUBPROCESS_PROXY to a base URL, if configured."""
127
+ from forge.core.reactive.env import (
128
+ FORGE_SUBPROCESS_BASE_URL_VAR,
129
+ FORGE_SUBPROCESS_PROXY_VAR,
130
+ )
131
+ from forge.core.reactive.proxy import lookup_proxy_base_url
132
+
133
+ injected_url = os.environ.get(FORGE_SUBPROCESS_BASE_URL_VAR)
134
+ if injected_url:
135
+ return injected_url
136
+
137
+ proxy = os.environ.get(FORGE_SUBPROCESS_PROXY_VAR)
138
+ if not proxy:
139
+ return None
140
+
141
+ try:
142
+ return lookup_proxy_base_url(proxy)
143
+ except Exception:
144
+ return None
145
+
146
+
147
+ def resolve_proxy_urls(specs: list[Any]) -> list[str]:
148
+ """Extract unique proxy base URLs from a list of ModelSpecs.
149
+
150
+ For specs with no explicit proxy, falls back to FORGE_SUBPROCESS_PROXY
151
+ when configured.
152
+ Deduplicates by resolved URL.
153
+ """
154
+ from forge.core.reactive.env import (
155
+ FORGE_SUBPROCESS_BASE_URL_VAR,
156
+ FORGE_SUBPROCESS_PROXY_VAR,
157
+ )
158
+ from forge.core.reactive.proxy import lookup_proxy_base_url
159
+
160
+ subprocess_proxy = os.environ.get(FORGE_SUBPROCESS_PROXY_VAR)
161
+ subprocess_base_url = os.environ.get(FORGE_SUBPROCESS_BASE_URL_VAR)
162
+ seen: set[str] = set()
163
+ urls: list[str] = []
164
+ for spec in specs:
165
+ proxy = getattr(spec, "preferred_proxy", None) or getattr(spec, "proxy", None) or subprocess_proxy
166
+ if not proxy:
167
+ continue
168
+ try:
169
+ url: str | None
170
+ if subprocess_base_url and proxy == subprocess_proxy:
171
+ url = subprocess_base_url
172
+ else:
173
+ url = lookup_proxy_base_url(proxy)
174
+ if url and url not in seen:
175
+ seen.add(url)
176
+ urls.append(url)
177
+ except Exception:
178
+ pass
179
+ return urls
180
+
181
+
182
+ def resolve_proxy_urls_from_plan(plan: Any) -> list[str]:
183
+ """Extract unique proxy base URLs from a WorkerRoutingPlan.
184
+
185
+ Uses actual routing decisions (correct for --proxy, subprocess proxy,
186
+ route scan, and session proxy fallback).
187
+ """
188
+ seen: set[str] = set()
189
+ urls: list[str] = []
190
+ for result in plan.routes:
191
+ url = result.base_url
192
+ if url and url not in seen:
193
+ seen.add(url)
194
+ urls.append(url)
195
+ return urls
196
+
197
+
198
+ @contextmanager
199
+ def track_verb_cost(verb: str, proxy_base_urls: list[str]):
200
+ """Snapshot proxy metrics across all proxies before/after a verb invocation.
201
+
202
+ Args:
203
+ verb: Origin label ("panel", "supervisor", "handoff", etc.)
204
+ proxy_base_urls: ALL proxy base URLs this verb will use.
205
+ Direct workers (no proxy) are excluded — only proxied
206
+ requests have cost data at the proxy level.
207
+
208
+ Yields control to the caller. On exit, computes snapshot deltas,
209
+ logs the verb cost record, and discards. The caller does not
210
+ receive the result (it's fire-and-forget for the log).
211
+ """
212
+ unique_urls = list(dict.fromkeys(u for u in proxy_base_urls if u))
213
+
214
+ if not unique_urls:
215
+ yield
216
+ return
217
+
218
+ snapshots_before: dict[str, dict[str, Any]] = {}
219
+ for url in unique_urls:
220
+ snap = _fetch_snapshot(url)
221
+ if snap is not None:
222
+ snapshots_before[url] = snap
223
+
224
+ start = time.monotonic()
225
+ try:
226
+ yield
227
+ finally:
228
+ elapsed = (time.monotonic() - start) * 1000
229
+
230
+ try:
231
+ deltas: list[ProxyCostDelta] = []
232
+ for url in unique_urls:
233
+ if url not in snapshots_before:
234
+ continue
235
+ after = _fetch_snapshot(url)
236
+ if after is None:
237
+ continue
238
+ deltas.append(_compute_delta(snapshots_before[url], after, url))
239
+
240
+ total_cost = sum(d.cost_micros for d in deltas)
241
+ total_input = sum(d.input_tokens for d in deltas)
242
+ total_output = sum(d.output_tokens for d in deltas)
243
+ total_cached = sum(d.cached_tokens for d in deltas)
244
+ total_requests = sum(d.request_count for d in deltas)
245
+
246
+ result = VerbCostResult(
247
+ verb=verb,
248
+ total_cost_micros=total_cost,
249
+ input_tokens=total_input,
250
+ output_tokens=total_output,
251
+ cached_tokens=total_cached,
252
+ request_count=total_requests,
253
+ duration_ms=elapsed,
254
+ estimated=True,
255
+ per_proxy=deltas,
256
+ )
257
+ _log_verb_cost(result)
258
+ except Exception as e:
259
+ logger.warning("Failed to track verb cost for %s: %s", verb, e)
260
+
261
+
262
+ def read_verb_logs(
263
+ period_start: datetime | None = None,
264
+ period_end: datetime | None = None,
265
+ ) -> list[dict[str, Any]]:
266
+ """Read and aggregate verb cost records from all PID shards."""
267
+ log_dir = _verb_log_dir()
268
+ if not log_dir.is_dir():
269
+ return []
270
+
271
+ records: list[dict[str, Any]] = []
272
+ for path in sorted(log_dir.glob("*.jsonl")):
273
+ try:
274
+ with open(path) as f:
275
+ for line in f:
276
+ line = line.strip()
277
+ if not line:
278
+ continue
279
+ try:
280
+ record = json.loads(line)
281
+ except json.JSONDecodeError:
282
+ continue
283
+
284
+ if period_start or period_end:
285
+ ts_str = record.get("ts", "")
286
+ try:
287
+ ts = datetime.fromisoformat(ts_str.rstrip("Z").removesuffix("+00:00") + "+00:00")
288
+ except (ValueError, TypeError):
289
+ continue
290
+ if period_start and ts < period_start:
291
+ continue
292
+ if period_end and ts >= period_end:
293
+ continue
294
+
295
+ records.append(record)
296
+ except OSError as e:
297
+ logger.warning("Failed to read verb log %s: %s", path, e)
298
+
299
+ records.sort(key=lambda r: r.get("ts", ""))
300
+ return records
@@ -0,0 +1,180 @@
1
+ """Environment builder for Claude subprocess invocation.
2
+
3
+ Provides ``build_claude_env()`` for constructing subprocess environments,
4
+ and ``FORGE_DEPTH`` helpers for recursion-guarding hook → subprocess chains.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ from collections.abc import Mapping
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Defense-in-depth: --bare prevents hook recursion in child processes,
16
+ # but FORGE_DEPTH still guards against subprocess spawning at depth >= 2.
17
+ FORGE_DEPTH_VAR = "FORGE_DEPTH"
18
+ FORGE_MAX_DEPTH = 2
19
+
20
+ FORGE_SUBPROCESS_PROXY_VAR = "FORGE_SUBPROCESS_PROXY"
21
+ FORGE_SUBPROCESS_BASE_URL_VAR = "FORGE_SUBPROCESS_BASE_URL"
22
+ FORGE_SUBPROCESS_PROXY_ID_VAR = "FORGE_SUBPROCESS_PROXY_ID"
23
+ FORGE_SUBPROCESS_TEMPLATE_VAR = "FORGE_SUBPROCESS_TEMPLATE"
24
+ FORGE_SIDECAR_VAR = "FORGE_SIDECAR"
25
+ FORGE_LAUNCH_MODE_VAR = "FORGE_LAUNCH_MODE"
26
+
27
+ # --bare (Claude Code >= 2.1.81) disables OAuth/keychain auth, requiring
28
+ # ANTHROPIC_API_KEY in the environment. Only safe when the key is present.
29
+ _BARE_AUTH_KEY = "ANTHROPIC_API_KEY"
30
+
31
+
32
+ def can_use_bare(env: Mapping[str, str] | None = None) -> bool:
33
+ """True if ``--bare`` is safe for headless subprocesses.
34
+
35
+ ``--bare`` disables OAuth/keychain auth, so it requires
36
+ ANTHROPIC_API_KEY. When an explicit ``env`` dict is given, checks
37
+ only that dict (caller owns the env). When using os.environ
38
+ (default), also falls back to the credential file via
39
+ ``resolve_env_or_credential`` (which respects ``auth_ignore_env``).
40
+ """
41
+ if env is not None:
42
+ return bool(env.get(_BARE_AUTH_KEY))
43
+
44
+ from forge.core.auth.template_secrets import resolve_env_or_credential
45
+
46
+ return bool(resolve_env_or_credential(_BARE_AUTH_KEY))
47
+
48
+
49
+ def get_forge_depth(env: Mapping[str, str] | None = None) -> int:
50
+ """Read current FORGE_DEPTH from the given env (or os.environ).
51
+
52
+ Invalid or missing values are treated as 0 (fail-open).
53
+ """
54
+ source = env if env is not None else os.environ
55
+ raw = source.get(FORGE_DEPTH_VAR, "0")
56
+ try:
57
+ return max(0, int(raw))
58
+ except (ValueError, TypeError):
59
+ return 0
60
+
61
+
62
+ def should_spawn_subprocesses(env: Mapping[str, str] | None = None) -> bool:
63
+ """True if current depth allows spawning ``claude -p`` subprocesses.
64
+
65
+ Returns False when depth >= FORGE_MAX_DEPTH, meaning hooks should skip
66
+ subprocess-spawning work (supervisor, handoff agent, etc.) to prevent
67
+ runaway recursion.
68
+ """
69
+ return get_forge_depth(env) < FORGE_MAX_DEPTH
70
+
71
+
72
+ def build_claude_env(
73
+ base_url: str | None = None,
74
+ extra_vars: dict[str, str] | None = None,
75
+ direct: bool = False,
76
+ ) -> dict[str, str]:
77
+ """Build environment dict for a Claude subprocess.
78
+
79
+ Starts with the current process environment. Sets ANTHROPIC_BASE_URL
80
+ if ``base_url`` is provided. When ``direct`` is True, removes any
81
+ inherited ANTHROPIC_BASE_URL and subprocess proxy so the child hits
82
+ Anthropic directly.
83
+ Applies ``extra_vars`` before routing and depth handling so explicit
84
+ function arguments remain authoritative.
85
+
86
+ Hydrates ANTHROPIC_API_KEY from the credential file when it's not in
87
+ the env (or when ``auth_ignore_env`` overrides it). This ensures
88
+ ``can_use_bare(env)`` and the subprocess both see the resolved key.
89
+
90
+ Args:
91
+ base_url: Proxy URL to route Claude requests through.
92
+ extra_vars: Additional environment variables to set/override.
93
+ direct: Force direct Anthropic routing (unset inherited proxy URL).
94
+
95
+ Returns:
96
+ Complete environment dict ready for ``subprocess.run(env=...)``.
97
+ """
98
+ env = os.environ.copy()
99
+ _hydrate_credentials(env)
100
+
101
+ # Apply extra_vars AFTER hydration so explicit caller overrides
102
+ # take precedence over credential-file values.
103
+ if extra_vars:
104
+ env.update(extra_vars)
105
+
106
+ if base_url:
107
+ env["ANTHROPIC_BASE_URL"] = base_url
108
+ elif direct:
109
+ env.pop("ANTHROPIC_BASE_URL", None)
110
+ env.pop(FORGE_SUBPROCESS_PROXY_VAR, None)
111
+ env.pop(FORGE_SUBPROCESS_BASE_URL_VAR, None)
112
+ env.pop(FORGE_SUBPROCESS_PROXY_ID_VAR, None)
113
+ env.pop(FORGE_SUBPROCESS_TEMPLATE_VAR, None)
114
+ else:
115
+ # No explicit base_url and not forced direct: check subprocess proxy fallback.
116
+ # FORGE_SUBPROCESS_PROXY is set by `forge session start --subprocess-proxy`
117
+ # and inherited by all child processes.
118
+ injected_subprocess_base_url = env.get(FORGE_SUBPROCESS_BASE_URL_VAR)
119
+ if injected_subprocess_base_url:
120
+ env["ANTHROPIC_BASE_URL"] = injected_subprocess_base_url
121
+ elif subprocess_proxy := env.get(FORGE_SUBPROCESS_PROXY_VAR):
122
+ resolved = _resolve_subprocess_proxy(subprocess_proxy)
123
+ if resolved:
124
+ env["ANTHROPIC_BASE_URL"] = resolved
125
+ else:
126
+ env.pop("ANTHROPIC_BASE_URL", None)
127
+
128
+ # Increment FORGE_DEPTH so child subprocesses know their nesting level
129
+ current_depth = get_forge_depth(env)
130
+ env[FORGE_DEPTH_VAR] = str(current_depth + 1)
131
+
132
+ return env
133
+
134
+
135
+ def _hydrate_credentials(env: dict[str, str]) -> None:
136
+ """Ensure resolved credentials are in the subprocess env dict.
137
+
138
+ When ``auth_ignore_env`` is active, removes the inherited env value
139
+ for ANTHROPIC_API_KEY and injects the credential-file value instead.
140
+ When inactive, injects the credential-file value only if the env
141
+ var is absent (so ``can_use_bare(env)`` and the subprocess agree).
142
+ """
143
+ from forge.core.auth.template_secrets import resolve_env_or_credential
144
+
145
+ resolved = resolve_env_or_credential(_BARE_AUTH_KEY)
146
+
147
+ try:
148
+ from forge.runtime_config import get_runtime_config
149
+
150
+ ignore_env = get_runtime_config().auth_ignore_env
151
+ except Exception as e:
152
+ logger.debug("Could not read auth_ignore_env; using environment credentials: %s", e)
153
+ ignore_env = False
154
+
155
+ if ignore_env:
156
+ if resolved:
157
+ env[_BARE_AUTH_KEY] = resolved
158
+ else:
159
+ env.pop(_BARE_AUTH_KEY, None)
160
+ elif resolved and not env.get(_BARE_AUTH_KEY):
161
+ env[_BARE_AUTH_KEY] = resolved
162
+
163
+
164
+ def _resolve_subprocess_proxy(proxy_id: str) -> str | None:
165
+ """Resolve subprocess proxy to a base URL, or None if unavailable.
166
+
167
+ Direct URL lookup only (not resolve_subprocess_routing). build_claude_env()
168
+ sets env vars for child processes and only needs a URL. Model compatibility
169
+ validation happens at workflow routing time (resolve_invocation_routing).
170
+ """
171
+ try:
172
+ from forge.core.reactive.proxy import lookup_proxy_base_url
173
+
174
+ url = lookup_proxy_base_url(proxy_id)
175
+ if url:
176
+ logger.debug("Subprocess proxy %r resolved to %s", proxy_id, url)
177
+ return url
178
+ except Exception as e:
179
+ logger.warning("Subprocess proxy %r unavailable: %s", proxy_id, e)
180
+ return None
@@ -0,0 +1,78 @@
1
+ """Proxy registry lookup utility.
2
+
3
+ Provides a shared function for looking up proxy base URLs from the
4
+ registry. Used by the semantic supervisor, handoff agent, and review engine.
5
+
6
+ Note: This module is intentionally NOT re-exported from __init__.py
7
+ because it lazy-imports forge.proxy.proxies (a top-level component).
8
+ Consumers import directly: ``from forge.core.reactive.proxy import lookup_proxy_base_url``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+
14
+ def lookup_proxy_base_url(proxy: str | None) -> str | None:
15
+ """Look up base_url from the proxy registry by proxy_id or template name.
16
+
17
+ Internal boundary: if ``proxy`` is provided but resolution fails, the
18
+ exception propagates (ProxyResolutionError, AmbiguousProxyError, or
19
+ ProxyRegistryCorruptedError). Callers decide how to handle the failure.
20
+
21
+ Args:
22
+ proxy: Proxy identifier or template name to look up. If None, returns None.
23
+
24
+ Returns:
25
+ The proxy's base_url if found, None if proxy is None/empty.
26
+
27
+ Raises:
28
+ ProxyResolutionError: If proxy name is provided but cannot be resolved.
29
+ ProxyRegistryCorruptedError: If the registry file is unreadable.
30
+ """
31
+ if not proxy:
32
+ return None
33
+
34
+ from forge.proxy.proxies import ProxyRegistryStore, resolve_proxy
35
+
36
+ registry = ProxyRegistryStore().read()
37
+ entry = resolve_proxy(registry, proxy)
38
+ return entry.base_url
39
+
40
+
41
+ def check_proxy_reachable(
42
+ proxy: str,
43
+ timeout_s: float = 1.0,
44
+ ) -> tuple[bool, str, str | None]:
45
+ """Check if a named proxy is locally routable.
46
+
47
+ Resolves via registry, then HTTP health-checks the endpoint.
48
+ "Ready" means the proxy responds at its base_url with valid
49
+ proxy metadata -- not just that it exists in the registry.
50
+
51
+ Returns:
52
+ (reachable, reason, base_url):
53
+ - reachable: True if proxy resolves AND health check passes
54
+ - reason: empty if reachable, human-readable otherwise
55
+ - base_url: proxy URL if resolved, None otherwise
56
+ """
57
+ from forge.proxy.proxies import ProxyRegistryStore, resolve_proxy
58
+ from forge.proxy.proxy_orchestrator import check_proxy_health
59
+
60
+ try:
61
+ registry = ProxyRegistryStore().read()
62
+ except Exception as e:
63
+ return (False, f"Registry error: {e}", None)
64
+
65
+ try:
66
+ entry = resolve_proxy(registry, proxy)
67
+ except Exception as e:
68
+ return (False, str(e), None)
69
+
70
+ if not check_proxy_health(
71
+ base_url=entry.base_url,
72
+ expected_template=entry.template,
73
+ expected_proxy_id=entry.proxy_id,
74
+ timeout_s=timeout_s,
75
+ ):
76
+ return (False, f"Proxy '{proxy}' not responding at {entry.base_url}", entry.base_url)
77
+
78
+ return (True, "", entry.base_url)