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,174 @@
1
+ """Backend management for Forge.
2
+
3
+ Backends are underlying services that proxies depend on (e.g., LiteLLM).
4
+ They have their own lifecycle, registry, and CLI commands.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from abc import ABC, abstractmethod
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Literal
13
+
14
+ from forge.backend.registry import (
15
+ BackendInstance,
16
+ BackendRegistry,
17
+ BackendRegistryStore,
18
+ )
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class BackendEnsureResult:
23
+ """Result of ensure_backend() operation."""
24
+
25
+ instance: BackendInstance
26
+ source: Literal["reuse", "start"]
27
+
28
+
29
+ class BackendAdapter(ABC):
30
+ """Abstract base class for backend lifecycle management."""
31
+
32
+ @abstractmethod
33
+ def start(self, backend_id: str, config_path: Path, port: int) -> BackendInstance:
34
+ """Start backend, return instance details.
35
+
36
+ Args:
37
+ backend_id: Unique instance ID (e.g., "litellm-4000")
38
+ config_path: Path to backend config file
39
+ port: Port number to bind
40
+
41
+ Returns:
42
+ BackendInstance with PID and status
43
+
44
+ Raises:
45
+ BackendStartError: If backend fails to start
46
+ """
47
+
48
+ @abstractmethod
49
+ def stop(self, instance: BackendInstance) -> None:
50
+ """Stop backend (best effort).
51
+
52
+ Args:
53
+ instance: Backend instance to stop
54
+ """
55
+
56
+ @abstractmethod
57
+ def health_check(self, instance: BackendInstance) -> bool:
58
+ """Check if backend is healthy.
59
+
60
+ Args:
61
+ instance: Backend instance to check
62
+
63
+ Returns:
64
+ True if healthy, False otherwise
65
+ """
66
+
67
+
68
+ class BackendStartError(Exception):
69
+ """Raised when backend fails to start."""
70
+
71
+
72
+ class BackendManager:
73
+ """Orchestrates backends via adapters."""
74
+
75
+ def __init__(self, registry_store: BackendRegistryStore) -> None:
76
+ """Initialize backend manager.
77
+
78
+ Args:
79
+ registry_store: Backend registry store
80
+ """
81
+ self.registry_store = registry_store
82
+ self.adapters: dict[str, BackendAdapter] = {}
83
+
84
+ def register_adapter(self, adapter_type: str, adapter: BackendAdapter) -> None:
85
+ """Register a backend adapter.
86
+
87
+ Args:
88
+ adapter_type: Adapter type (e.g., "litellm")
89
+ adapter: Adapter instance
90
+ """
91
+ self.adapters[adapter_type] = adapter
92
+
93
+ def ensure_backend(self, backend_id: str, adapter_type: str, port: int) -> BackendEnsureResult:
94
+ """Ensure backend is running (reuse -> start pattern).
95
+
96
+ Args:
97
+ backend_id: Backend instance ID (e.g., "litellm-4000")
98
+ adapter_type: Adapter type (e.g., "litellm")
99
+ port: Port number
100
+
101
+ Returns:
102
+ BackendEnsureResult with instance and source ("reuse" or "start")
103
+
104
+ Raises:
105
+ BackendStartError: If backend fails to start
106
+ """
107
+ from forge.backend.creation import get_backend_config_path
108
+
109
+ adapter = self.adapters.get(adapter_type)
110
+ if not adapter:
111
+ raise ValueError(f"No adapter registered for type: {adapter_type}")
112
+
113
+ registry = self.registry_store.read()
114
+ existing = registry.backends.get(backend_id)
115
+
116
+ if existing:
117
+ # health_check works with or without PID (port probe fallback)
118
+ if adapter.health_check(existing):
119
+ return BackendEnsureResult(instance=existing, source="reuse")
120
+
121
+ def remove_dead(reg: BackendRegistry) -> None:
122
+ reg.backends.pop(backend_id, None)
123
+
124
+ self.registry_store.update(timeout_s=10.0, mutate=remove_dead)
125
+
126
+ config_path = get_backend_config_path(adapter_type)
127
+ if not config_path.exists():
128
+ raise BackendStartError(
129
+ f"Backend config not found: {config_path}\n" f"Create it with: forge backend create {adapter_type}"
130
+ )
131
+
132
+ instance = adapter.start(backend_id, config_path, port)
133
+
134
+ def add_instance(reg: BackendRegistry) -> None:
135
+ reg.backends[backend_id] = instance
136
+
137
+ self.registry_store.update(timeout_s=10.0, mutate=add_instance)
138
+
139
+ return BackendEnsureResult(instance=instance, source="start")
140
+
141
+ def stop_backend(self, backend_id: str) -> None:
142
+ """Stop backend and remove from registry.
143
+
144
+ Args:
145
+ backend_id: Backend instance ID
146
+
147
+ Raises:
148
+ ValueError: If backend not found
149
+ """
150
+ registry = self.registry_store.read()
151
+ instance = registry.backends.get(backend_id)
152
+
153
+ if not instance:
154
+ raise ValueError(f"Backend not found: {backend_id}")
155
+
156
+ adapter = self.adapters.get(instance.adapter_type)
157
+ if adapter:
158
+ adapter.stop(instance)
159
+
160
+ def remove_instance(reg: BackendRegistry) -> None:
161
+ reg.backends.pop(backend_id, None)
162
+
163
+ self.registry_store.update(timeout_s=10.0, mutate=remove_instance)
164
+
165
+
166
+ __all__ = [
167
+ "BackendAdapter",
168
+ "BackendEnsureResult",
169
+ "BackendInstance",
170
+ "BackendManager",
171
+ "BackendRegistry",
172
+ "BackendRegistryStore",
173
+ "BackendStartError",
174
+ ]
@@ -0,0 +1,38 @@
1
+ """Backend adapters for different backend types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from forge.backend import BackendAdapter
6
+ from forge.backend.adapters.litellm import LiteLLMAdapter
7
+
8
+ # Registry of known adapter types
9
+ _ADAPTER_REGISTRY: dict[str, type["BackendAdapter"]] = {
10
+ "litellm": LiteLLMAdapter,
11
+ }
12
+
13
+
14
+ def get_adapter(adapter_type: str) -> "BackendAdapter":
15
+ """Get an adapter instance by type.
16
+
17
+ Args:
18
+ adapter_type: Adapter type (e.g., "litellm")
19
+
20
+ Returns:
21
+ New adapter instance
22
+
23
+ Raises:
24
+ ValueError: If adapter type is unknown
25
+ """
26
+ adapter_class = _ADAPTER_REGISTRY.get(adapter_type)
27
+ if adapter_class is None:
28
+ available = ", ".join(sorted(_ADAPTER_REGISTRY.keys()))
29
+ raise ValueError(f"Unknown adapter type: '{adapter_type}'. Available: {available}")
30
+ return adapter_class()
31
+
32
+
33
+ def get_supported_adapters() -> list[str]:
34
+ """Get list of supported adapter types."""
35
+ return sorted(_ADAPTER_REGISTRY.keys())
36
+
37
+
38
+ __all__ = ["LiteLLMAdapter", "get_adapter", "get_supported_adapters"]
@@ -0,0 +1,158 @@
1
+ """LiteLLM backend adapter.
2
+
3
+ Manages LiteLLM proxy processes for multi-provider model access.
4
+ LiteLLM supports many providers (Gemini, OpenAI, Anthropic, etc.) -
5
+ the specific provider is determined by the config file and env vars.
6
+
7
+ Env var validation happens BEFORE this adapter is called (in _ensure_dependency_backend),
8
+ so this adapter just passes through whatever is in os.environ.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import time
17
+ from pathlib import Path
18
+
19
+ import httpx
20
+
21
+ from forge.backend import BackendAdapter, BackendStartError
22
+ from forge.backend.registry import BackendInstance
23
+ from forge.core.paths import get_forge_home
24
+ from forge.core.state import now_iso
25
+
26
+
27
+ class LiteLLMAdapter(BackendAdapter):
28
+ """Adapter for managing LiteLLM backend processes.
29
+
30
+ This adapter is provider-agnostic - it starts LiteLLM with whatever
31
+ config and environment variables are provided. The specific provider
32
+ (Gemini, OpenAI, etc.) is determined by:
33
+ 1. The LiteLLM config file (model_list with provider prefixes)
34
+ 2. Environment variables (GEMINI_API_KEY, OPENAI_API_KEY, etc.)
35
+
36
+ Env var validation is handled by the caller (_ensure_dependency_backend)
37
+ which checks BackendDependency.required_env_vars before starting.
38
+ """
39
+
40
+ def _wait_for_health(self, port: int, timeout: float = 15.0) -> bool:
41
+ """Wait for backend to become healthy.
42
+
43
+ Uses /health/liveliness (not /health) because the full health endpoint
44
+ runs model-level checks against remote providers, which can take 5-10s.
45
+ The liveliness endpoint just verifies the server process is accepting
46
+ requests (~5ms).
47
+
48
+ Args:
49
+ port: Port to check
50
+ timeout: Timeout in seconds
51
+
52
+ Returns:
53
+ True if healthy within timeout, False otherwise
54
+ """
55
+ start_time = time.time()
56
+ url = f"http://localhost:{port}/health/liveliness"
57
+
58
+ while time.time() - start_time < timeout:
59
+ try:
60
+ with httpx.Client(timeout=httpx.Timeout(2.0)) as client:
61
+ response = client.get(url)
62
+ if response.status_code == 200:
63
+ return True
64
+ except (httpx.RequestError, httpx.TimeoutException):
65
+ pass
66
+ time.sleep(0.5)
67
+
68
+ return False
69
+
70
+ def start(self, backend_id: str, config_path: Path, port: int) -> BackendInstance:
71
+ """Start LiteLLM backend.
72
+
73
+ Args:
74
+ backend_id: Backend instance ID (e.g., "litellm-4000")
75
+ config_path: Path to LiteLLM config file
76
+ port: Port number to bind
77
+
78
+ Returns:
79
+ BackendInstance with PID and status
80
+
81
+ Raises:
82
+ BackendStartError: If backend fails to start
83
+
84
+ Note:
85
+ Required env vars (GEMINI_API_KEY, OPENAI_API_KEY, etc.) should
86
+ already be in os.environ - validation happens before this method
87
+ is called. We pass through the full environment to the subprocess.
88
+ """
89
+ # Build command — resolve the litellm binary from the same venv as
90
+ # sys.executable, rather than relying on a bare 'litellm' on PATH.
91
+ # Console scripts (pip, litellm, etc.) are always siblings of python
92
+ # in the venv's bin/ directory.
93
+ litellm_bin = str(Path(sys.executable).parent / "litellm")
94
+ cmd = [litellm_bin, "--config", str(config_path), "--port", str(port)]
95
+
96
+ # Pass through current environment (includes API keys loaded by load_config)
97
+ log_file = get_forge_home() / "logs" / "backend" / f"litellm-{port}.log"
98
+ log_file.parent.mkdir(parents=True, exist_ok=True)
99
+
100
+ with log_file.open("a") as log:
101
+ proc = subprocess.Popen(
102
+ cmd,
103
+ env=os.environ.copy(),
104
+ stdout=log,
105
+ stderr=subprocess.STDOUT,
106
+ start_new_session=True, # Detach from parent
107
+ )
108
+
109
+ # Wait for health (timeout 10s)
110
+ if not self._wait_for_health(port, timeout=10):
111
+ try:
112
+ proc.kill()
113
+ except OSError:
114
+ pass # Process already exited
115
+ raise BackendStartError(f"LiteLLM failed to start on port {port}\nCheck logs: {log_file}")
116
+
117
+ return BackendInstance(
118
+ backend_id=backend_id,
119
+ adapter_type="litellm",
120
+ port=port,
121
+ pid=proc.pid,
122
+ status="healthy",
123
+ created_at=now_iso(),
124
+ )
125
+
126
+ def stop(self, instance: BackendInstance) -> None:
127
+ """Stop LiteLLM backend (best effort).
128
+
129
+ Args:
130
+ instance: Backend instance to stop
131
+ """
132
+ if instance.pid is None:
133
+ return
134
+
135
+ try:
136
+ os.kill(instance.pid, 15) # SIGTERM
137
+ except (ProcessLookupError, PermissionError):
138
+ pass
139
+
140
+ def health_check(self, instance: BackendInstance) -> bool:
141
+ """Check if LiteLLM backend is healthy.
142
+
143
+ Uses /health/liveliness for fast checks (~5ms) rather than the full
144
+ /health endpoint which contacts all model providers (~5-10s).
145
+
146
+ Args:
147
+ instance: Backend instance to check
148
+
149
+ Returns:
150
+ True if healthy, False otherwise
151
+ """
152
+ try:
153
+ url = f"http://localhost:{instance.port}/health/liveliness"
154
+ with httpx.Client(timeout=httpx.Timeout(2.0)) as client:
155
+ response = client.get(url)
156
+ return response.status_code == 200
157
+ except (httpx.RequestError, httpx.TimeoutException):
158
+ return False
@@ -0,0 +1,89 @@
1
+ """Backend config creation (copy templates to installed location)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ from forge.config.loader import get_defaults_dir
10
+ from forge.core.paths import get_forge_home
11
+
12
+
13
+ def create_backend_config(
14
+ adapter_type: str,
15
+ source_config: Path | None = None,
16
+ ) -> Path:
17
+ """Create backend config by copying to installed location.
18
+
19
+ Config is shared by all instances of the same adapter type.
20
+
21
+ Args:
22
+ adapter_type: Adapter type (e.g., "litellm")
23
+ source_config: Source config file (defaults to defaults/backends/{adapter}.yaml)
24
+
25
+ Returns:
26
+ Path to created config file
27
+
28
+ Raises:
29
+ ValueError: If adapter type is unknown or source config not found
30
+ """
31
+ backend_dir = get_forge_home() / "backends" / adapter_type
32
+ backend_dir.mkdir(parents=True, exist_ok=True)
33
+
34
+ # Determine source config
35
+ if source_config is None:
36
+ # Use convention-based path: defaults/backends/{adapter}.yaml
37
+ defaults_dir = get_defaults_dir() # src/forge/config/defaults/
38
+ source_config = defaults_dir / "backends" / f"{adapter_type}.yaml"
39
+
40
+ if not source_config.exists():
41
+ raise ValueError(
42
+ f"No default config for adapter '{adapter_type}': {source_config}\n"
43
+ f"Either provide --config or create {source_config}"
44
+ )
45
+
46
+ # Copy config (idempotent - overwrites if exists)
47
+ dest_config = backend_dir / "config.yaml"
48
+ shutil.copy(source_config, dest_config)
49
+ dest_config.chmod(0o600)
50
+
51
+ return dest_config
52
+
53
+
54
+ def get_backend_config_path(adapter_type: str) -> Path:
55
+ """Get path to backend config file.
56
+
57
+ Args:
58
+ adapter_type: Adapter type (e.g., "litellm")
59
+
60
+ Returns:
61
+ Path to config file (may not exist yet)
62
+ """
63
+ return get_forge_home() / "backends" / adapter_type / "config.yaml"
64
+
65
+
66
+ def is_backend_config_outdated(adapter_type: str) -> bool:
67
+ """Check if installed backend config differs from the default.
68
+
69
+ Compares SHA256 digests of the installed config and the default template.
70
+ Returns True if the installed config exists but differs from the default
71
+ (meaning new models or settings may be available).
72
+
73
+ Args:
74
+ adapter_type: Adapter type (e.g., "litellm")
75
+
76
+ Returns:
77
+ True if installed config is outdated, False otherwise.
78
+ """
79
+ installed = get_backend_config_path(adapter_type)
80
+ if not installed.exists():
81
+ return False # No config yet — will be created on first use
82
+
83
+ default = get_defaults_dir() / "backends" / f"{adapter_type}.yaml"
84
+ if not default.exists():
85
+ return False # No default to compare against
86
+
87
+ installed_digest = hashlib.sha256(installed.read_bytes()).hexdigest()
88
+ default_digest = hashlib.sha256(default.read_bytes()).hexdigest()
89
+ return installed_digest != default_digest
@@ -0,0 +1,178 @@
1
+ """Backend registry for Forge backend services.
2
+
3
+ A backend is a service that proxies depend on (e.g., LiteLLM on port 4000).
4
+ The backend registry is stored at:
5
+
6
+ - ~/.forge/backends/index.json
7
+
8
+ This module implements a small, versioned JSON store with atomic writes.
9
+
10
+ Ownership: Forge Backend Manager (`forge backend` CLI).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import logging
17
+ from dataclasses import asdict, dataclass, field
18
+ from pathlib import Path
19
+ from typing import Callable, Literal
20
+
21
+ import dacite
22
+
23
+ from forge.core.paths import get_forge_home
24
+ from forge.core.state import (
25
+ StateCorruptedError,
26
+ atomic_write_json,
27
+ file_lock_for_target,
28
+ )
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ BACKEND_REGISTRY_VERSION = 1
33
+ BACKENDS_DIR = "backends"
34
+ BACKEND_INDEX_FILENAME = "index.json"
35
+
36
+ CLI_LOCK_TIMEOUT_S = 5.0
37
+
38
+
39
+ from forge.core.process import is_pid_alive as is_pid_alive # noqa: E402, F401 # re-export
40
+
41
+
42
+ class BackendRegistryCorruptedError(StateCorruptedError):
43
+ """Raised when the backend registry cannot be parsed."""
44
+
45
+ pass
46
+
47
+
48
+ @dataclass
49
+ class BackendInstance:
50
+ """A backend instance (used both in registry and at runtime).
51
+
52
+ Timestamps are stored as ISO8601 strings.
53
+ """
54
+
55
+ backend_id: str # e.g., "litellm-4000"
56
+ adapter_type: str # e.g., "litellm"
57
+ port: int
58
+ pid: int | None = None
59
+ status: Literal["healthy", "unhealthy", "stopped", "unknown"] = "unknown"
60
+ created_at: str | None = None
61
+
62
+
63
+ @dataclass
64
+ class BackendRegistry:
65
+ """Backend registry file format."""
66
+
67
+ version: int = BACKEND_REGISTRY_VERSION
68
+ backends: dict[str, BackendInstance] = field(default_factory=dict)
69
+
70
+
71
+ def get_backend_registry_path() -> Path:
72
+ """Return the full path to the backend registry file."""
73
+
74
+ return get_forge_home() / BACKENDS_DIR / BACKEND_INDEX_FILENAME
75
+
76
+
77
+ class BackendRegistryStore:
78
+ """Manage the backend registry at ~/.forge/backends/index.json.
79
+
80
+ Error handling:
81
+ - Missing file: returns empty registry (self-healing)
82
+ - Corrupted file: raises BackendRegistryCorruptedError
83
+ """
84
+
85
+ def __init__(self, registry_path: Path | None = None) -> None:
86
+ self._registry_path = registry_path or get_backend_registry_path()
87
+
88
+ @property
89
+ def registry_path(self) -> Path:
90
+ return self._registry_path
91
+
92
+ def exists(self) -> bool:
93
+ return self._registry_path.is_file()
94
+
95
+ def read(self) -> BackendRegistry:
96
+ if not self.exists():
97
+ return BackendRegistry()
98
+
99
+ try:
100
+ with open(self._registry_path, encoding="utf-8") as f:
101
+ data = json.load(f)
102
+ except json.JSONDecodeError as e:
103
+ raise BackendRegistryCorruptedError(str(self._registry_path), f"invalid JSON: {e}")
104
+ except OSError as e:
105
+ raise BackendRegistryCorruptedError(str(self._registry_path), f"read error: {e}")
106
+
107
+ version = data.get("version")
108
+ if version is None:
109
+ raise BackendRegistryCorruptedError(str(self._registry_path), "missing version field")
110
+ if version != BACKEND_REGISTRY_VERSION:
111
+ raise BackendRegistryCorruptedError(
112
+ str(self._registry_path),
113
+ f"incompatible version {version} (this Forge expects {BACKEND_REGISTRY_VERSION}). "
114
+ f"Delete this file and retry.",
115
+ )
116
+
117
+ try:
118
+ return dacite.from_dict(
119
+ data_class=BackendRegistry,
120
+ data=data,
121
+ config=dacite.Config(strict=True),
122
+ )
123
+ except (dacite.DaciteError, TypeError, KeyError) as e:
124
+ raise BackendRegistryCorruptedError(str(self._registry_path), f"deserialization error: {e}")
125
+
126
+ def write(self, registry: BackendRegistry) -> None:
127
+ data = asdict(registry)
128
+ atomic_write_json(self._registry_path, data)
129
+
130
+ def update(self, *, timeout_s: float, mutate: Callable[[BackendRegistry], None]) -> BackendRegistry:
131
+ """Update registry via a locked read-modify-write cycle."""
132
+
133
+ with file_lock_for_target(target_path=self._registry_path, timeout_s=timeout_s):
134
+ registry = self.read()
135
+ mutate(registry)
136
+ self.write(registry)
137
+ return registry
138
+
139
+ def prune_dead_pids(self, *, timeout_s: float = CLI_LOCK_TIMEOUT_S) -> list[str]:
140
+ """Remove registry entries whose Forge-spawned pid is no longer running.
141
+
142
+ Definition of stale (normative):
143
+ - Only entries with pid != None are considered (Forge-spawned backends).
144
+ - Entries with pid == None are never auto-pruned.
145
+
146
+ Returns:
147
+ List of backend IDs removed from the registry.
148
+ """
149
+
150
+ with file_lock_for_target(target_path=self._registry_path, timeout_s=timeout_s):
151
+ registry = self.read()
152
+
153
+ stale_ids: list[str] = []
154
+ for backend_id, entry in list(registry.backends.items()):
155
+ if entry.pid is None:
156
+ continue
157
+ if not is_pid_alive(entry.pid):
158
+ del registry.backends[backend_id]
159
+ stale_ids.append(backend_id)
160
+
161
+ if stale_ids:
162
+ self.write(registry)
163
+
164
+ return stale_ids
165
+
166
+ def list_backends(self) -> list[BackendInstance]:
167
+ """List all backends (prunes dead PIDs first).
168
+
169
+ Returns:
170
+ List of backend instances, ordered by creation time (oldest first).
171
+ """
172
+
173
+ self.prune_dead_pids()
174
+ registry = self.read()
175
+
176
+ backends = list(registry.backends.values())
177
+ backends.sort(key=lambda x: x.created_at or "")
178
+ return backends
forge/cli/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Forge CLI - Command line interface for Multi-Forge.
2
+
3
+ Entry point: `forge` command (installed via pyproject.toml scripts).
4
+
5
+ Usage:
6
+ forge session start [name] # Create and start a new session
7
+ forge session resume <name> # Resume an existing session (reattach or --fresh for context assembly)
8
+ forge session list # List all sessions
9
+ forge session delete <name> # Delete a session
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .main import main
15
+
16
+ __all__ = ["main"]