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,158 @@
1
+ """Model ID normalization for proxy requests.
2
+
3
+ This module handles the flexible model ID scheme:
4
+ - provider/vendor/model → explicit provider
5
+ - vendor/model → default_provider
6
+ - model → default_provider + inferred vendor
7
+
8
+ The proxy owns this normalization logic as orchestration concern.
9
+ core.llm stays strict and requires explicit (provider, model_id) tuples.
10
+ """
11
+
12
+ from typing import cast
13
+
14
+ from forge.core.llm.detection import ProviderType
15
+
16
+ # Known providers - these can appear as first segment
17
+ KNOWN_PROVIDERS = frozenset({"litellm_remote", "litellm_local"})
18
+
19
+ # Known vendors - these appear as vendor/ prefix before model name
20
+ KNOWN_VENDORS = frozenset(
21
+ {
22
+ "openai",
23
+ "anthropic",
24
+ "vertex_ai",
25
+ "gemini",
26
+ "bedrock",
27
+ "replicate",
28
+ "together_ai",
29
+ }
30
+ )
31
+
32
+
33
+ def _infer_vendor(model_name: str) -> str:
34
+ """Infer vendor from model name patterns.
35
+
36
+ Args:
37
+ model_name: Model name without any vendor prefix (e.g., "gpt-5.5")
38
+
39
+ Returns:
40
+ Inferred vendor prefix.
41
+
42
+ Raises:
43
+ ValueError: If vendor cannot be inferred from model name.
44
+
45
+ Examples:
46
+ >>> _infer_vendor("gpt-5.5")
47
+ 'openai'
48
+ >>> _infer_vendor("claude-sonnet-4.6")
49
+ 'anthropic'
50
+ >>> _infer_vendor("gemini-3.1-pro")
51
+ 'vertex_ai'
52
+ """
53
+ name = model_name.lower()
54
+
55
+ # OpenAI models: gpt-*, o1*, o3*, o4*
56
+ if name.startswith("gpt-") or name.startswith(("o1", "o3", "o4")):
57
+ return "openai"
58
+
59
+ # Anthropic models: claude-*
60
+ if name.startswith("claude-"):
61
+ return "anthropic"
62
+
63
+ # Google models: gemini-* → vertex_ai for remote routing
64
+ if name.startswith("gemini-"):
65
+ return "vertex_ai"
66
+
67
+ raise ValueError(
68
+ f"Cannot infer vendor for model '{model_name}'. "
69
+ f"Use explicit vendor prefix like 'openai/{model_name}' or 'anthropic/{model_name}'."
70
+ )
71
+
72
+
73
+ def normalize_model_spec(
74
+ input_spec: str,
75
+ default_provider: ProviderType = "litellm_remote",
76
+ ) -> tuple[ProviderType, str]:
77
+ """Parse model spec and return (provider, vendor/model).
78
+
79
+ Supports progressive fallback:
80
+ - provider/vendor/model → explicit provider (e.g., "litellm_local/gemini/gemini-3.1")
81
+ - vendor/model → default_provider (e.g., "openai/gpt-5.5")
82
+ - model → default_provider + inferred vendor (e.g., "gpt-5.5" → "openai/gpt-5.5")
83
+
84
+ Args:
85
+ input_spec: Model specification in any supported format.
86
+ default_provider: Provider to use when not explicitly specified.
87
+
88
+ Returns:
89
+ Tuple of (provider, model_id) where model_id is vendor/model format.
90
+
91
+ Raises:
92
+ ValueError: If the spec is malformed or cannot be parsed.
93
+
94
+ Examples:
95
+ >>> normalize_model_spec("litellm_remote/openai/gpt-5.5")
96
+ ('litellm_remote', 'openai/gpt-5.5')
97
+ >>> normalize_model_spec("openai/gpt-5.5")
98
+ ('litellm_remote', 'openai/gpt-5.5')
99
+ >>> normalize_model_spec("gpt-5.5")
100
+ ('litellm_remote', 'openai/gpt-5.5')
101
+ >>> normalize_model_spec("litellm_remote/openai/gpt-5.5")
102
+ ('litellm_remote', 'openai/gpt-5.5')
103
+ """
104
+ if not input_spec or not input_spec.strip():
105
+ raise ValueError("Model spec cannot be empty")
106
+
107
+ parts = input_spec.strip().split("/")
108
+
109
+ if len(parts) == 1:
110
+ # Single segment: model only → infer vendor, use default provider
111
+ model_name = parts[0]
112
+ vendor = _infer_vendor(model_name)
113
+ return (default_provider, f"{vendor}/{model_name}")
114
+
115
+ if len(parts) == 2:
116
+ first, second = parts
117
+
118
+ # Check if first segment is a known provider (ambiguous case)
119
+ if first in KNOWN_PROVIDERS:
120
+ # This looks like provider/something, but we need vendor/model
121
+ raise ValueError(
122
+ f"Ambiguous model spec '{input_spec}': '{first}' looks like a provider "
123
+ f"but missing vendor segment. Use '{first}/openai/{second}' format."
124
+ )
125
+
126
+ # Standard vendor/model format
127
+ if first not in KNOWN_VENDORS:
128
+ raise ValueError(
129
+ f"Unknown vendor '{first}' in model spec '{input_spec}'. "
130
+ f"Known vendors: {', '.join(sorted(KNOWN_VENDORS))}"
131
+ )
132
+
133
+ return (default_provider, input_spec)
134
+
135
+ if len(parts) == 3:
136
+ provider, vendor, model = parts
137
+
138
+ # Validate provider
139
+ if provider not in KNOWN_PROVIDERS:
140
+ raise ValueError(
141
+ f"Unknown provider '{provider}' in model spec '{input_spec}'. "
142
+ f"Known providers: {', '.join(sorted(KNOWN_PROVIDERS))}"
143
+ )
144
+
145
+ # Validate vendor
146
+ if vendor not in KNOWN_VENDORS:
147
+ raise ValueError(
148
+ f"Unknown vendor '{vendor}' in model spec '{input_spec}'. "
149
+ f"Known vendors: {', '.join(sorted(KNOWN_VENDORS))}"
150
+ )
151
+
152
+ return (cast(ProviderType, provider), f"{vendor}/{model}")
153
+
154
+ # 4+ segments: invalid
155
+ raise ValueError(
156
+ f"Invalid model spec '{input_spec}': too many segments. "
157
+ f"Use 'provider/vendor/model', 'vendor/model', or 'model' format."
158
+ )
forge/proxy/proxies.py ADDED
@@ -0,0 +1,333 @@
1
+ """Proxy registry for Forge proxy endpoints.
2
+
3
+ A proxy is a first-class identity for a running proxy endpoint (base_url/port) bound to a template.
4
+ The proxy registry is stored at:
5
+
6
+ - ~/.forge/proxies/index.json
7
+
8
+ This module implements a small, versioned JSON store with atomic writes.
9
+
10
+ Ownership: Forge Proxy Orchestrator (`forge proxy` CLI).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ from dataclasses import asdict, dataclass, field
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Callable
21
+
22
+ import dacite
23
+
24
+ from forge.core.paths import get_forge_home
25
+ from forge.core.state import (
26
+ StateCorruptedError,
27
+ atomic_write_json,
28
+ file_lock_for_target,
29
+ )
30
+
31
+ _log = logging.getLogger(__name__)
32
+
33
+ # A "starting" entry with no PID older than this is considered orphaned.
34
+ STARTING_STALENESS_THRESHOLD_S = 60
35
+
36
+ PROXY_REGISTRY_VERSION = 1
37
+ PROXIES_DIR = "proxies"
38
+ PROXY_INDEX_FILENAME = "index.json"
39
+
40
+ CLI_LOCK_TIMEOUT_S = 5.0
41
+
42
+
43
+ from forge.core.process import is_pid_alive as is_pid_alive # noqa: E402, F401 # re-export
44
+
45
+
46
+ def _is_orphaned_starting(entry: ProxyEntry) -> bool:
47
+ """Return True if a 'starting' entry with no PID is stale.
48
+
49
+ A proxy in "starting" state should transition to "healthy" within seconds.
50
+ If it's been in "starting" for longer than STARTING_STALENESS_THRESHOLD_S,
51
+ it was orphaned by an interrupted start_proxy() call (e.g., Ctrl+C).
52
+ """
53
+ if entry.created_at is None:
54
+ # No timestamp — can't determine age, treat as stale (defensive).
55
+ return True
56
+ try:
57
+ created = datetime.fromisoformat(entry.created_at)
58
+ # Ensure timezone-aware comparison
59
+ now = datetime.now(timezone.utc)
60
+ if created.tzinfo is None:
61
+ created = created.replace(tzinfo=timezone.utc)
62
+ age_s = (now - created).total_seconds()
63
+ return age_s > STARTING_STALENESS_THRESHOLD_S
64
+ except (ValueError, TypeError):
65
+ # Unparseable timestamp — treat as stale.
66
+ return True
67
+
68
+
69
+ class ProxyRegistryCorruptedError(StateCorruptedError):
70
+ """Raised when the proxy registry cannot be parsed."""
71
+
72
+ pass
73
+
74
+
75
+ @dataclass
76
+ class ProxyEntry:
77
+ """A single proxy entry.
78
+
79
+ Timestamps are stored as ISO8601 strings.
80
+ """
81
+
82
+ proxy_id: str
83
+ template: str
84
+ base_url: str
85
+ port: int
86
+ pid: int | None = None
87
+ created_at: str | None = None
88
+ last_seen_at: str | None = None
89
+ status: str | None = None
90
+
91
+
92
+ @dataclass
93
+ class ProxyRegistry:
94
+ """Proxy registry file format."""
95
+
96
+ version: int = PROXY_REGISTRY_VERSION
97
+ proxies: dict[str, ProxyEntry] = field(default_factory=dict)
98
+
99
+
100
+ def get_proxy_registry_path() -> Path:
101
+ """Return the full path to the proxy registry file."""
102
+
103
+ return get_forge_home() / PROXIES_DIR / PROXY_INDEX_FILENAME
104
+
105
+
106
+ def lookup_proxy_by_base_url(registry: ProxyRegistry, base_url: str) -> ProxyEntry | None:
107
+ """Reverse lookup: find proxy entry by base_url.
108
+
109
+ Args:
110
+ registry: The proxy registry to search.
111
+ base_url: The URL to match (e.g., "http://localhost:8084").
112
+
113
+ Returns:
114
+ The matching ProxyEntry, or None if no proxy owns that base_url.
115
+ """
116
+ for entry in registry.proxies.values():
117
+ if entry.base_url == base_url:
118
+ return entry
119
+ return None
120
+
121
+
122
+ # Proxy statuses that represent a routable (active) proxy.
123
+ ROUTABLE_STATUSES = frozenset({"healthy", "starting"})
124
+
125
+
126
+ class ProxyResolutionError(Exception):
127
+ """Base for proxy resolution failures."""
128
+
129
+
130
+ class ProxyNotFoundError(ProxyResolutionError):
131
+ """No proxy matches the given name (neither proxy_id nor template)."""
132
+
133
+ def __init__(self, name: str, *, inactive_ids: list[str] | None = None) -> None:
134
+ self.name = name
135
+ self.inactive_ids = inactive_ids or []
136
+ if self.inactive_ids:
137
+ ids = ", ".join(self.inactive_ids)
138
+ super().__init__(
139
+ f"found {len(self.inactive_ids)} proxy(s) with template '{name}' " f"but none are active: {ids}"
140
+ )
141
+ else:
142
+ super().__init__(f"no proxy found matching '{name}' (checked proxy_id and template)")
143
+
144
+
145
+ class AmbiguousProxyError(ProxyResolutionError):
146
+ """Multiple active proxies match the given template name."""
147
+
148
+ def __init__(self, name: str, proxy_ids: list[str]) -> None:
149
+ self.name = name
150
+ self.proxy_ids = proxy_ids
151
+ ids = ", ".join(proxy_ids)
152
+ super().__init__(
153
+ f"ambiguous: template '{name}' matches {len(proxy_ids)} active proxies: {ids}. "
154
+ f"Use a specific proxy_id instead"
155
+ )
156
+
157
+
158
+ def resolve_proxy(registry: ProxyRegistry, name: str) -> ProxyEntry:
159
+ """Resolve a proxy by ID or template name.
160
+
161
+ Resolution order:
162
+ 1. Exact proxy_id match (any status -- user asked for it by name).
163
+ 2. Template fallback among active (routable) entries only.
164
+ Succeeds if exactly one active proxy uses that template.
165
+
166
+ Raises:
167
+ ProxyNotFoundError: No match found.
168
+ AmbiguousProxyError: Multiple active proxies share the template.
169
+ """
170
+ # 1. Exact proxy_id match
171
+ if name in registry.proxies:
172
+ return registry.proxies[name]
173
+
174
+ # 2. Template fallback (active entries only)
175
+ all_matches = [e for e in registry.proxies.values() if e.template == name]
176
+ active = [e for e in all_matches if e.status in ROUTABLE_STATUSES]
177
+
178
+ if len(active) == 1:
179
+ return active[0]
180
+ if len(active) > 1:
181
+ raise AmbiguousProxyError(name, [e.proxy_id for e in active])
182
+ if all_matches:
183
+ raise ProxyNotFoundError(name, inactive_ids=[e.proxy_id for e in all_matches])
184
+ raise ProxyNotFoundError(name)
185
+
186
+
187
+ def resolve_proxy_optional(registry: ProxyRegistry, name: str) -> ProxyEntry | None:
188
+ """Fail-open variant of resolve_proxy.
189
+
190
+ Returns None on not-found. Logs a warning on ambiguous match
191
+ (silent fallback to direct could bypass enterprise proxy policies).
192
+ Intended for headless consumers (supervisor, handoff, workflows)
193
+ where a missing proxy should degrade gracefully.
194
+ """
195
+ try:
196
+ return resolve_proxy(registry, name)
197
+ except AmbiguousProxyError as e:
198
+ _log.warning("Proxy resolution ambiguous, falling back to direct: %s", e)
199
+ return None
200
+ except ProxyResolutionError:
201
+ return None
202
+
203
+
204
+ class ProxyRegistryStore:
205
+ """Manage the proxy registry at ~/.forge/proxies/index.json.
206
+
207
+ Error handling:
208
+ - Missing file: returns empty registry (self-healing)
209
+ - Corrupted file: raises ProxyRegistryCorruptedError
210
+ """
211
+
212
+ def __init__(self, registry_path: Path | None = None) -> None:
213
+ self._registry_path = registry_path or get_proxy_registry_path()
214
+
215
+ @property
216
+ def registry_path(self) -> Path:
217
+ return self._registry_path
218
+
219
+ def exists(self) -> bool:
220
+ return self._registry_path.is_file()
221
+
222
+ def read(self) -> ProxyRegistry:
223
+ if not self.exists():
224
+ return ProxyRegistry()
225
+
226
+ try:
227
+ with open(self._registry_path, encoding="utf-8") as f:
228
+ data = json.load(f)
229
+ except json.JSONDecodeError as e:
230
+ raise ProxyRegistryCorruptedError(str(self._registry_path), f"invalid JSON: {e}")
231
+ except OSError as e:
232
+ raise ProxyRegistryCorruptedError(str(self._registry_path), f"read error: {e}")
233
+
234
+ version = data.get("version")
235
+ if version is None:
236
+ raise ProxyRegistryCorruptedError(str(self._registry_path), "missing version field")
237
+ if version != PROXY_REGISTRY_VERSION:
238
+ raise ProxyRegistryCorruptedError(
239
+ str(self._registry_path),
240
+ f"incompatible version {version} (this Forge expects {PROXY_REGISTRY_VERSION}). "
241
+ f"Delete this file and retry.",
242
+ )
243
+
244
+ try:
245
+ return dacite.from_dict(
246
+ data_class=ProxyRegistry,
247
+ data=data,
248
+ config=dacite.Config(strict=True),
249
+ )
250
+ except (dacite.DaciteError, TypeError, KeyError) as e:
251
+ raise ProxyRegistryCorruptedError(str(self._registry_path), f"deserialization error: {e}")
252
+
253
+ def write(self, registry: ProxyRegistry) -> None:
254
+ data = asdict(registry)
255
+ atomic_write_json(self._registry_path, data)
256
+
257
+ def update(self, *, timeout_s: float, mutate: Callable[[ProxyRegistry], None]) -> ProxyRegistry:
258
+ """Update registry via a locked read-modify-write cycle."""
259
+
260
+ with file_lock_for_target(target_path=self._registry_path, timeout_s=timeout_s):
261
+ registry = self.read()
262
+ mutate(registry)
263
+ self.write(registry)
264
+ return registry
265
+
266
+ def prune_dead_pids(self, *, timeout_s: float = CLI_LOCK_TIMEOUT_S) -> list[str]:
267
+ """Remove stale proxy entries from the registry.
268
+
269
+ Definition of stale (normative):
270
+ - Entries with a pid that is no longer running (dead process).
271
+ - Entries with status "starting", pid == None, and created_at older than
272
+ STARTING_STALENESS_THRESHOLD_S (orphaned from interrupted start_proxy).
273
+
274
+ Entries with pid == None and status other than "starting" (e.g.,
275
+ "configured", "stopped") are intentional user states and never pruned.
276
+
277
+ Returns:
278
+ List of proxy IDs removed from the registry.
279
+ """
280
+
281
+ with file_lock_for_target(target_path=self._registry_path, timeout_s=timeout_s):
282
+ registry = self.read()
283
+
284
+ stale_ids: list[str] = []
285
+ for proxy_id, entry in list(registry.proxies.items()):
286
+ if entry.pid is not None:
287
+ if not is_pid_alive(entry.pid):
288
+ del registry.proxies[proxy_id]
289
+ stale_ids.append(proxy_id)
290
+ elif entry.status == "starting" and _is_orphaned_starting(entry):
291
+ del registry.proxies[proxy_id]
292
+ stale_ids.append(proxy_id)
293
+
294
+ if stale_ids:
295
+ self.write(registry)
296
+
297
+ return stale_ids
298
+
299
+ def list_proxies(self) -> list[ProxyEntry]:
300
+ """List proxy entries in deterministic order.
301
+
302
+ Entries with last_seen_at sort before entries without, then by proxy_id ASC
303
+ within each group. Does not sort by timestamp value (format-agnostic).
304
+ """
305
+
306
+ registry = self.read()
307
+ proxies = list(registry.proxies.values())
308
+
309
+ def _sort_key(entry: ProxyEntry) -> tuple[int, str]:
310
+ # Prefer entries with last_seen_at; sort them newest-first.
311
+ if entry.last_seen_at is None:
312
+ return (0, entry.proxy_id)
313
+ return (1, entry.proxy_id)
314
+
315
+ # We can't parse timestamps here without committing to a format; keep stable ordering.
316
+ # The CLI will display timestamps; orchestration later can implement richer sorting.
317
+ proxies.sort(key=_sort_key, reverse=True)
318
+ return proxies
319
+
320
+ def find_by_base_url(self, base_url: str) -> ProxyEntry | None:
321
+ """Find a proxy entry by its base_url.
322
+
323
+ This is a convenience method that combines read() + lookup_proxy_by_base_url().
324
+ Useful for status line and other consumers that need reverse lookup from URL.
325
+
326
+ Args:
327
+ base_url: The URL to match (e.g., "http://localhost:8084").
328
+
329
+ Returns:
330
+ The matching ProxyEntry, or None if no proxy owns that base_url.
331
+ """
332
+ registry = self.read()
333
+ return lookup_proxy_by_base_url(registry, base_url)
@@ -0,0 +1,134 @@
1
+ """Proxy identity discovery for runtime truth.
2
+
3
+ This module provides a lightweight, testable way to discover the proxy's
4
+ identity for the GET / runtime truth endpoint.
5
+
6
+ The discovery uses a 2-tier approach:
7
+ 1. Registry lookup by (template, port) (primary)
8
+ 2. Derived from request/env (fallback - for unregistered proxies)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from dataclasses import dataclass
15
+ from typing import Literal
16
+
17
+ from forge.proxy.proxies import ProxyRegistryCorruptedError, ProxyRegistryStore
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Default port when neither request nor env port is available.
22
+ # This is intentionally a simple constant (not derived from config) to keep
23
+ # this module lightweight and avoid circular imports. The value matches
24
+ # the base.yaml default, but if they drift, the impact is only on the
25
+ # "derived" fallback path (manual/unregistered proxies).
26
+ DEFAULT_PROXY_PORT = 8082
27
+
28
+ # Type aliases for clarity and correctness
29
+ ProxySource = Literal["registry", "derived"]
30
+ ProxyStatus = Literal["registered", "unregistered"]
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ProxyIdentity:
35
+ """Proxy identity for runtime truth.
36
+
37
+ Immutable (frozen) to prevent accidental mutation.
38
+ All fields are always populated - no None values except proxy_id
39
+ when the proxy is unregistered.
40
+ """
41
+
42
+ proxy_id: str | None
43
+ template: str
44
+ port: int
45
+ base_url: str
46
+ source: ProxySource
47
+ status: ProxyStatus
48
+
49
+
50
+ def get_proxy_identity(
51
+ *,
52
+ active_template: str,
53
+ request_host: str | None = None,
54
+ request_port: int | None = None,
55
+ env_port: int | None = None,
56
+ process_proxy_id: str | None = None,
57
+ ) -> ProxyIdentity:
58
+ """Discover proxy identity using 2-tier approach.
59
+
60
+ Priority for port determination:
61
+ request_port > env_port > DEFAULT_PROXY_PORT
62
+
63
+ Priority for proxy identity:
64
+ 1. Process proxy id + registry lookup by (template, port) (source="registry")
65
+ 2. Registry lookup by (template, port) (source="registry")
66
+ 3. Derived values (source="derived", status="unregistered")
67
+
68
+ The base_url is always derived from the effective host/port, not from
69
+ the registry, to ensure accuracy with the actual request endpoint.
70
+
71
+ Args:
72
+ active_template: The proxy template name (e.g., "litellm-openai")
73
+ request_host: Host from the incoming request (preferred)
74
+ request_port: Port from the incoming request (preferred)
75
+ env_port: Port from ACTIVE_PORT env var (fallback)
76
+ process_proxy_id: Proxy id this process was started with (FORGE_PROXY_ID).
77
+
78
+ Returns:
79
+ ProxyIdentity with all fields populated. proxy_id may be None
80
+ when the proxy is unregistered.
81
+ """
82
+ effective_port = request_port or env_port or DEFAULT_PROXY_PORT
83
+ effective_host = request_host or "localhost"
84
+ base_url = f"http://{effective_host}:{effective_port}"
85
+
86
+ try:
87
+ store = ProxyRegistryStore()
88
+ registry = store.read()
89
+
90
+ # Multiple matches shouldn't happen, but corruption could cause it;
91
+ # sort by proxy_id for deterministic selection.
92
+ matches = [
93
+ entry
94
+ for entry in registry.proxies.values()
95
+ if entry.template == active_template and entry.port == effective_port
96
+ ]
97
+ if process_proxy_id:
98
+ for entry in matches:
99
+ if entry.proxy_id == process_proxy_id:
100
+ return ProxyIdentity(
101
+ proxy_id=entry.proxy_id,
102
+ template=active_template,
103
+ port=effective_port,
104
+ base_url=base_url,
105
+ source="registry",
106
+ status="registered",
107
+ )
108
+
109
+ if matches:
110
+ best_match = sorted(matches, key=lambda e: e.proxy_id)[0]
111
+ return ProxyIdentity(
112
+ proxy_id=best_match.proxy_id,
113
+ template=active_template,
114
+ port=effective_port,
115
+ base_url=base_url, # Use derived base_url, not registry
116
+ source="registry",
117
+ status="registered",
118
+ )
119
+ except ProxyRegistryCorruptedError as e:
120
+ logger.warning(f"Proxy registry corrupted during identity lookup: {e}")
121
+ except Exception as e:
122
+ # Don't fail if registry is unavailable (e.g., missing file is handled
123
+ # by ProxyRegistryStore.read() returning empty registry, but other
124
+ # unexpected errors should be logged)
125
+ logger.debug(f"Proxy registry lookup failed: {e}")
126
+
127
+ return ProxyIdentity(
128
+ proxy_id=process_proxy_id,
129
+ template=active_template,
130
+ port=effective_port,
131
+ base_url=base_url,
132
+ source="derived",
133
+ status="unregistered",
134
+ )