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
forge/cli/proxy.py ADDED
@@ -0,0 +1,1821 @@
1
+ """CLI commands for proxy management.
2
+
3
+ Proxies are model routing configurations that define model routing and hyperparameters.
4
+
5
+ Commands:
6
+ - forge proxy list # List proxies
7
+ - forge proxy show <name> # Show proxy contents
8
+ - forge proxy create <template> # Create proxy (starts unless --no-start)
9
+ - forge proxy edit <id> # Edit proxy in $EDITOR
10
+ - forge proxy delete <id> [...] # Delete proxy(ies) and stop server(s)
11
+ - forge proxy start <id> # Start server for existing proxy
12
+ - forge proxy stop <id> # Stop server for proxy
13
+ - forge proxy set <id> k=v # Set single value
14
+ - forge proxy clean # Clean up stale proxies
15
+ - forge proxy validate <id> # Validate proxy config
16
+ - forge proxy metrics [id] # Show runtime metrics for a running proxy
17
+ - forge proxy template list # List available templates
18
+ - forge proxy template show <n> # Show template YAML
19
+ - forge proxy template edit <n> # Customize a template (copy-on-first-edit)
20
+ - forge proxy template reset <n># Reset to built-in default
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ import shutil
27
+ import signal
28
+ import subprocess
29
+ import sys
30
+ import tempfile
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Any, Literal
34
+
35
+ import click
36
+ from rich.console import Console
37
+ from rich.syntax import Syntax
38
+ from rich.table import Table
39
+
40
+ from forge.cli.proxy_costs import costs_cmd
41
+ from forge.config.loader import (
42
+ get_proxy_file_path,
43
+ get_template_path,
44
+ get_user_template_path,
45
+ is_user_template,
46
+ list_template_names,
47
+ load_config,
48
+ load_proxy_instance_config,
49
+ read_shipped_template,
50
+ read_template,
51
+ shipped_template_exists,
52
+ template_exists,
53
+ validate_template_name,
54
+ )
55
+ from forge.core.paths import display_path
56
+ from forge.core.process import find_pid_by_port
57
+ from forge.proxy.proxies import (
58
+ CLI_LOCK_TIMEOUT_S,
59
+ ProxyEntry,
60
+ ProxyRegistry,
61
+ ProxyRegistryCorruptedError,
62
+ ProxyRegistryStore,
63
+ is_pid_alive,
64
+ )
65
+ from forge.proxy.proxy_orchestrator import (
66
+ ProxyStartError,
67
+ TierOverrideOptions,
68
+ check_proxy_health,
69
+ create_proxy_file,
70
+ prune_stale_proxies,
71
+ start_proxy,
72
+ )
73
+
74
+
75
+ def _infer_proxy_source(entry: ProxyEntry) -> str:
76
+ """Derive display source from pid + status (no schema change needed)."""
77
+ if entry.pid is not None:
78
+ return "managed" if is_pid_alive(entry.pid) else "stale"
79
+ if entry.status == "healthy":
80
+ return "adopted"
81
+ return entry.status or "-"
82
+
83
+
84
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
85
+ def proxy() -> None:
86
+ """Manage proxies (model routing configurations).
87
+
88
+ \b
89
+ Examples:
90
+ forge proxy create openrouter-gemini # Create proxy from template
91
+ forge proxy list # List all proxies
92
+ forge proxy show my-proxy # Show proxy details
93
+ """
94
+
95
+
96
+ proxy.add_command(costs_cmd)
97
+
98
+
99
+ def _clear_workflow_template_cache() -> None:
100
+ """Invalidate workflow routing template metadata after template edits."""
101
+ try:
102
+ from forge.review.routing import clear_template_cache
103
+
104
+ clear_template_cache()
105
+ except Exception:
106
+ pass
107
+
108
+
109
+ # --- List ---
110
+
111
+
112
+ @proxy.command("list")
113
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
114
+ def list_cmd(as_json: bool) -> None:
115
+ """List proxies."""
116
+ from forge.core.ops.context import ExecutionContext
117
+ from forge.core.ops.proxy import list_proxies as list_proxies_op
118
+ from forge.core.ops.session import ForgeOpError
119
+
120
+ console = Console(width=200)
121
+
122
+ # Prune stale proxies before listing (CLI-only side effect)
123
+ try:
124
+ prune_stale_proxies()
125
+ except Exception:
126
+ pass # Best-effort pruning
127
+
128
+ try:
129
+ ctx = ExecutionContext.from_cwd()
130
+ result = list_proxies_op(ctx=ctx)
131
+ except ForgeOpError as e:
132
+ if as_json:
133
+ import json
134
+
135
+ click.echo(json.dumps({"error": str(e)}, indent=2), err=True)
136
+ else:
137
+ console.print(f"[red]Error:[/red] {e}")
138
+ sys.exit(1)
139
+
140
+ if as_json:
141
+ import json
142
+
143
+ data = []
144
+ for item in result.proxies:
145
+ source = _infer_proxy_source(item.entry)
146
+ data.append(
147
+ {
148
+ "proxy_id": item.proxy_id,
149
+ "template": item.entry.template,
150
+ "base_url": item.entry.base_url,
151
+ "port": item.entry.port,
152
+ "pid": item.entry.pid,
153
+ "status": item.entry.status,
154
+ "source": source,
155
+ }
156
+ )
157
+ click.echo(json.dumps(data, indent=2, default=str))
158
+ return
159
+
160
+ if not result.proxies:
161
+ console.print("No proxies found.")
162
+ console.print("\n[dim]Tip: Run 'forge proxy template list' to see available templates.[/dim]")
163
+ return
164
+
165
+ table = Table(title="Forge Proxies")
166
+ table.add_column("PROXY ID", style="cyan")
167
+ table.add_column("TEMPLATE")
168
+ table.add_column("BASE URL")
169
+ table.add_column("PORT", justify="right")
170
+ table.add_column("PID", justify="right")
171
+ table.add_column("STATUS")
172
+ table.add_column("SOURCE", style="dim")
173
+
174
+ for item in result.proxies:
175
+ source = _infer_proxy_source(item.entry)
176
+ table.add_row(
177
+ item.proxy_id,
178
+ item.entry.template,
179
+ item.entry.base_url,
180
+ str(item.entry.port),
181
+ str(item.entry.pid) if item.entry.pid is not None else "-",
182
+ item.entry.status or "-",
183
+ source,
184
+ )
185
+
186
+ console.print(table)
187
+
188
+ # Show backend status (best-effort, prunes dead PIDs)
189
+ try:
190
+ from forge.backend.registry import BackendRegistryStore
191
+
192
+ backend_store = BackendRegistryStore()
193
+ backends = backend_store.list_backends() # Prunes dead PIDs
194
+ if backends:
195
+ console.print("\n[bold]Backends:[/bold]")
196
+ for backend in backends:
197
+ status_color = "green" if backend.status == "healthy" else "yellow"
198
+ console.print(
199
+ f" [{status_color}]{backend.backend_id}[/{status_color}] "
200
+ f"(port {backend.port}, pid {backend.pid or '-'})"
201
+ )
202
+ except Exception:
203
+ # Best-effort - don't fail proxy list if backend registry has issues
204
+ pass
205
+
206
+ console.print("\n[dim]Tip: To use a proxy:[/dim]")
207
+ console.print(" forge claude start --proxy <proxy_id>")
208
+ console.print(" forge session start <name> --proxy <proxy_id>")
209
+
210
+
211
+ # --- Show ---
212
+
213
+
214
+ @proxy.command("show")
215
+ @click.argument("proxy_id")
216
+ @click.option("--raw", is_flag=True, help="Output raw YAML without syntax highlighting")
217
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON")
218
+ def show_cmd(proxy_id: str, raw: bool, as_json: bool) -> None:
219
+ """Show proxy configuration.
220
+
221
+ \b
222
+ Examples:
223
+ forge proxy show my-proxy
224
+ """
225
+ console = Console(width=200)
226
+
227
+ from forge.core.ops.context import ExecutionContext
228
+ from forge.core.ops.proxy import show_proxy as show_proxy_op
229
+ from forge.core.ops.session import ForgeOpError
230
+
231
+ try:
232
+ ctx = ExecutionContext.from_cwd()
233
+ result = show_proxy_op(ctx=ctx, proxy_id=proxy_id)
234
+ except ForgeOpError as e:
235
+ console.print(f"[red]Error:[/red] {e}")
236
+ console.print("\n[dim]Tip: Use 'forge proxy template show <name>' to show a template.[/dim]")
237
+ sys.exit(1)
238
+
239
+ content = result.config_yaml or ""
240
+ path = get_proxy_file_path(proxy_id)
241
+ entry = result.entry
242
+
243
+ if as_json:
244
+ import json
245
+
246
+ data = {
247
+ "proxy_id": proxy_id,
248
+ "config_yaml": content,
249
+ "entry": (
250
+ {
251
+ "template": entry.template,
252
+ "base_url": entry.base_url,
253
+ "port": entry.port,
254
+ "pid": entry.pid,
255
+ "status": entry.status,
256
+ }
257
+ if entry
258
+ else None
259
+ ),
260
+ }
261
+ click.echo(json.dumps(data, indent=2, default=str))
262
+ return
263
+
264
+ if raw:
265
+ console.print(content)
266
+ else:
267
+ syntax = Syntax(content, "yaml", theme="monokai", line_numbers=True)
268
+ console.print(f"[bold]Proxy:[/bold] {proxy_id}")
269
+ console.print(f"[bold]Path:[/bold] {display_path(path)}")
270
+
271
+ if entry is not None:
272
+ status_color = "green" if entry.status == "healthy" else "dim"
273
+ console.print(f"[bold]Status:[/bold] [{status_color}]{entry.status or 'unknown'}[/{status_color}]")
274
+ if entry.pid:
275
+ console.print(f"[bold]PID:[/bold] {entry.pid}")
276
+
277
+ from forge.core.logging import find_latest_log
278
+
279
+ latest_log = find_latest_log("proxy", "proxy.*.log")
280
+ if latest_log:
281
+ console.print(f"[bold]Log:[/bold] {display_path(latest_log)}")
282
+
283
+ console.print()
284
+ console.print(syntax)
285
+
286
+
287
+ # --- Create (replaces acquire + clone) ---
288
+
289
+
290
+ @proxy.command("create")
291
+ @click.argument("template")
292
+ @click.option("--name", "-n", help="Name for the proxy (defaults to template name)")
293
+ @click.option("--port", "-p", type=int, help="Port number (defaults to template's default)")
294
+ @click.option("--no-start", is_flag=True, help="Create config only, don't start the server")
295
+ @click.option("--json", "json_output", is_flag=True, help="Output as JSON")
296
+ @click.option("--host", type=str, default="localhost", help="Host to bind server to")
297
+ @click.option("--base-url", "upstream_url", type=str, help="Upstream LiteLLM base URL (overrides env var)")
298
+ # Per-tier reasoning effort overrides
299
+ @click.option("--haiku-reasoning", type=str, help="Override reasoning_effort for haiku tier")
300
+ @click.option("--sonnet-reasoning", type=str, help="Override reasoning_effort for sonnet tier")
301
+ @click.option("--opus-reasoning", type=str, help="Override reasoning_effort for opus tier")
302
+ # Per-tier temperature overrides
303
+ @click.option("--haiku-temperature", type=float, help="Override temperature for haiku tier")
304
+ @click.option("--sonnet-temperature", type=float, help="Override temperature for sonnet tier")
305
+ @click.option("--opus-temperature", type=float, help="Override temperature for opus tier")
306
+ @click.option("--smoke-test", is_flag=True, help="Send a test LLM request after start to verify upstream")
307
+ def create_cmd(
308
+ template: str,
309
+ name: str | None,
310
+ port: int | None,
311
+ no_start: bool,
312
+ json_output: bool,
313
+ host: str,
314
+ upstream_url: str | None,
315
+ haiku_reasoning: str | None,
316
+ sonnet_reasoning: str | None,
317
+ opus_reasoning: str | None,
318
+ haiku_temperature: float | None,
319
+ sonnet_temperature: float | None,
320
+ opus_temperature: float | None,
321
+ smoke_test: bool,
322
+ ) -> None:
323
+ """Create a proxy from a template and start it.
324
+
325
+ \b
326
+ Examples:
327
+ forge proxy create openrouter-gemini # Create and start server
328
+ forge proxy create openrouter-gemini --no-start # Create config only
329
+ forge proxy create openrouter-gemini -n my-proxy # Custom name
330
+ forge proxy create openrouter-gemini --opus-reasoning=high # With overrides
331
+ forge proxy create litellm-openai --base-url https://litellm.corp.com # Explicit upstream
332
+ forge proxy create openrouter-openai --smoke-test # Verify upstream after start
333
+ """
334
+ console = Console(width=200)
335
+
336
+ try:
337
+ exists = template_exists(template)
338
+ except ValueError as e:
339
+ console.print(f"[red]Error:[/red] {e}")
340
+ sys.exit(1)
341
+ if not exists:
342
+ console.print(f"[red]Error:[/red] Template '{template}' not found")
343
+ console.print("\n[dim]Tip: Run 'forge proxy template list' to see available templates.[/dim]")
344
+ sys.exit(1)
345
+
346
+ proxy_name = name or template
347
+
348
+ # Preserve the raw user-provided port before default resolution.
349
+ # When calling start_proxy(), we only pass port if the user explicitly
350
+ # provided --port (so the orchestrator can still do template-scoped
351
+ # port scanning for the default create path).
352
+ user_port = port
353
+
354
+ # Get default port from template if not specified
355
+ if port is None:
356
+ cfg = load_config(template=template)
357
+ port = cfg.proxy.default_port
358
+ if not port:
359
+ console.print("[red]Error:[/red] Template has no default_port, use --port")
360
+ sys.exit(1)
361
+
362
+ base_url = f"http://{host}:{port}"
363
+
364
+ tier_overrides = TierOverrideOptions(
365
+ haiku_reasoning_effort=haiku_reasoning,
366
+ sonnet_reasoning_effort=sonnet_reasoning,
367
+ opus_reasoning_effort=opus_reasoning,
368
+ haiku_temperature=haiku_temperature,
369
+ sonnet_temperature=sonnet_temperature,
370
+ opus_temperature=opus_temperature,
371
+ )
372
+ has_overrides = any(
373
+ [
374
+ haiku_reasoning,
375
+ sonnet_reasoning,
376
+ opus_reasoning,
377
+ haiku_temperature,
378
+ sonnet_temperature,
379
+ opus_temperature,
380
+ ]
381
+ )
382
+
383
+ if not no_start:
384
+ # Check if proxy already exists when user provided --name
385
+ if name is not None:
386
+ proxy_path = get_proxy_file_path(proxy_name)
387
+ if proxy_path.exists():
388
+ console.print(f"[red]Error:[/red] Proxy '{proxy_name}' already exists")
389
+ console.print("[dim]Tip: Use 'forge proxy start' to start it, or 'forge proxy delete' first.[/dim]")
390
+ sys.exit(1)
391
+
392
+ if not json_output:
393
+ console.print(f"Creating proxy [cyan]{proxy_name}[/cyan] from '{template}'...")
394
+
395
+ # Only pass proxy_id/port when the user explicitly provided --name/--port.
396
+ # Otherwise let the orchestrator use its template-scoped defaults (reuse any
397
+ # healthy proxy for the template, scan for available ports).
398
+ explicit_proxy_id = proxy_name if name is not None else None
399
+ explicit_port = user_port
400
+
401
+ try:
402
+ prune_stale_proxies()
403
+ result = start_proxy(
404
+ template=template,
405
+ host=host,
406
+ proxy_id=explicit_proxy_id,
407
+ port=explicit_port,
408
+ tier_overrides=tier_overrides if has_overrides else None,
409
+ upstream_base_url=upstream_url,
410
+ )
411
+ except ProxyRegistryCorruptedError as e:
412
+ console.print(f"[red]Error:[/red] {e}")
413
+ sys.exit(1)
414
+ except ProxyStartError as e:
415
+ console.print(f"[red]Failed to start server:[/red] {e}")
416
+ err_str = str(e)
417
+ if "dependency backend" not in err_str and "upstream URL" not in err_str:
418
+ console.print("\n[dim]Tip: Use --no-start to create the config without starting the server:[/dim]")
419
+ console.print(f" forge proxy create {template} --name {proxy_name} --no-start")
420
+ sys.exit(1)
421
+
422
+ proxy_entry = result.proxy
423
+
424
+ if json_output:
425
+ import json
426
+
427
+ print(
428
+ json.dumps(
429
+ {
430
+ "proxy_id": proxy_entry.proxy_id,
431
+ "template": proxy_entry.template,
432
+ "base_url": proxy_entry.base_url,
433
+ "port": proxy_entry.port,
434
+ "pid": proxy_entry.pid,
435
+ "status": proxy_entry.status,
436
+ "source": result.source,
437
+ }
438
+ )
439
+ )
440
+ else:
441
+ if result.source == "reuse":
442
+ prefix = "Reusing existing"
443
+ elif result.source == "adopt":
444
+ prefix = f"Found existing process on port {proxy_entry.port}, registered as"
445
+ else:
446
+ prefix = "Started"
447
+
448
+ console.print(f"[green]{prefix}[/green] proxy [cyan]{proxy_entry.proxy_id}[/cyan]")
449
+ console.print(f" URL: {proxy_entry.base_url}")
450
+ console.print(f" PID: {proxy_entry.pid or '-'}")
451
+
452
+ # Show log location (skip for adopted — no Forge-owned log exists)
453
+ if result.source != "adopt":
454
+ from forge.core.logging import find_latest_log
455
+
456
+ latest_log = find_latest_log("proxy", "proxy.*.log")
457
+ if latest_log:
458
+ console.print(f" Log: {display_path(latest_log)}")
459
+
460
+ if result.source == "adopt":
461
+ console.print(
462
+ f"\n[dim]Tip: This proxy was not started by Forge. "
463
+ f"Logs may be unavailable.\n"
464
+ f" Delete and recreate for full Forge management: "
465
+ f"forge proxy delete {proxy_entry.proxy_id} && "
466
+ f"forge proxy create {proxy_entry.template}[/dim]"
467
+ )
468
+
469
+ if smoke_test:
470
+ from forge.proxy.proxy_orchestrator import smoke_test_proxy
471
+
472
+ if not json_output:
473
+ console.print("\n[dim]Smoke testing upstream LLM...[/dim]")
474
+
475
+ ok, detail = smoke_test_proxy(base_url=proxy_entry.base_url)
476
+
477
+ if json_output:
478
+ import json
479
+
480
+ print(json.dumps({"smoke_test": {"passed": ok, "detail": detail}}))
481
+ elif ok:
482
+ console.print(f"[green]Smoke test passed[/green]: {detail[:80]}")
483
+ else:
484
+ console.print(f"[red]Smoke test failed[/red]: {detail}")
485
+ sys.exit(1)
486
+ else:
487
+ proxy_path = get_proxy_file_path(proxy_name)
488
+ if proxy_path.exists():
489
+ console.print(f"[red]Error:[/red] Proxy '{proxy_name}' already exists")
490
+ console.print("[dim]Tip: Use 'forge proxy edit' to modify it, or 'forge proxy delete' first.[/dim]")
491
+ sys.exit(1)
492
+
493
+ try:
494
+ created_path = create_proxy_file(
495
+ proxy_id=proxy_name,
496
+ template=template,
497
+ base_url=base_url,
498
+ port=port,
499
+ cli_overrides=tier_overrides if has_overrides else None,
500
+ upstream_base_url=upstream_url,
501
+ )
502
+ except Exception as e:
503
+ console.print(f"[red]Error:[/red] Failed to create proxy: {e}")
504
+ sys.exit(1)
505
+
506
+ # Register the proxy in index.json so it appears in `forge proxy list`
507
+ from forge.core.state import now_iso
508
+
509
+ store = ProxyRegistryStore()
510
+ now = now_iso()
511
+ proxy_entry = ProxyEntry(
512
+ proxy_id=proxy_name,
513
+ template=template,
514
+ base_url=base_url,
515
+ port=port,
516
+ pid=None,
517
+ created_at=now,
518
+ last_seen_at=None,
519
+ status="configured",
520
+ )
521
+
522
+ def _register_proxy(registry: ProxyRegistry) -> None:
523
+ registry.proxies[proxy_name] = proxy_entry
524
+
525
+ try:
526
+ store.update(timeout_s=CLI_LOCK_TIMEOUT_S, mutate=_register_proxy)
527
+ except Exception as e:
528
+ try:
529
+ shutil.rmtree(created_path.parent)
530
+ except OSError as cleanup_error:
531
+ console.print(
532
+ f"[yellow]Warning:[/yellow] Could not remove unregistered proxy directory: {cleanup_error}"
533
+ )
534
+ console.print(f"[red]Error:[/red] Could not register proxy: {e}")
535
+ sys.exit(1)
536
+
537
+ console.print(f"[green]Created[/green] proxy [cyan]{proxy_name}[/cyan] from '{template}'")
538
+ console.print(f" Path: {display_path(created_path)}")
539
+ console.print(f" Port: {port}")
540
+ console.print("\n[dim]Next steps:[/dim]")
541
+ console.print(f" forge proxy edit {proxy_name} # Customize config")
542
+ console.print(f" forge proxy start {proxy_name} # Start server")
543
+
544
+
545
+ # --- Start / Stop ---
546
+
547
+
548
+ StopProxyOutcome = Literal["stopped", "already_stopped", "adopted_left_running", "error"]
549
+
550
+
551
+ def _stop_proxy_process(console: Console, entry: ProxyEntry, *, kill_adopted: bool = False) -> StopProxyOutcome:
552
+ """Kill the proxy process if Forge owns it (known PID).
553
+
554
+ Adopted proxies (pid=None) are NOT killed by default — Forge didn't start
555
+ them and shouldn't stop them. Pass kill_adopted=True to override.
556
+
557
+ Returns:
558
+ "stopped": A process was killed.
559
+ "already_stopped": No live process remained; registry state should be cleared.
560
+ "error": Refused or failed to stop the process.
561
+ """
562
+ # Known PID — Forge started this process, safe to kill
563
+ if entry.pid is not None and is_pid_alive(entry.pid):
564
+ try:
565
+ os.kill(entry.pid, signal.SIGTERM)
566
+ console.print(f"Stopped server (pid {entry.pid})")
567
+ return "stopped"
568
+ except (ProcessLookupError, PermissionError) as e:
569
+ console.print(f"[yellow]Warning:[/yellow] Could not stop server: {e}")
570
+ return "error"
571
+
572
+ # PID unknown (adopted) — not our process to kill
573
+ if entry.pid is None:
574
+ if not kill_adopted:
575
+ console.print(
576
+ f"[dim]Adopted proxy on port {entry.port} (not started by Forge, leaving process alone)[/dim]"
577
+ )
578
+ return "adopted_left_running"
579
+
580
+ # Explicit kill_adopted: find by port with health guard
581
+ discovered_pid = find_pid_by_port(entry.port)
582
+ if discovered_pid is None:
583
+ console.print(f"[dim]No process found on port {entry.port}[/dim]")
584
+ return "already_stopped"
585
+
586
+ if not check_proxy_health(
587
+ base_url=entry.base_url,
588
+ expected_template=entry.template,
589
+ timeout_s=1.0,
590
+ expected_proxy_id=entry.proxy_id,
591
+ ):
592
+ console.print(
593
+ f"[yellow]Warning:[/yellow] Process on port {entry.port} doesn't match "
594
+ f"proxy '{entry.proxy_id}' (template '{entry.template}'), skipping kill"
595
+ )
596
+ return "error"
597
+
598
+ try:
599
+ os.kill(discovered_pid, signal.SIGTERM)
600
+ console.print(f"Stopped server on port {entry.port} (discovered pid {discovered_pid})")
601
+ return "stopped"
602
+ except (ProcessLookupError, PermissionError) as e:
603
+ console.print(f"[yellow]Warning:[/yellow] Could not stop process on port {entry.port}: {e}")
604
+ return "error"
605
+
606
+ # PID known but process is dead
607
+ console.print(f"[dim]Process pid {entry.pid} is not running[/dim]")
608
+ return "already_stopped"
609
+
610
+
611
+ @proxy.command("start")
612
+ @click.argument("proxy_id")
613
+ @click.option("--smoke-test", is_flag=True, help="Send a test LLM request after start to verify upstream")
614
+ def start_cmd(proxy_id: str, smoke_test: bool) -> None:
615
+ """Start server for an existing proxy.
616
+
617
+ \b
618
+ Example:
619
+ forge proxy start my-proxy
620
+ forge proxy start my-proxy --smoke-test
621
+ """
622
+ console = Console(width=200)
623
+
624
+ proxy_path = get_proxy_file_path(proxy_id)
625
+ if not proxy_path.exists():
626
+ console.print(f"[red]Error:[/red] Proxy '{proxy_id}' not found at {display_path(proxy_path)}")
627
+ console.print("\n[dim]Create one first:[/dim]")
628
+ console.print(f" forge proxy create <template> --name {proxy_id}")
629
+ sys.exit(1)
630
+
631
+ config = load_proxy_instance_config(proxy_id)
632
+ if config is None:
633
+ console.print(f"[red]Error:[/red] Failed to load proxy config for '{proxy_id}'")
634
+ sys.exit(1)
635
+
636
+ try:
637
+ prune_stale_proxies()
638
+ result = start_proxy(
639
+ template=config.template,
640
+ host="localhost",
641
+ proxy_id=proxy_id,
642
+ port=config.port,
643
+ skip_proxy_file=True,
644
+ )
645
+ except ProxyRegistryCorruptedError as e:
646
+ console.print(f"[red]Error:[/red] {e}")
647
+ sys.exit(1)
648
+ except ProxyStartError as e:
649
+ console.print(f"[red]Error:[/red] {e}")
650
+ sys.exit(1)
651
+
652
+ proxy_entry = result.proxy
653
+ if result.source == "reuse":
654
+ console.print(f"Server already running for [cyan]{proxy_entry.proxy_id}[/cyan]")
655
+ else:
656
+ console.print(f"[green]Started[/green] server for [cyan]{proxy_entry.proxy_id}[/cyan]")
657
+ console.print(f" URL: {proxy_entry.base_url}")
658
+ console.print(f" PID: {proxy_entry.pid}")
659
+
660
+ if smoke_test:
661
+ from forge.proxy.proxy_orchestrator import smoke_test_proxy
662
+
663
+ console.print("\n[dim]Smoke testing upstream LLM...[/dim]")
664
+ ok, detail = smoke_test_proxy(base_url=proxy_entry.base_url)
665
+ if ok:
666
+ console.print(f"[green]Smoke test passed[/green]: {detail[:80]}")
667
+ else:
668
+ console.print(f"[red]Smoke test failed[/red]: {detail}")
669
+ sys.exit(1)
670
+
671
+
672
+ @proxy.command("stop")
673
+ @click.argument("proxy_id")
674
+ @click.option("--force", "-f", is_flag=True, help="Stop even if other proxies share the port")
675
+ @click.option("--kill-adopted", is_flag=True, help="Terminate adopted processes (not started by Forge)")
676
+ def stop_cmd(proxy_id: str, force: bool, kill_adopted: bool) -> None:
677
+ """Stop server for a proxy (keeps the proxy config).
678
+
679
+ \b
680
+ Examples:
681
+ forge proxy stop my-proxy
682
+ forge proxy stop my-proxy --force # Stop even if port is shared
683
+ forge proxy stop my-proxy --kill-adopted # Kill adopted process
684
+ """
685
+ console = Console(width=200)
686
+ store = ProxyRegistryStore()
687
+
688
+ try:
689
+ registry = store.read()
690
+ except ProxyRegistryCorruptedError as e:
691
+ console.print(f"[red]Error:[/red] {e}")
692
+ sys.exit(1)
693
+
694
+ entry = registry.proxies.get(proxy_id)
695
+ if entry is None:
696
+ console.print(f"[red]Error:[/red] Proxy '{proxy_id}' not found in registry")
697
+ console.print("[dim]The proxy may not be running.[/dim]")
698
+ console.print("[dim]Tip: Run 'forge proxy list' to see configured proxies.[/dim]")
699
+ sys.exit(1)
700
+
701
+ # Shared-port policy: refuse if other proxies share the same port
702
+ if not force:
703
+ sharing = _live_proxy_ids_on_port(registry, proxy_id, entry.port)
704
+ if sharing:
705
+ names = ", ".join(sharing[:5])
706
+ console.print(f"[red]Error:[/red] Cannot stop: other proxies share port {entry.port}: {names}")
707
+ console.print("[dim]Tip: Use --force to stop anyway, or delete individual proxies.[/dim]")
708
+ sys.exit(1)
709
+
710
+ outcome = _stop_proxy_process(console, entry, kill_adopted=kill_adopted)
711
+
712
+ if outcome == "stopped":
713
+ console.print(f"[green]Stopped[/green] server for [cyan]{proxy_id}[/cyan]")
714
+ elif outcome == "already_stopped":
715
+ console.print(f"[green]Cleared[/green] stale running state for [cyan]{proxy_id}[/cyan]")
716
+ elif outcome == "adopted_left_running":
717
+ # Process still alive (not ours to kill) — don't mark as "stopped"
718
+ console.print(f"[green]Detached[/green] [cyan]{proxy_id}[/cyan] from registry (process still running)")
719
+
720
+ def _detach(reg: ProxyRegistry) -> None:
721
+ reg.proxies.pop(proxy_id, None)
722
+
723
+ try:
724
+ store.update(timeout_s=CLI_LOCK_TIMEOUT_S, mutate=_detach)
725
+ except Exception as e:
726
+ console.print(f"[yellow]Warning:[/yellow] Could not update registry: {e}")
727
+ return
728
+ else:
729
+ # _stop_proxy_process already printed the reason
730
+ return
731
+
732
+ # Update registry: mark this proxy AND all siblings on the same port as stopped.
733
+ # When --force bypasses the shared-port guard, siblings become stale.
734
+ stopped_siblings: list[str] = []
735
+
736
+ def clear_pid(reg: ProxyRegistry) -> None:
737
+ nonlocal stopped_siblings
738
+ if proxy_id in reg.proxies:
739
+ reg.proxies[proxy_id].pid = None
740
+ reg.proxies[proxy_id].status = "stopped"
741
+ # Mark siblings on the same port as stopped too
742
+ for eid, e in reg.proxies.items():
743
+ if eid != proxy_id and e.port == entry.port and e.status != "stopped":
744
+ e.pid = None
745
+ e.status = "stopped"
746
+ stopped_siblings.append(eid)
747
+
748
+ try:
749
+ store.update(timeout_s=CLI_LOCK_TIMEOUT_S, mutate=clear_pid)
750
+ except Exception as e:
751
+ console.print(f"[yellow]Warning:[/yellow] Could not update registry: {e}")
752
+
753
+ if stopped_siblings:
754
+ console.print(
755
+ f"[dim]Also marked as stopped (shared port {entry.port}): " f"{', '.join(stopped_siblings)}[/dim]"
756
+ )
757
+
758
+
759
+ # --- Edit ---
760
+
761
+
762
+ @proxy.command("edit")
763
+ @click.argument("proxy_id")
764
+ def edit_cmd(proxy_id: str) -> None:
765
+ """Open proxy configuration in $EDITOR.
766
+
767
+ Uses a temp file for safety - changes are validated before applying.
768
+ """
769
+ console = Console(width=200)
770
+
771
+ proxy_path = get_proxy_file_path(proxy_id)
772
+ if not proxy_path.exists():
773
+ console.print(f"[red]Error:[/red] Proxy '{proxy_id}' not found at {display_path(proxy_path)}")
774
+ sys.exit(1)
775
+
776
+ editor = os.environ.get("EDITOR", "vim")
777
+
778
+ if not shutil.which(editor):
779
+ console.print(f"[red]Error:[/red] Editor '{editor}' not found. Set $EDITOR to an available editor.")
780
+ sys.exit(1)
781
+
782
+ # Copy to temp file for safe editing
783
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
784
+ tmp.write(proxy_path.read_text())
785
+ tmp_path = Path(tmp.name)
786
+
787
+ success = False
788
+ try:
789
+ result = subprocess.run([editor, str(tmp_path)])
790
+ if result.returncode != 0:
791
+ console.print(f"[red]Error:[/red] Editor exited with code {result.returncode}")
792
+ console.print(f"Your changes are saved at: {display_path(tmp_path)}")
793
+ sys.exit(1)
794
+
795
+ from ruamel.yaml import YAML
796
+
797
+ yaml = YAML()
798
+ try:
799
+ with open(tmp_path) as f:
800
+ edited_data = yaml.load(f)
801
+ except Exception as e:
802
+ console.print(f"[red]Error:[/red] Invalid YAML: {e}")
803
+ console.print(f"Your changes are saved at: {display_path(tmp_path)}")
804
+ sys.exit(1)
805
+
806
+ # Validate through ProxyInstanceConfig (catches type errors, invalid values)
807
+ try:
808
+ from forge.config.loader import load_proxy_instance_config_from_dict
809
+
810
+ load_proxy_instance_config_from_dict(edited_data)
811
+ except (ValueError, TypeError, KeyError, AttributeError) as e:
812
+ console.print(f"[red]Error:[/red] Invalid proxy configuration: {e}")
813
+ console.print(f"Your changes are saved at: {display_path(tmp_path)}")
814
+ sys.exit(1)
815
+
816
+ from forge.core.state import atomic_write_text
817
+
818
+ atomic_write_text(proxy_path, tmp_path.read_text(), create_parents=False)
819
+
820
+ success = True
821
+ console.print(f"[green]Updated[/green] proxy '{proxy_id}'")
822
+
823
+ finally:
824
+ if success and tmp_path.exists():
825
+ try:
826
+ tmp_path.unlink()
827
+ except OSError:
828
+ pass
829
+
830
+
831
+ # --- Set ---
832
+
833
+
834
+ @proxy.command("set")
835
+ @click.argument("proxy_id")
836
+ @click.argument("key_value")
837
+ def set_cmd(proxy_id: str, key_value: str) -> None:
838
+ """Set a single value in proxy configuration.
839
+
840
+ Supports dot notation for nested keys.
841
+
842
+ \b
843
+ Examples:
844
+ forge proxy set my-proxy default_tier=opus
845
+ forge proxy set my-proxy tier_overrides.opus.reasoning_effort=high
846
+ forge proxy set my-proxy port=8085
847
+ """
848
+ console = Console(width=200)
849
+
850
+ if "=" not in key_value:
851
+ console.print(f"[red]Error:[/red] Expected format: key=value (got: {key_value})")
852
+ sys.exit(1)
853
+
854
+ key, value = key_value.split("=", 1)
855
+
856
+ proxy_path = get_proxy_file_path(proxy_id)
857
+ if not proxy_path.exists():
858
+ console.print(f"[red]Error:[/red] Proxy '{proxy_id}' not found at {display_path(proxy_path)}")
859
+ sys.exit(1)
860
+
861
+ from ruamel.yaml import YAML
862
+
863
+ yaml = YAML()
864
+ yaml.preserve_quotes = True
865
+ with open(proxy_path) as f:
866
+ data = yaml.load(f)
867
+
868
+ keys = key.split(".")
869
+ current = data
870
+ for k in keys[:-1]:
871
+ if k not in current:
872
+ current[k] = {}
873
+ current = current[k]
874
+
875
+ final_key = keys[-1]
876
+ coerced_value: Any
877
+ try:
878
+ if value.lower() in ("none", "null"):
879
+ coerced_value = None
880
+ elif final_key in ("port", "proxy_format", "thinking_budget_tokens"):
881
+ coerced_value = int(value)
882
+ elif final_key in ("temperature",) or key in ("costs.caps.per_day", "costs.caps.per_month"):
883
+ coerced_value = float(value)
884
+ elif value.lower() == "true":
885
+ coerced_value = True
886
+ elif value.lower() == "false":
887
+ coerced_value = False
888
+ else:
889
+ coerced_value = value
890
+ except ValueError as e:
891
+ console.print(f"[red]Error:[/red] Invalid value for '{final_key}': {e}")
892
+ sys.exit(1)
893
+
894
+ current[final_key] = coerced_value
895
+
896
+ # Validate the full config before writing (CR-006)
897
+ try:
898
+ from forge.config.loader import load_proxy_instance_config_from_dict
899
+
900
+ load_proxy_instance_config_from_dict(data)
901
+ except (ValueError, TypeError, KeyError, AttributeError) as e:
902
+ console.print(f"[red]Error:[/red] Invalid value — {e}")
903
+ sys.exit(1)
904
+
905
+ import io
906
+
907
+ from forge.core.state import atomic_write_text
908
+
909
+ buf = io.StringIO()
910
+ yaml.dump(data, buf)
911
+ atomic_write_text(proxy_path, buf.getvalue(), create_parents=False)
912
+
913
+ console.print(f"[green]Set[/green] {key}={coerced_value} in proxy '{proxy_id}'")
914
+ if key.startswith("costs."):
915
+ console.print(
916
+ "[dim]Tip: Cost config is read at proxy startup. Restart the proxy for this change to take effect.[/dim]"
917
+ )
918
+
919
+
920
+ # --- Delete ---
921
+
922
+
923
+ def _find_sessions_for_proxy(proxy_id: str, *, port: int | None = None) -> list[str]:
924
+ """Best-effort scan for sessions affected by deleting a proxy. Returns [] on any error.
925
+
926
+ When port is None (non-terminal delete — other entries share the port),
927
+ matches only sessions whose confirmed.started_with_proxy.proxy_id equals the
928
+ target. This avoids false positives from shared-port aliases.
929
+
930
+ When port is provided (terminal delete — server will die), also matches
931
+ sessions bound to ANY alias on that port (by extracting port from the
932
+ session's started_with_proxy.base_url), since all sessions on the port
933
+ will lose connectivity.
934
+ """
935
+ try:
936
+ from urllib.parse import urlparse
937
+
938
+ from forge.session.identity import session_name_from_key
939
+ from forge.session.index import IndexStore
940
+ from forge.session.store import SessionStore
941
+
942
+ matching: list[str] = []
943
+ for key, idx_entry in IndexStore().read().sessions.items():
944
+ name = session_name_from_key(key)
945
+ try:
946
+ store = SessionStore(idx_entry.forge_root or idx_entry.worktree_path, name)
947
+ if not store.exists():
948
+ continue
949
+ state = store.read()
950
+ swp = state.confirmed.started_with_proxy
951
+ if not swp:
952
+ continue
953
+ if swp.proxy_id == proxy_id:
954
+ matching.append(name)
955
+ elif port is not None and swp.base_url:
956
+ # Extract port from session's base_url for host-spelling-agnostic match
957
+ parsed = urlparse(swp.base_url)
958
+ session_port = parsed.port
959
+ if session_port == port:
960
+ matching.append(name)
961
+ except Exception:
962
+ continue
963
+ return matching
964
+ except Exception:
965
+ return []
966
+
967
+
968
+ _ALIVE_STATUSES = frozenset({"healthy", "starting"})
969
+
970
+
971
+ def _all_proxy_ids_on_port(registry: ProxyRegistry, proxy_id: str, port: int) -> list[str]:
972
+ """Return ALL other proxy IDs on the same port (any status).
973
+
974
+ Used for UX: confirmation messages should list every sibling for
975
+ awareness, including configured and stopped entries.
976
+ """
977
+ return sorted(
978
+ entry_id for entry_id, entry in registry.proxies.items() if entry_id != proxy_id and entry.port == port
979
+ )
980
+
981
+
982
+ def _live_proxy_ids_on_port(registry: ProxyRegistry, proxy_id: str, port: int) -> list[str]:
983
+ """Return OTHER proxy IDs that share the same port AND have a live listener.
984
+
985
+ Only includes entries with status in {healthy, starting}. Configured
986
+ (never started) and stopped (listener dead) entries are excluded so
987
+ they don't block stop/delete of a healthy proxy.
988
+ """
989
+ return sorted(
990
+ entry_id
991
+ for entry_id, entry in registry.proxies.items()
992
+ if entry_id != proxy_id and entry.port == port and entry.status in _ALIVE_STATUSES
993
+ )
994
+
995
+
996
+ def _restore_proxy_registry_entry(store: ProxyRegistryStore, entry: ProxyEntry) -> None:
997
+ """Best-effort restore for a registry entry removed before cleanup failed."""
998
+
999
+ def _restore(registry: ProxyRegistry) -> None:
1000
+ registry.proxies.setdefault(entry.proxy_id, entry)
1001
+
1002
+ store.update(timeout_s=CLI_LOCK_TIMEOUT_S, mutate=_restore)
1003
+
1004
+
1005
+ @proxy.command("delete")
1006
+ @click.argument("proxy_ids", nargs=-1)
1007
+ @click.option("--all", "-a", "delete_all", is_flag=True, help="Delete all proxies")
1008
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompts")
1009
+ @click.option("--force", "-f", is_flag=True, hidden=True, help="Deprecated alias for --yes")
1010
+ @click.option("--kill-adopted", is_flag=True, help="Terminate adopted processes during deletion")
1011
+ @click.option("--no-kill", is_flag=True, help="Remove from registry without killing the process")
1012
+ def delete_cmd(
1013
+ proxy_ids: tuple[str, ...], delete_all: bool, yes: bool, force: bool, kill_adopted: bool, no_kill: bool
1014
+ ) -> None:
1015
+ """Delete one or more proxies and stop their servers if running.
1016
+
1017
+ \b
1018
+ Examples:
1019
+ forge proxy delete my-proxy
1020
+ forge proxy delete proxy-1 proxy-2
1021
+ forge proxy delete --all
1022
+ forge proxy delete --all --yes
1023
+ """
1024
+ # Deprecated --force alias: preserves both old behaviors (skip confirmation
1025
+ # + kill adopted) during the deprecation window.
1026
+ if force:
1027
+ yes = True
1028
+ kill_adopted = True
1029
+ console = Console(width=200)
1030
+
1031
+ if delete_all and proxy_ids:
1032
+ console.print("[red]Error:[/red] Cannot combine --all with explicit proxy IDs")
1033
+ sys.exit(1)
1034
+
1035
+ if not delete_all and not proxy_ids:
1036
+ console.print("[red]Error:[/red] Provide proxy ID(s) or use --all")
1037
+ sys.exit(1)
1038
+
1039
+ store = ProxyRegistryStore()
1040
+
1041
+ try:
1042
+ registry = store.read()
1043
+ except ProxyRegistryCorruptedError as e:
1044
+ console.print(f"[red]Error:[/red] {e}")
1045
+ sys.exit(1)
1046
+
1047
+ if delete_all:
1048
+ if not registry.proxies:
1049
+ console.print("[dim]No proxies to delete.[/dim]")
1050
+ return
1051
+ targets = list(registry.proxies.keys())
1052
+
1053
+ console.print(f"About to delete [bold]all {len(targets)} proxy(ies)[/bold]:")
1054
+ for t in targets:
1055
+ console.print(f" - {t}")
1056
+ console.print()
1057
+ if not yes:
1058
+ if not click.confirm("Are you sure you want to delete all proxies?"):
1059
+ console.print("Cancelled.")
1060
+ return
1061
+ else:
1062
+ targets = list(dict.fromkeys(proxy_ids))
1063
+
1064
+ deleted = 0
1065
+ failed = 0
1066
+
1067
+ for proxy_id in targets:
1068
+ try:
1069
+ _delete_single_proxy(
1070
+ console=console,
1071
+ store=store,
1072
+ proxy_id=proxy_id,
1073
+ yes=yes or delete_all,
1074
+ kill_adopted=kill_adopted,
1075
+ no_kill=no_kill,
1076
+ )
1077
+ deleted += 1
1078
+ except SystemExit as e:
1079
+ if len(targets) == 1:
1080
+ raise
1081
+ if e.code not in (0, None):
1082
+ failed += 1
1083
+ except Exception as e:
1084
+ console.print(f"[red]Error:[/red] {proxy_id}: {e}")
1085
+ failed += 1
1086
+
1087
+ if len(targets) > 1:
1088
+ parts = [f"{deleted} deleted"]
1089
+ if failed:
1090
+ parts.append(f"{failed} failed")
1091
+ console.print(f"\n[dim]Summary: {', '.join(parts)}[/dim]")
1092
+
1093
+ if failed:
1094
+ sys.exit(1)
1095
+
1096
+
1097
+ def _delete_single_proxy(
1098
+ *,
1099
+ console: Console,
1100
+ store: ProxyRegistryStore,
1101
+ proxy_id: str,
1102
+ yes: bool,
1103
+ kill_adopted: bool = False,
1104
+ no_kill: bool = False,
1105
+ ) -> None:
1106
+ """Delete a single proxy, handling confirmation and cleanup.
1107
+
1108
+ Args:
1109
+ yes: Skip confirmation prompts (informational output stays visible).
1110
+ kill_adopted: Terminate adopted processes during deletion.
1111
+
1112
+ Raises:
1113
+ SystemExit: If user cancels or proxy not found.
1114
+ """
1115
+ try:
1116
+ registry = store.read()
1117
+ except ProxyRegistryCorruptedError as e:
1118
+ console.print(f"[red]Error:[/red] {e}")
1119
+ raise SystemExit(1)
1120
+
1121
+ entry = registry.proxies.get(proxy_id)
1122
+ proxy_path = get_proxy_file_path(proxy_id)
1123
+ proxy_dir = proxy_path.parent
1124
+
1125
+ if entry is None and not proxy_dir.exists():
1126
+ console.print(f"[red]Error:[/red] Proxy '{proxy_id}' not found")
1127
+ raise SystemExit(1)
1128
+
1129
+ # Best-effort pre-check for UX (prompt message and session warnings).
1130
+ # The authoritative ref-count check happens under lock below.
1131
+ shared_proxy_ids: list[str] = []
1132
+ shared_url_hint = False
1133
+ if entry is not None:
1134
+ shared_proxy_ids = _all_proxy_ids_on_port(registry, proxy_id, entry.port)
1135
+ shared_url_hint = bool(shared_proxy_ids)
1136
+
1137
+ # Informational output — always visible (--yes only skips prompts)
1138
+ if shared_url_hint:
1139
+ referencing_sessions = _find_sessions_for_proxy(proxy_id)
1140
+ else:
1141
+ referencing_sessions = _find_sessions_for_proxy(proxy_id, port=entry.port if entry else None)
1142
+
1143
+ base_url_label = entry.base_url if entry else "unknown"
1144
+ if referencing_sessions:
1145
+ if shared_url_hint:
1146
+ console.print(f"[yellow]Warning:[/yellow] {len(referencing_sessions)} " "session(s) reference this proxy:")
1147
+ else:
1148
+ console.print(
1149
+ f"[yellow]Warning:[/yellow] Deleting the last proxy on "
1150
+ f"{base_url_label} affects "
1151
+ f"{len(referencing_sessions)} session(s):"
1152
+ )
1153
+ console.print(f"[dim]Related sessions on {base_url_label}:[/dim]")
1154
+ for s in referencing_sessions[:5]:
1155
+ console.print(f" - {s}")
1156
+ if len(referencing_sessions) > 5:
1157
+ console.print(f" ... and {len(referencing_sessions) - 5} more")
1158
+ console.print("\n[dim]Tip: Delete sessions first with " "'forge session delete <name>'[/dim]")
1159
+
1160
+ elif not shared_url_hint and entry is not None:
1161
+ console.print(f"[dim]Related sessions on {base_url_label}:[/dim] none")
1162
+
1163
+ if shared_proxy_ids:
1164
+ console.print(f"[dim]Related proxies on the same port " f"({base_url_label}):[/dim]")
1165
+ for related_proxy_id in shared_proxy_ids[:5]:
1166
+ console.print(f" - {related_proxy_id}")
1167
+ if len(shared_proxy_ids) > 5:
1168
+ console.print(f" ... and {len(shared_proxy_ids) - 5} more")
1169
+
1170
+ # Confirmation prompt — gated by --yes only
1171
+ if not yes:
1172
+ has_process = (entry and entry.pid and is_pid_alive(entry.pid)) or (
1173
+ entry and entry.pid is None and entry.status == "healthy"
1174
+ )
1175
+ if has_process:
1176
+ if shared_url_hint:
1177
+ msg = f"Delete proxy '{proxy_id}' (server kept alive -- other proxies share this port)?"
1178
+ else:
1179
+ pid_info = f"pid {entry.pid}" if entry and entry.pid else f"port {entry.port}" if entry else ""
1180
+ msg = f"Delete proxy '{proxy_id}' and stop running server ({pid_info})?"
1181
+ else:
1182
+ if shared_url_hint:
1183
+ msg = f"Delete proxy '{proxy_id}' (other proxies share this port)?"
1184
+ else:
1185
+ msg = f"Delete proxy '{proxy_id}'?"
1186
+ if not click.confirm(msg):
1187
+ console.print("Cancelled.")
1188
+ raise SystemExit(0)
1189
+
1190
+ # Remove from registry and determine PID fate under lock (TOCTOU-safe).
1191
+ should_kill_pid = False
1192
+ remaining_aliases: list[str] = []
1193
+ if entry:
1194
+
1195
+ def remove_and_check(reg: ProxyRegistry) -> None:
1196
+ nonlocal should_kill_pid, remaining_aliases
1197
+ reg.proxies.pop(proxy_id, None)
1198
+ remaining_aliases = _live_proxy_ids_on_port(reg, proxy_id, entry.port)
1199
+ if not remaining_aliases:
1200
+ should_kill_pid = True
1201
+
1202
+ try:
1203
+ store.update(timeout_s=CLI_LOCK_TIMEOUT_S, mutate=remove_and_check)
1204
+ except Exception as e:
1205
+ console.print(f"[red]Error:[/red] Could not update registry: {e}")
1206
+ raise SystemExit(1)
1207
+
1208
+ # Post-lock summary: show authoritative remaining aliases
1209
+ if remaining_aliases:
1210
+ console.print(f"[dim]Keeping shared server references:[/dim] " f"{', '.join(remaining_aliases)}")
1211
+
1212
+ # Delete proxy directory
1213
+ if proxy_dir.exists():
1214
+ try:
1215
+ shutil.rmtree(proxy_dir)
1216
+ except OSError as e:
1217
+ if entry is not None:
1218
+ try:
1219
+ _restore_proxy_registry_entry(store, entry)
1220
+ except Exception as restore_error:
1221
+ console.print(
1222
+ f"[yellow]Warning:[/yellow] Could not restore registry entry after delete failure: "
1223
+ f"{restore_error}"
1224
+ )
1225
+ console.print(f"[red]Error:[/red] Could not delete proxy directory: {e}")
1226
+ raise SystemExit(1)
1227
+
1228
+ # Kill server only if the locked check confirmed we're the last reference
1229
+ if entry and should_kill_pid and not no_kill:
1230
+ _stop_proxy_process(console, entry, kill_adopted=kill_adopted)
1231
+ elif entry and not should_kill_pid:
1232
+ console.print(f"[dim]Server kept alive (other proxies share port {entry.port})[/dim]")
1233
+
1234
+ console.print(f"[green]Deleted[/green] proxy '{proxy_id}'")
1235
+
1236
+
1237
+ # --- Prune ---
1238
+
1239
+
1240
+ @proxy.command("clean")
1241
+ def clean_cmd() -> None:
1242
+ """Clean up stale proxies (dead server processes)."""
1243
+ console = Console(width=200)
1244
+
1245
+ try:
1246
+ result = prune_stale_proxies()
1247
+ except ProxyRegistryCorruptedError as e:
1248
+ console.print(f"[red]Error:[/red] {e}")
1249
+ sys.exit(1)
1250
+
1251
+ if not result.pruned_proxy_ids:
1252
+ console.print("No stale proxies to clean.")
1253
+ return
1254
+
1255
+ console.print(f"Cleaned {len(result.pruned_proxy_ids)} stale proxy(ies):")
1256
+ for pid in result.pruned_proxy_ids:
1257
+ console.print(f" - {pid}")
1258
+
1259
+
1260
+ # --- Validate ---
1261
+
1262
+
1263
+ @proxy.command("validate")
1264
+ @click.argument("proxy_id")
1265
+ def validate_cmd(proxy_id: str) -> None:
1266
+ """Validate a proxy configuration file."""
1267
+ console = Console(width=200)
1268
+
1269
+ proxy_path = get_proxy_file_path(proxy_id)
1270
+ if not proxy_path.exists():
1271
+ console.print(f"[red]Error:[/red] Proxy '{proxy_id}' not found at {display_path(proxy_path)}")
1272
+ sys.exit(1)
1273
+
1274
+ try:
1275
+ config = load_proxy_instance_config(proxy_id)
1276
+ if config is None:
1277
+ console.print(f"[red]Error:[/red] Failed to load proxy '{proxy_id}'")
1278
+ sys.exit(1)
1279
+
1280
+ console.print(f"[green]✓[/green] Proxy '{proxy_id}' is valid")
1281
+ console.print(f" Template: {config.template}")
1282
+ console.print(f" Provider: {config.provider}")
1283
+ console.print(f" Port: {config.port}")
1284
+ console.print(f" Default tier: {config.default_tier}")
1285
+
1286
+ except ValueError as e:
1287
+ console.print(f"[red]✗[/red] Validation failed: {e}")
1288
+ sys.exit(1)
1289
+ except Exception as e:
1290
+ console.print(f"[red]✗[/red] Error loading proxy: {e}")
1291
+ sys.exit(1)
1292
+
1293
+
1294
+ # --- Metrics ---
1295
+
1296
+
1297
+ def _format_tokens(n: int) -> str:
1298
+ """Format token count with K/M suffix for readability."""
1299
+ if n >= 1_000_000:
1300
+ return f"{n / 1_000_000:.1f}M"
1301
+ if n >= 1_000:
1302
+ return f"{n / 1_000:.1f}K"
1303
+ return str(n)
1304
+
1305
+
1306
+ def _format_duration(seconds: float) -> str:
1307
+ """Format duration as human-readable (3s, 5m, 2h 15m, 1d 3h)."""
1308
+ if seconds < 60:
1309
+ return f"{seconds:.0f}s"
1310
+ if seconds < 3600:
1311
+ return f"{seconds / 60:.0f}m"
1312
+ if seconds < 86400:
1313
+ hours = int(seconds // 3600)
1314
+ mins = int((seconds % 3600) // 60)
1315
+ return f"{hours}h {mins}m"
1316
+ days = int(seconds // 86400)
1317
+ hours = int((seconds % 86400) // 3600)
1318
+ return f"{days}d {hours}h"
1319
+
1320
+
1321
+ def _format_latency(ms: float) -> str:
1322
+ """Format latency with comma separators."""
1323
+ return f"{ms:,.0f}ms"
1324
+
1325
+
1326
+ def _format_relative_time(iso_str: str) -> str:
1327
+ """Format ISO timestamp as relative time ('12s ago', '5m ago')."""
1328
+ from datetime import datetime, timezone
1329
+
1330
+ try:
1331
+ dt = datetime.fromisoformat(iso_str)
1332
+ if dt.tzinfo is None:
1333
+ dt = dt.replace(tzinfo=timezone.utc)
1334
+ delta = datetime.now(timezone.utc) - dt
1335
+ secs = delta.total_seconds()
1336
+ if secs < 0:
1337
+ return "just now"
1338
+ return f"{_format_duration(secs)} ago"
1339
+ except (ValueError, TypeError):
1340
+ return iso_str
1341
+
1342
+
1343
+ @dataclass
1344
+ class _ProxyInfo:
1345
+ """Fetched proxy info (metrics + identity)."""
1346
+
1347
+ metrics: dict[str, Any]
1348
+ template: str | None = None
1349
+
1350
+
1351
+ def _fetch_proxy_info(base_url: str) -> _ProxyInfo | None:
1352
+ """Fetch metrics + identity from a proxy's GET / endpoint."""
1353
+ import httpx
1354
+
1355
+ try:
1356
+ with httpx.Client(timeout=httpx.Timeout(5.0)) as client:
1357
+ resp = client.get(f"{base_url}/")
1358
+ if resp.status_code != 200:
1359
+ return None
1360
+ data = resp.json()
1361
+ metrics = data.get("metrics")
1362
+ if metrics is None:
1363
+ return None
1364
+ return _ProxyInfo(metrics=metrics, template=data.get("template"))
1365
+ except Exception:
1366
+ return None
1367
+
1368
+
1369
+ def _display_metrics(
1370
+ console: Console,
1371
+ proxy_id: str,
1372
+ base_url: str,
1373
+ info: _ProxyInfo,
1374
+ *,
1375
+ show_separator: bool = False,
1376
+ ) -> None:
1377
+ """Render metrics to the console using Rich."""
1378
+ metrics = info.metrics
1379
+ uptime = _format_duration(metrics.get("uptime_seconds", 0))
1380
+ total = metrics.get("total_requests", 0)
1381
+ streaming = metrics.get("total_streaming", 0)
1382
+ failures = metrics.get("total_failures", 0)
1383
+
1384
+ tokens = metrics.get("tokens", {})
1385
+ cache_rate = metrics.get("cache_hit_rate", 0)
1386
+
1387
+ if show_separator:
1388
+ console.print("[dim]" + "-" * 60 + "[/dim]")
1389
+ console.print(f"\n[bold]Proxy Metrics:[/bold] {proxy_id}")
1390
+ identity_parts = []
1391
+ if info.template:
1392
+ identity_parts.append(info.template)
1393
+ identity_parts.append(base_url)
1394
+ identity_parts.append(f"uptime {uptime}")
1395
+ console.print(f" [dim]{' | '.join(identity_parts)}[/dim]\n")
1396
+
1397
+ streaming_note = f" ({streaming:,} streaming)" if streaming > 0 else ""
1398
+ console.print(f" Requests {total:>10,}{streaming_note}")
1399
+ if failures > 0:
1400
+ fail_pct = f" ({failures / total * 100:.1f}%)" if total > 0 else ""
1401
+ console.print(f" Failures {failures:>10,}{fail_pct}")
1402
+ else:
1403
+ console.print(f" Failures {failures:>10,}")
1404
+
1405
+ console.print("\n [bold]Tokens[/bold]")
1406
+ console.print(f" Input {_format_tokens(tokens.get('input', 0)):>10}")
1407
+ console.print(f" Output {_format_tokens(tokens.get('output', 0)):>10}")
1408
+ cached = tokens.get("cached", 0)
1409
+ cache_str = f" ({cache_rate:.1f}% hit rate)" if cached > 0 else ""
1410
+ console.print(f" Cached {_format_tokens(cached):>10}{cache_str}")
1411
+
1412
+ failed_in = tokens.get("failed_input", 0)
1413
+ failed_out = tokens.get("failed_output", 0)
1414
+ if failed_in > 0 or failed_out > 0:
1415
+ console.print("\n [bold]Failed Tokens[/bold]")
1416
+ console.print(f" Input {_format_tokens(failed_in):>10}")
1417
+ console.print(f" Output {_format_tokens(failed_out):>10}")
1418
+
1419
+ by_tier = metrics.get("by_tier", {})
1420
+ if by_tier:
1421
+ console.print("\n [bold]By Tier[/bold]")
1422
+ tier_table = Table(show_header=True, header_style="dim", box=None, padding=(0, 2))
1423
+ tier_table.add_column("TIER", style="bold")
1424
+ tier_table.add_column("REQUESTS", justify="right")
1425
+ tier_table.add_column("INPUT", justify="right")
1426
+ tier_table.add_column("OUTPUT", justify="right")
1427
+ tier_table.add_column("CACHED", justify="right")
1428
+ tier_table.add_column("LATENCY", justify="right")
1429
+ for tier, data in sorted(by_tier.items()):
1430
+ tier_table.add_row(
1431
+ tier,
1432
+ f"{data.get('requests', 0):,}",
1433
+ _format_tokens(data.get("input_tokens", 0)),
1434
+ _format_tokens(data.get("output_tokens", 0)),
1435
+ _format_tokens(data.get("cached_tokens", 0)),
1436
+ _format_latency(data.get("avg_latency_ms", 0)),
1437
+ )
1438
+ console.print(tier_table)
1439
+
1440
+ by_model = metrics.get("by_model", {})
1441
+ if by_model:
1442
+ console.print("\n [bold]By Model[/bold]")
1443
+ model_table = Table(show_header=True, header_style="dim", box=None, padding=(0, 2))
1444
+ model_table.add_column("MODEL", style="bold")
1445
+ model_table.add_column("REQUESTS", justify="right")
1446
+ model_table.add_column("INPUT", justify="right")
1447
+ model_table.add_column("OUTPUT", justify="right")
1448
+ model_table.add_column("CACHED", justify="right")
1449
+ model_table.add_column("LATENCY", justify="right")
1450
+ for model, data in sorted(by_model.items()):
1451
+ model_table.add_row(
1452
+ model,
1453
+ f"{data.get('requests', 0):,}",
1454
+ _format_tokens(data.get("input_tokens", 0)),
1455
+ _format_tokens(data.get("output_tokens", 0)),
1456
+ _format_tokens(data.get("cached_tokens", 0)),
1457
+ _format_latency(data.get("avg_latency_ms", 0)),
1458
+ )
1459
+ console.print(model_table)
1460
+
1461
+ failures_by_type = metrics.get("failures_by_type", {})
1462
+ if failures_by_type:
1463
+ console.print("\n [bold]Failures by Type[/bold]")
1464
+ for err_type, count in sorted(failures_by_type.items(), key=lambda x: -x[1]):
1465
+ console.print(f" {err_type:<25} {count:>5}")
1466
+
1467
+ last = metrics.get("last_request_at")
1468
+ if last:
1469
+ console.print(f"\n [dim]Last request: {_format_relative_time(last)}[/dim]")
1470
+ console.print()
1471
+
1472
+
1473
+ @proxy.command("metrics")
1474
+ @click.argument("proxy_id", required=False)
1475
+ @click.option("--json", "json_output", is_flag=True, help="Output raw JSON")
1476
+ @click.option("--all", "show_all", is_flag=True, help="Show all active proxies")
1477
+ def metrics_cmd(proxy_id: str | None, json_output: bool, show_all: bool) -> None:
1478
+ """Show runtime metrics for a running proxy."""
1479
+ import json
1480
+
1481
+ console = Console(width=200)
1482
+
1483
+ try:
1484
+ store = ProxyRegistryStore()
1485
+ except ProxyRegistryCorruptedError as e:
1486
+ console.print(f"[red]Error:[/red] Proxy registry error: {e}")
1487
+ sys.exit(1)
1488
+
1489
+ if show_all:
1490
+ try:
1491
+ proxies = store.list_proxies()
1492
+ except ProxyRegistryCorruptedError as e:
1493
+ console.print(f"[red]Error:[/red] Proxy registry error: {e}")
1494
+ sys.exit(1)
1495
+ if not proxies:
1496
+ console.print("[dim]No proxies registered.[/dim]")
1497
+ return
1498
+ if json_output:
1499
+ # Collect all results into a single valid JSON object
1500
+ results: dict[str, Any] = {}
1501
+ for entry in proxies:
1502
+ info = _fetch_proxy_info(entry.base_url)
1503
+ results[entry.proxy_id] = info.metrics if info else None
1504
+ console.print(json.dumps(results, indent=2))
1505
+ else:
1506
+ show_sep = len(proxies) > 1
1507
+ for i, entry in enumerate(proxies):
1508
+ info = _fetch_proxy_info(entry.base_url)
1509
+ if info is None:
1510
+ if show_sep and i > 0:
1511
+ console.print("[dim]" + "-" * 60 + "[/dim]")
1512
+ console.print(f"\n[dim]{entry.proxy_id}: not reachable at {entry.base_url}[/dim]\n")
1513
+ else:
1514
+ _display_metrics(
1515
+ console,
1516
+ entry.proxy_id,
1517
+ entry.base_url,
1518
+ info,
1519
+ show_separator=show_sep and i > 0,
1520
+ )
1521
+ return
1522
+
1523
+ if not proxy_id:
1524
+ # Default: show the single proxy if exactly one exists
1525
+ try:
1526
+ proxies = store.list_proxies()
1527
+ except ProxyRegistryCorruptedError as e:
1528
+ console.print(f"[red]Error:[/red] Proxy registry error: {e}")
1529
+ sys.exit(1)
1530
+ if len(proxies) == 1:
1531
+ proxy_id = proxies[0].proxy_id
1532
+ elif len(proxies) == 0:
1533
+ console.print("[dim]No proxies registered.[/dim]")
1534
+ return
1535
+ else:
1536
+ console.print("[red]Error:[/red] Multiple proxies exist. Specify a proxy_id or use --all.")
1537
+ sys.exit(1)
1538
+
1539
+ try:
1540
+ registry = store.read()
1541
+ except ProxyRegistryCorruptedError as e:
1542
+ console.print(f"[red]Error:[/red] Proxy registry error: {e}")
1543
+ sys.exit(1)
1544
+ maybe_entry = registry.proxies.get(proxy_id)
1545
+ if maybe_entry is None:
1546
+ console.print(f"[red]Error:[/red] Proxy '{proxy_id}' not found in registry.")
1547
+ sys.exit(1)
1548
+ entry = maybe_entry
1549
+
1550
+ info = _fetch_proxy_info(entry.base_url)
1551
+ if info is None:
1552
+ console.print(f"[dim]Proxy '{proxy_id}' not reachable at {entry.base_url}[/dim]")
1553
+ sys.exit(1)
1554
+
1555
+ if json_output:
1556
+ console.print(json.dumps(info.metrics, indent=2))
1557
+ else:
1558
+ _display_metrics(console, proxy_id, entry.base_url, info)
1559
+
1560
+
1561
+ # --- Template subgroup ---
1562
+
1563
+
1564
+ def _extract_template_description(content: str) -> str:
1565
+ """Extract description from template YAML comments.
1566
+
1567
+ Shipped templates follow the convention:
1568
+ # Template: <name> <- line 1: skip (repeats name)
1569
+ # <description> <- line 2: use this
1570
+
1571
+ Returns empty string if no suitable comment is found.
1572
+ """
1573
+ lines = content.splitlines()
1574
+ comment_lines = [line.lstrip("# ").strip() for line in lines if line.startswith("#")]
1575
+ # Skip the first comment (usually "Template: <name>") and any blank comment lines
1576
+ for line in comment_lines[1:]:
1577
+ if line:
1578
+ return line
1579
+ return ""
1580
+
1581
+
1582
+ @proxy.group("template")
1583
+ def template_group() -> None:
1584
+ """Manage proxy templates.
1585
+
1586
+ \b
1587
+ Templates define model routing and are used to create proxies.
1588
+ User-customized templates are stored at ~/.forge/templates/.
1589
+
1590
+ \b
1591
+ Examples:
1592
+ forge proxy template list # List available templates
1593
+ forge proxy template show <name> # Show template config
1594
+ forge proxy template edit <name> # Customize a template
1595
+ forge proxy template reset <name> # Reset to built-in default
1596
+ """
1597
+
1598
+
1599
+ @template_group.command("list")
1600
+ def template_list_cmd() -> None:
1601
+ """List available proxy templates."""
1602
+ console = Console(width=200)
1603
+
1604
+ templates = list_template_names()
1605
+ if not templates:
1606
+ console.print("[dim]No templates available.[/dim]")
1607
+ return
1608
+
1609
+ table = Table(title="Proxy Templates")
1610
+ table.add_column("NAME", style="cyan")
1611
+ table.add_column("SOURCE")
1612
+ table.add_column("DESCRIPTION", style="dim")
1613
+
1614
+ for name in templates:
1615
+ user = is_user_template(name)
1616
+ shipped = shipped_template_exists(name)
1617
+ if user and shipped:
1618
+ source = "customized"
1619
+ elif user:
1620
+ source = "user"
1621
+ else:
1622
+ source = "built-in"
1623
+
1624
+ try:
1625
+ content = read_template(name)
1626
+ description = _extract_template_description(content)
1627
+ except Exception:
1628
+ description = ""
1629
+
1630
+ table.add_row(name, source, description)
1631
+
1632
+ console.print(table)
1633
+ console.print("\n[dim]Tip: Run 'forge proxy create <template>' to create a proxy.[/dim]")
1634
+
1635
+
1636
+ @template_group.command("show")
1637
+ @click.argument("name")
1638
+ @click.option("--raw", is_flag=True, help="Output raw YAML without syntax highlighting")
1639
+ def template_show_cmd(name: str, raw: bool) -> None:
1640
+ """Show template configuration.
1641
+
1642
+ \b
1643
+ Examples:
1644
+ forge proxy template show openrouter-gemini
1645
+ forge proxy template show openrouter-gemini --raw
1646
+ """
1647
+ console = Console(width=200)
1648
+
1649
+ try:
1650
+ exists = template_exists(name)
1651
+ except ValueError as e:
1652
+ console.print(f"[red]Error:[/red] {e}")
1653
+ sys.exit(1)
1654
+
1655
+ if not exists:
1656
+ console.print(f"[red]Error:[/red] Template '{name}' not found")
1657
+ console.print("\n[dim]Tip: Run 'forge proxy template list' to see available templates.[/dim]")
1658
+ sys.exit(1)
1659
+
1660
+ content = read_template(name)
1661
+ path = get_template_path(name)
1662
+
1663
+ user = is_user_template(name)
1664
+ shipped = shipped_template_exists(name)
1665
+ if user and shipped:
1666
+ source_label = "customized (overrides built-in)"
1667
+ elif user:
1668
+ source_label = "user"
1669
+ else:
1670
+ source_label = "built-in"
1671
+
1672
+ if raw:
1673
+ console.print(content)
1674
+ else:
1675
+ syntax = Syntax(content, "yaml", theme="monokai", line_numbers=True)
1676
+ console.print(f"[bold]Template:[/bold] {name}")
1677
+ console.print(f"[bold]Source:[/bold] {source_label}")
1678
+ console.print(f"[bold]Path:[/bold] {display_path(path)}")
1679
+ console.print()
1680
+ console.print(syntax)
1681
+
1682
+
1683
+ @template_group.command("edit")
1684
+ @click.argument("name")
1685
+ def template_edit_cmd(name: str) -> None:
1686
+ """Customize a template (copy-on-first-edit).
1687
+
1688
+ Creates a user copy at ~/.forge/templates/<name>.yaml on first edit.
1689
+ Subsequent edits modify the user copy directly.
1690
+
1691
+ \b
1692
+ Examples:
1693
+ forge proxy template edit openrouter-gemini
1694
+ """
1695
+ console = Console(width=200)
1696
+
1697
+ try:
1698
+ validate_template_name(name)
1699
+ except ValueError as e:
1700
+ console.print(f"[red]Error:[/red] {e}")
1701
+ sys.exit(1)
1702
+
1703
+ # edit requires a shipped template to seed from
1704
+ if not shipped_template_exists(name):
1705
+ console.print(f"[red]Error:[/red] No built-in template '{name}' to customize")
1706
+ console.print("\n[dim]Tip: Run 'forge proxy template list' to see available templates.[/dim]")
1707
+ sys.exit(1)
1708
+
1709
+ user_path = get_user_template_path(name)
1710
+ first_edit = not user_path.is_file()
1711
+
1712
+ # Seed temp file from user copy (if exists) or shipped template.
1713
+ # The user file is only created/updated after successful validation.
1714
+ seed_content = user_path.read_text(encoding="utf-8") if not first_edit else read_shipped_template(name)
1715
+
1716
+ editor = os.environ.get("EDITOR", "vim")
1717
+ if not shutil.which(editor):
1718
+ console.print(f"[red]Error:[/red] Editor '{editor}' not found. Set $EDITOR to an available editor.")
1719
+ sys.exit(1)
1720
+
1721
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp:
1722
+ tmp.write(seed_content)
1723
+ tmp_path = Path(tmp.name)
1724
+
1725
+ success = False
1726
+ try:
1727
+ result = subprocess.run([editor, str(tmp_path)])
1728
+ if result.returncode != 0:
1729
+ console.print(f"[red]Error:[/red] Editor exited with code {result.returncode}")
1730
+ console.print(f"Your changes are saved at: {display_path(tmp_path)}")
1731
+ sys.exit(1)
1732
+
1733
+ import yaml as pyyaml
1734
+
1735
+ try:
1736
+ with open(tmp_path, encoding="utf-8") as f:
1737
+ edited_data = pyyaml.safe_load(f)
1738
+ except Exception as e:
1739
+ console.print(f"[red]Error:[/red] Invalid YAML: {e}")
1740
+ console.print(f"Your changes are saved at: {display_path(tmp_path)}")
1741
+ sys.exit(1)
1742
+
1743
+ if not isinstance(edited_data, dict):
1744
+ console.print("[red]Error:[/red] Template must be a YAML mapping")
1745
+ console.print(f"Your changes are saved at: {display_path(tmp_path)}")
1746
+ sys.exit(1)
1747
+
1748
+ # Validate template shape (ForgeConfig, not ProxyInstanceConfig)
1749
+ try:
1750
+ from forge.config.dataclass_utils import dict_to_dataclass
1751
+ from forge.config.schema import ForgeConfig
1752
+
1753
+ dict_to_dataclass(ForgeConfig, edited_data, strict=True)
1754
+ except (ValueError, TypeError, KeyError, AttributeError) as e:
1755
+ console.print(f"[red]Error:[/red] Invalid template configuration: {e}")
1756
+ console.print(f"Your changes are saved at: {display_path(tmp_path)}")
1757
+ sys.exit(1)
1758
+
1759
+ # Write back atomically (create user dir on first edit)
1760
+ from forge.core.state import atomic_write_text
1761
+
1762
+ user_path.parent.mkdir(parents=True, exist_ok=True)
1763
+ content = tmp_path.read_text(encoding="utf-8")
1764
+ atomic_write_text(user_path, content)
1765
+
1766
+ success = True
1767
+ if first_edit:
1768
+ console.print(f"[dim]Created user copy at {display_path(user_path)}[/dim]")
1769
+ console.print(f"[green]Updated[/green] template '{name}'")
1770
+ _clear_workflow_template_cache()
1771
+
1772
+ finally:
1773
+ if success and tmp_path.exists():
1774
+ try:
1775
+ tmp_path.unlink()
1776
+ except OSError:
1777
+ pass
1778
+
1779
+
1780
+ @template_group.command("reset")
1781
+ @click.argument("name")
1782
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt")
1783
+ @click.option("--force", "-f", is_flag=True, hidden=True, help="Deprecated alias for --yes")
1784
+ def template_reset_cmd(name: str, yes: bool, force: bool) -> None:
1785
+ """Reset a template to built-in defaults.
1786
+
1787
+ Removes the user-customized copy so the shipped template takes effect.
1788
+
1789
+ \b
1790
+ Examples:
1791
+ forge proxy template reset openrouter-gemini
1792
+ forge proxy template reset openrouter-gemini --yes
1793
+ """
1794
+ yes = yes or force
1795
+ console = Console(width=200)
1796
+
1797
+ try:
1798
+ user_path = get_user_template_path(name)
1799
+ except ValueError as e:
1800
+ console.print(f"[red]Error:[/red] {e}")
1801
+ sys.exit(1)
1802
+
1803
+ if not user_path.is_file():
1804
+ console.print(f"[dim]Already using built-in defaults for '{name}'.[/dim]")
1805
+ return
1806
+
1807
+ if not yes:
1808
+ if shipped_template_exists(name):
1809
+ msg = f"Reset template '{name}' to built-in defaults?"
1810
+ else:
1811
+ console.print(
1812
+ f"[yellow]Warning:[/yellow] No built-in template '{name}'. " "This will delete the template entirely."
1813
+ )
1814
+ msg = f"Delete user template '{name}'?"
1815
+ if not click.confirm(msg):
1816
+ console.print("[dim]Cancelled.[/dim]")
1817
+ return
1818
+
1819
+ user_path.unlink()
1820
+ console.print(f"[green]Reset[/green] template '{name}' to built-in defaults")
1821
+ _clear_workflow_template_cache()