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,622 @@
1
+ """Shared subprocess routing primitives.
2
+
3
+ Defines ``ModelRoute`` and ``RoutingResult`` used by all subprocess
4
+ types (workflow workers, supervisor, handoff agent). The resolution
5
+ chain in ``resolve_subprocess_routing()`` replaces ad-hoc resolution
6
+ in each subprocess launcher.
7
+
8
+ Dependency boundary: this module imports from ``core.auth``,
9
+ ``core.reactive.proxy``, ``proxy.proxies``, and ``config.loader``.
10
+ It must never import from ``forge.review.*``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ from dataclasses import dataclass
18
+ from typing import Any, Literal
19
+
20
+ from forge.core.reactive.env import (
21
+ FORGE_LAUNCH_MODE_VAR,
22
+ FORGE_SIDECAR_VAR,
23
+ FORGE_SUBPROCESS_BASE_URL_VAR,
24
+ FORGE_SUBPROCESS_PROXY_ID_VAR,
25
+ FORGE_SUBPROCESS_PROXY_VAR,
26
+ FORGE_SUBPROCESS_TEMPLATE_VAR,
27
+ )
28
+ from forge.proxy.proxies import ProxyEntry
29
+
30
+ _log = logging.getLogger(__name__)
31
+
32
+ RoutingSource = Literal[
33
+ "explicit",
34
+ "subprocess_proxy",
35
+ "preferred_proxy",
36
+ "route_scan",
37
+ "session_proxy",
38
+ "direct",
39
+ "unresolved",
40
+ ]
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ModelRoute:
45
+ """Derived routing option for a model through a specific provider/template.
46
+
47
+ Generated by ``derive_model_routes()`` in ``forge.review.routing``,
48
+ consumed by ``resolve_subprocess_routing()`` here for route scan
49
+ and compatibility validation.
50
+ """
51
+
52
+ provider: str
53
+ credential: str
54
+ family: str
55
+ template_id: str | None
56
+ template_family: str | None
57
+ model_ref: str
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class RoutingResult:
62
+ """Outcome of the subprocess routing resolution chain.
63
+
64
+ ``route=None`` means unresolved fallback -- NOT valid direct execution.
65
+ Direct-capable models get a real ``ModelRoute(provider="direct")``.
66
+ """
67
+
68
+ base_url: str | None
69
+ proxy_id: str | None
70
+ template: str | None
71
+ source: RoutingSource
72
+ route: ModelRoute | None
73
+ credential: str | None
74
+ warning: str | None = None
75
+
76
+
77
+ # ── Proxy entry helpers ──────────────────────────────────────────
78
+
79
+
80
+ def _is_sidecar_mode() -> bool:
81
+ return bool(os.environ.get(FORGE_SIDECAR_VAR)) or os.environ.get(FORGE_LAUNCH_MODE_VAR) == "sidecar"
82
+
83
+
84
+ def lookup_proxy_entry(proxy: str) -> "ProxyEntry | None":
85
+ """Resolve a proxy name to a full registry entry (soft, returns None)."""
86
+ from forge.proxy.proxies import ProxyRegistryStore, resolve_proxy_optional
87
+
88
+ try:
89
+ registry = ProxyRegistryStore().read()
90
+ return resolve_proxy_optional(registry, proxy)
91
+ except Exception as e:
92
+ _log.debug("Proxy lookup failed for '%s': %s", proxy, e)
93
+ return None
94
+
95
+
96
+ def lookup_proxy_entry_strict(proxy: str) -> ProxyEntry:
97
+ """Resolve a proxy name to a full registry entry (strict, raises)."""
98
+ from forge.proxy.proxies import ProxyRegistryStore, resolve_proxy
99
+
100
+ registry = ProxyRegistryStore().read()
101
+ return resolve_proxy(registry, proxy)
102
+
103
+
104
+ def _check_proxy_reachable(entry: ProxyEntry, timeout_s: float = 1.0) -> bool:
105
+ """HTTP health check on a proxy entry."""
106
+ from forge.proxy.proxy_orchestrator import check_proxy_health
107
+
108
+ return check_proxy_health(
109
+ base_url=entry.base_url,
110
+ expected_template=entry.template,
111
+ expected_proxy_id=entry.proxy_id,
112
+ timeout_s=timeout_s,
113
+ )
114
+
115
+
116
+ def _probe_proxy_metadata(base_url: str, timeout_s: float = 1.0) -> dict[str, Any] | None:
117
+ """Live GET / probe for Forge proxy metadata.
118
+
119
+ Returns {"template": ..., "proxy_id": ...} if the endpoint is a
120
+ Forge proxy, or None if unreachable or not a Forge proxy.
121
+ """
122
+ try:
123
+ import httpx
124
+
125
+ with httpx.Client(timeout=httpx.Timeout(timeout_s)) as client:
126
+ resp = client.get(f"{base_url}/")
127
+ if resp.status_code != 200:
128
+ return None
129
+ data = resp.json()
130
+ if data.get("is_proxy") is not True:
131
+ return None
132
+ result: dict[str, Any] = {}
133
+ if data.get("template"):
134
+ result["template"] = str(data["template"])
135
+ proxy_block = data.get("proxy", {})
136
+ if isinstance(proxy_block, dict) and proxy_block.get("proxy_id"):
137
+ result["proxy_id"] = str(proxy_block["proxy_id"])
138
+ if isinstance(proxy_block, dict) and not result.get("template") and proxy_block.get("template"):
139
+ result["template"] = str(proxy_block["template"])
140
+
141
+ advertised_models = _extract_advertised_models(data)
142
+ if advertised_models:
143
+ result["advertised_models"] = tuple(sorted(advertised_models))
144
+ return result if result else None
145
+ except Exception as e:
146
+ _log.debug("Session proxy GET / probe failed for %s: %s", base_url, e)
147
+ return None
148
+
149
+
150
+ def _extract_advertised_models(data: dict[str, Any]) -> set[str]:
151
+ """Extract model refs advertised by GET / tier mappings."""
152
+ models: set[str] = set()
153
+
154
+ def collect(mapping: Any) -> None:
155
+ if not isinstance(mapping, dict):
156
+ return
157
+ for value in mapping.values():
158
+ if isinstance(value, str) and value:
159
+ models.add(value)
160
+ elif isinstance(value, dict):
161
+ model = value.get("model")
162
+ if isinstance(model, str) and model:
163
+ models.add(model)
164
+
165
+ collect(data.get("tiers"))
166
+ runtime = data.get("runtime")
167
+ if isinstance(runtime, dict):
168
+ collect(runtime.get("tier_mappings"))
169
+
170
+ return models
171
+
172
+
173
+ # ── Compatibility validation ─────────────────────────────────────
174
+
175
+
176
+ def _find_matching_route(
177
+ template: str,
178
+ routes: tuple[ModelRoute, ...],
179
+ ) -> ModelRoute | None:
180
+ """Find the first route whose template_id matches the proxy's template.
181
+
182
+ Exact template_id match only. Cross-family routing (e.g., gpt-5.5
183
+ through openrouter-anthropic) works because derive_model_routes()
184
+ already emits routes with those cross-family template_ids. Credential
185
+ matching alone is insufficient: litellm-gemini and litellm-openai
186
+ share litellm-remote but serve different model families.
187
+ """
188
+ for route in routes:
189
+ if route.template_id == template:
190
+ return route
191
+ return None
192
+
193
+
194
+ def _make_result_from_entry(
195
+ entry: ProxyEntry,
196
+ route: ModelRoute | None,
197
+ source: RoutingSource,
198
+ warning: str | None = None,
199
+ *,
200
+ advisory_check: bool = False,
201
+ ) -> RoutingResult:
202
+ """Build a RoutingResult from a ProxyEntry."""
203
+ return RoutingResult(
204
+ base_url=entry.base_url,
205
+ proxy_id=entry.proxy_id,
206
+ template=entry.template,
207
+ source=source,
208
+ route=route,
209
+ credential=route.credential if route else None,
210
+ warning=_route_warning(
211
+ route,
212
+ base_url=entry.base_url,
213
+ advisory_check=advisory_check,
214
+ warning=warning,
215
+ ),
216
+ )
217
+
218
+
219
+ def _join_warnings(*warnings: str | None) -> str | None:
220
+ parts = [warning.strip() for warning in warnings if warning and warning.strip()]
221
+ return " ".join(parts) or None
222
+
223
+
224
+ def _cross_family_warning(route: ModelRoute | None) -> str | None:
225
+ if route is None or route.template_id is None or route.template_family is None:
226
+ return None
227
+ if route.template_family == route.family:
228
+ return None
229
+ return (
230
+ f"Model family '{route.family}' is routed through template '{route.template_id}' "
231
+ f"({route.template_family}); tier overrides may differ."
232
+ )
233
+
234
+
235
+ def _live_advisory_warning(
236
+ base_url: str,
237
+ route: ModelRoute | None,
238
+ metadata: dict[str, Any] | None = None,
239
+ ) -> str | None:
240
+ if route is None:
241
+ return None
242
+
243
+ metadata = metadata or _probe_proxy_metadata(base_url)
244
+ if not metadata:
245
+ return None
246
+
247
+ advertised = metadata.get("advertised_models")
248
+ if not isinstance(advertised, (tuple, list, set, frozenset)):
249
+ return None
250
+
251
+ advertised_models = {str(model) for model in advertised if model}
252
+ if advertised_models and route.model_ref not in advertised_models:
253
+ return (
254
+ f"Proxy tier mappings do not advertise model '{route.model_ref}'; "
255
+ "request routing may still work, but tier overrides may not apply."
256
+ )
257
+ return None
258
+
259
+
260
+ def _route_warning(
261
+ route: ModelRoute | None,
262
+ *,
263
+ base_url: str | None = None,
264
+ metadata: dict[str, Any] | None = None,
265
+ advisory_check: bool = False,
266
+ warning: str | None = None,
267
+ ) -> str | None:
268
+ live_warning = _live_advisory_warning(base_url, route, metadata) if advisory_check and base_url else None
269
+ return _join_warnings(warning, _cross_family_warning(route), live_warning)
270
+
271
+
272
+ # ── Resolution chain ─────────────────────────────────────────────
273
+
274
+
275
+ class ProxyRoutingError(ValueError):
276
+ """Raised when a proxy fails strict validation during routing."""
277
+
278
+
279
+ def resolve_subprocess_routing(
280
+ explicit_base_url: str | None = None,
281
+ explicit_proxy: str | None = None,
282
+ preferred_proxy: str | None = None,
283
+ routes: tuple[ModelRoute, ...] = (),
284
+ *,
285
+ require_route: bool = False,
286
+ use_environment: bool = True,
287
+ advisory_check: bool = False,
288
+ ) -> RoutingResult:
289
+ """Unified routing resolution for all Forge subprocesses.
290
+
291
+ Walks the resolution chain and returns a structured route/proxy decision.
292
+ Callers decide fail-open vs fail-closed based on ``source`` and their
293
+ use case.
294
+
295
+ Args:
296
+ explicit_base_url: Highest-priority URL override (supervisor's
297
+ ``config.base_url``). Opaque -- no compatibility check.
298
+ explicit_proxy: User-chosen proxy (``--proxy``, ``--supervisor-proxy``).
299
+ Strict: hard error if missing, unreachable, or incompatible.
300
+ preferred_proxy: Catalog hint (``ModelSpec.preferred_proxy``).
301
+ Soft: warn and continue if missing.
302
+ routes: Derived model routes for compatibility and route scan.
303
+ require_route: When True (workflows), opaque session proxy without
304
+ a matching route is treated as unresolved. When False
305
+ (supervisor, handoff), it is accepted as-is.
306
+ use_environment: When False, skip ambient FORGE_SUBPROCESS_PROXY
307
+ and ANTHROPIC_BASE_URL lookups. Useful for callers that need
308
+ to preserve their own explicit fallback ordering.
309
+ advisory_check: When True, perform non-blocking live GET / checks
310
+ on selected proxies to surface tier-mapping compatibility warnings.
311
+ """
312
+ sidecar = _is_sidecar_mode()
313
+
314
+ # Step 1: Explicit base URL (highest priority, opaque)
315
+ if explicit_base_url:
316
+ return RoutingResult(
317
+ base_url=explicit_base_url,
318
+ proxy_id=None,
319
+ template=None,
320
+ source="explicit",
321
+ route=None,
322
+ credential=None,
323
+ )
324
+
325
+ # Step 2: Explicit proxy (strict)
326
+ if explicit_proxy:
327
+ if sidecar:
328
+ injected = _resolve_injected_subprocess_proxy(
329
+ expected_proxy=explicit_proxy,
330
+ routes=routes,
331
+ source="explicit",
332
+ require_route=require_route,
333
+ advisory_check=advisory_check,
334
+ )
335
+ if injected is not None:
336
+ return injected
337
+ raise ProxyRoutingError(
338
+ f"Proxy '{explicit_proxy}' cannot be resolved inside sidecar.\n"
339
+ "Tip: Start the session with '--subprocess-proxy' or run the workflow on the host."
340
+ )
341
+ return _resolve_strict_proxy(explicit_proxy, routes, "explicit", advisory_check=advisory_check)
342
+
343
+ # Step 3: Subprocess proxy (strict)
344
+ subprocess_proxy = os.environ.get(FORGE_SUBPROCESS_PROXY_VAR) if use_environment else None
345
+ if subprocess_proxy:
346
+ if sidecar:
347
+ injected = _resolve_injected_subprocess_proxy(
348
+ expected_proxy=subprocess_proxy,
349
+ routes=routes,
350
+ source="subprocess_proxy",
351
+ require_route=require_route,
352
+ advisory_check=advisory_check,
353
+ )
354
+ if injected is not None:
355
+ return injected
356
+ raise ProxyRoutingError(
357
+ f"Subprocess proxy '{subprocess_proxy}' configured but not resolvable inside sidecar.\n"
358
+ "Tip: Ensure the host injects FORGE_SUBPROCESS_BASE_URL into the sidecar environment."
359
+ )
360
+ else:
361
+ return _resolve_strict_proxy(subprocess_proxy, routes, "subprocess_proxy", advisory_check=advisory_check)
362
+ elif sidecar:
363
+ injected = _resolve_injected_subprocess_proxy(
364
+ expected_proxy=None,
365
+ routes=routes,
366
+ source="subprocess_proxy",
367
+ require_route=require_route,
368
+ advisory_check=advisory_check,
369
+ )
370
+ if injected is not None:
371
+ return injected
372
+
373
+ # Step 4: Preferred proxy (soft)
374
+ if preferred_proxy and not sidecar:
375
+ result = _resolve_soft_proxy(preferred_proxy, routes, advisory_check=advisory_check)
376
+ if result is not None:
377
+ return result
378
+
379
+ # Step 5: Route scan (registry-based)
380
+ if routes and not sidecar:
381
+ result = _scan_routes_for_proxy(routes, advisory_check=advisory_check)
382
+ if result is not None:
383
+ return result
384
+
385
+ # Step 6: Session proxy (inherited ANTHROPIC_BASE_URL)
386
+ session_base_url = os.environ.get("ANTHROPIC_BASE_URL") if use_environment else None
387
+ if session_base_url:
388
+ return _resolve_session_proxy(session_base_url, routes, require_route, advisory_check=advisory_check)
389
+
390
+ # Step 7: Unresolved
391
+ return RoutingResult(
392
+ base_url=None,
393
+ proxy_id=None,
394
+ template=None,
395
+ source="unresolved",
396
+ route=None,
397
+ credential=None,
398
+ )
399
+
400
+
401
+ def _resolve_strict_proxy(
402
+ proxy: str,
403
+ routes: tuple[ModelRoute, ...],
404
+ source: RoutingSource,
405
+ *,
406
+ advisory_check: bool = False,
407
+ ) -> RoutingResult:
408
+ """Resolve a user-chosen proxy with strict validation."""
409
+ entry = lookup_proxy_entry_strict(proxy)
410
+
411
+ if not _check_proxy_reachable(entry):
412
+ raise ProxyRoutingError(
413
+ f"Proxy '{proxy}' is not reachable at {entry.base_url}.\n"
414
+ f"Tip: Run 'forge proxy start {proxy}' or check if the process is running."
415
+ )
416
+
417
+ route = _find_matching_route(entry.template, routes)
418
+ if routes and route is None:
419
+ template_desc = entry.template
420
+ route_desc = ", ".join(f"{r.template_id}/{r.credential}" for r in routes if r.template_id)
421
+ raise ProxyRoutingError(
422
+ f"Proxy '{proxy}' (template: {template_desc}) is not compatible with this model.\n"
423
+ f" Compatible routes: {route_desc or '(direct only)'}\n"
424
+ f"Tip: Use '--proxy' with a compatible proxy, or 'forge proxy create <template>'."
425
+ )
426
+
427
+ return _make_result_from_entry(entry, route, source, advisory_check=advisory_check)
428
+
429
+
430
+ def _resolve_soft_proxy(
431
+ proxy: str,
432
+ routes: tuple[ModelRoute, ...],
433
+ *,
434
+ advisory_check: bool = False,
435
+ ) -> RoutingResult | None:
436
+ """Resolve a catalog-recommended proxy (warn and skip on failure)."""
437
+ entry = lookup_proxy_entry(proxy)
438
+ if entry is None:
439
+ _log.debug("Preferred proxy '%s' not found in registry; skipping", proxy)
440
+ return None
441
+
442
+ if not _check_proxy_reachable(entry):
443
+ _log.debug("Preferred proxy '%s' not reachable; skipping", proxy)
444
+ return None
445
+
446
+ route = _find_matching_route(entry.template, routes)
447
+ if routes and route is None:
448
+ _log.debug("Preferred proxy '%s' not compatible with model routes; skipping", proxy)
449
+ return None
450
+
451
+ return _make_result_from_entry(entry, route, "preferred_proxy", advisory_check=advisory_check)
452
+
453
+
454
+ def _scan_routes_for_proxy(
455
+ routes: tuple[ModelRoute, ...],
456
+ *,
457
+ advisory_check: bool = False,
458
+ ) -> RoutingResult | None:
459
+ """Find a running proxy that matches one of the model's derived routes.
460
+
461
+ Only returns reachable proxies (HTTP health check). Ranking:
462
+ 1. Route preference order (from derive_model_routes)
463
+ 2. Alphabetical proxy_id tiebreaker
464
+ """
465
+ from forge.proxy.proxies import ROUTABLE_STATUSES, ProxyRegistryStore
466
+
467
+ try:
468
+ registry = ProxyRegistryStore().read()
469
+ except Exception as e:
470
+ _log.debug("Route scan: registry read failed: %s", e)
471
+ return None
472
+
473
+ template_to_routes: dict[str, ModelRoute] = {}
474
+ for route in routes:
475
+ if route.template_id and route.template_id not in template_to_routes:
476
+ template_to_routes[route.template_id] = route
477
+
478
+ if not template_to_routes:
479
+ return None
480
+
481
+ candidates: list[tuple[int, str, ProxyEntry, ModelRoute]] = []
482
+
483
+ for entry in registry.proxies.values():
484
+ if entry.status not in ROUTABLE_STATUSES:
485
+ continue
486
+
487
+ matched_route = template_to_routes.get(entry.template)
488
+ if matched_route is None:
489
+ continue
490
+
491
+ if not _check_proxy_reachable(entry, timeout_s=1.0):
492
+ _log.debug("Route scan: proxy '%s' matches but is not reachable", entry.proxy_id)
493
+ continue
494
+
495
+ route_rank = next(
496
+ (i for i, r in enumerate(routes) if r.template_id == entry.template),
497
+ len(routes),
498
+ )
499
+ candidates.append((route_rank, entry.proxy_id, entry, matched_route))
500
+
501
+ if not candidates:
502
+ return None
503
+
504
+ candidates.sort(key=lambda c: (c[0], c[1]))
505
+ _, _, best_entry, best_route = candidates[0]
506
+
507
+ return _make_result_from_entry(best_entry, best_route, "route_scan", advisory_check=advisory_check)
508
+
509
+
510
+ def _resolve_injected_subprocess_proxy(
511
+ *,
512
+ expected_proxy: str | None,
513
+ routes: tuple[ModelRoute, ...],
514
+ source: RoutingSource,
515
+ require_route: bool,
516
+ advisory_check: bool,
517
+ ) -> RoutingResult | None:
518
+ """Resolve host-injected subprocess proxy metadata inside sidecar mode."""
519
+ base_url = os.environ.get(FORGE_SUBPROCESS_BASE_URL_VAR)
520
+ if not base_url:
521
+ return None
522
+
523
+ proxy_id = os.environ.get(FORGE_SUBPROCESS_PROXY_ID_VAR) or None
524
+ template = os.environ.get(FORGE_SUBPROCESS_TEMPLATE_VAR) or None
525
+ if expected_proxy and expected_proxy not in {value for value in (proxy_id, template) if value}:
526
+ return None
527
+
528
+ route = _find_matching_route(template, routes) if template and routes else None
529
+ if require_route and routes and route is None:
530
+ route_desc = ", ".join(f"{r.template_id}/{r.credential}" for r in routes if r.template_id)
531
+ raise ProxyRoutingError(
532
+ "Injected subprocess proxy metadata is not compatible with this model.\n"
533
+ f" Proxy: {proxy_id or expected_proxy or '(unknown)'}"
534
+ f"{f' (template: {template})' if template else ''}\n"
535
+ f" Compatible routes: {route_desc or '(direct only)'}"
536
+ )
537
+
538
+ return RoutingResult(
539
+ base_url=base_url,
540
+ proxy_id=proxy_id,
541
+ template=template,
542
+ source=source,
543
+ route=route,
544
+ credential=route.credential if route else None,
545
+ warning=_route_warning(route, base_url=base_url, advisory_check=advisory_check),
546
+ )
547
+
548
+
549
+ def _resolve_session_proxy(
550
+ base_url: str,
551
+ routes: tuple[ModelRoute, ...],
552
+ require_route: bool,
553
+ *,
554
+ advisory_check: bool = False,
555
+ ) -> RoutingResult:
556
+ """Resolve inherited ANTHROPIC_BASE_URL with registry + live metadata probe."""
557
+ from forge.proxy.proxies import ProxyRegistryStore, lookup_proxy_by_base_url
558
+
559
+ proxy_id: str | None = None
560
+ template: str | None = None
561
+ route: ModelRoute | None = None
562
+ warning: str | None = None
563
+ probed: dict[str, Any] | None = None
564
+
565
+ # Try registry lookup first (cheapest)
566
+ try:
567
+ registry = ProxyRegistryStore().read()
568
+ entry = lookup_proxy_by_base_url(registry, base_url)
569
+ if entry:
570
+ if require_route and not _check_proxy_reachable(entry):
571
+ warning = (
572
+ f"Session proxy registry match '{entry.proxy_id}' at {base_url} did not pass health validation; "
573
+ "treating as unresolved unless live metadata matches."
574
+ )
575
+ _log.debug("Session proxy registry match '%s' not reachable; probing live metadata", entry.proxy_id)
576
+ else:
577
+ proxy_id = entry.proxy_id
578
+ template = entry.template
579
+ route = _find_matching_route(entry.template, routes)
580
+ except Exception as e:
581
+ _log.debug("Session proxy registry lookup failed: %s", e)
582
+
583
+ # If registry miss, try live GET / probe for Forge proxy metadata
584
+ if template is None:
585
+ probed = _probe_proxy_metadata(base_url)
586
+ if probed:
587
+ probed_template = probed.get("template")
588
+ probed_proxy_id = probed.get("proxy_id")
589
+ template = probed_template if isinstance(probed_template, str) else None
590
+ proxy_id = proxy_id or (probed_proxy_id if isinstance(probed_proxy_id, str) else None)
591
+ if template and routes:
592
+ route = _find_matching_route(template, routes)
593
+
594
+ if require_route and route is None:
595
+ return RoutingResult(
596
+ base_url=None,
597
+ proxy_id=None,
598
+ template=None,
599
+ source="unresolved",
600
+ route=None,
601
+ credential=None,
602
+ warning=_join_warnings(
603
+ warning,
604
+ f"Session proxy at {base_url} has no matching route; treating as unresolved.",
605
+ ),
606
+ )
607
+
608
+ return RoutingResult(
609
+ base_url=base_url,
610
+ proxy_id=proxy_id,
611
+ template=template,
612
+ source="session_proxy",
613
+ route=route,
614
+ credential=route.credential if route else None,
615
+ warning=_route_warning(
616
+ route,
617
+ base_url=base_url,
618
+ metadata=probed,
619
+ advisory_check=advisory_check,
620
+ warning=warning,
621
+ ),
622
+ )