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.
- forge/__init__.py +3 -0
- forge/_extensions/agents/.gitkeep +0 -0
- forge/_extensions/commands/.gitkeep +0 -0
- forge/_extensions/skills/analyze/SKILL.md +87 -0
- forge/_extensions/skills/challenge/SKILL.md +91 -0
- forge/_extensions/skills/consensus/SKILL.md +120 -0
- forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
- forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
- forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
- forge/_extensions/skills/debate/SKILL.md +116 -0
- forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
- forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
- forge/_extensions/skills/panel/SKILL.md +141 -0
- forge/_extensions/skills/panel/resources/synthesis.md +103 -0
- forge/_extensions/skills/qa/SKILL.md +704 -0
- forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
- forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
- forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
- forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
- forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
- forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
- forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
- forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
- forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
- forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
- forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
- forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
- forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
- forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
- forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
- forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
- forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
- forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
- forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
- forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
- forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
- forge/_extensions/skills/qa/resources/checklist.md +103 -0
- forge/_extensions/skills/qa/resources/report-template.md +62 -0
- forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
- forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
- forge/_extensions/skills/review/SKILL.md +125 -0
- forge/_extensions/skills/review/references/claude-4.6.md +474 -0
- forge/_extensions/skills/review/references/claude-4.7.md +710 -0
- forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
- forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
- forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
- forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
- forge/_extensions/skills/review/resources/code-gemini.md +184 -0
- forge/_extensions/skills/review/resources/code-openai.md +203 -0
- forge/_extensions/skills/review/resources/code.md +160 -0
- forge/_extensions/skills/review-docs/SKILL.md +121 -0
- forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
- forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
- forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
- forge/_extensions/skills/review-docs/resources/docs.md +170 -0
- forge/_extensions/skills/smoke-test/SKILL.md +27 -0
- forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
- forge/_extensions/skills/understand/SKILL.md +148 -0
- forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
- forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
- forge/_extensions/skills/understand/resources/code-openai.md +181 -0
- forge/_extensions/skills/understand/resources/code.md +163 -0
- forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
- forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
- forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
- forge/_extensions/skills/understand/resources/docs.md +177 -0
- forge/_extensions/skills/walkthrough/SKILL.md +599 -0
- forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
- forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
- forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
- forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
- forge/backend/__init__.py +174 -0
- forge/backend/adapters/__init__.py +38 -0
- forge/backend/adapters/litellm.py +158 -0
- forge/backend/creation.py +89 -0
- forge/backend/registry.py +178 -0
- forge/cli/__init__.py +16 -0
- forge/cli/auth.py +483 -0
- forge/cli/backend.py +298 -0
- forge/cli/claude.py +411 -0
- forge/cli/config_cmd.py +303 -0
- forge/cli/extensions.py +1001 -0
- forge/cli/gc.py +165 -0
- forge/cli/guard.py +1018 -0
- forge/cli/guards.py +106 -0
- forge/cli/handoff.py +110 -0
- forge/cli/hooks/__init__.py +36 -0
- forge/cli/hooks/_group.py +20 -0
- forge/cli/hooks/_helpers.py +149 -0
- forge/cli/hooks/commands.py +1677 -0
- forge/cli/hooks/direct_commands.py +1304 -0
- forge/cli/hooks/install.py +232 -0
- forge/cli/hooks/policy.py +151 -0
- forge/cli/hooks/read_hygiene.py +74 -0
- forge/cli/hooks/verification.py +370 -0
- forge/cli/logs.py +406 -0
- forge/cli/main.py +292 -0
- forge/cli/proxy.py +1821 -0
- forge/cli/proxy_costs.py +313 -0
- forge/cli/search.py +416 -0
- forge/cli/session.py +892 -0
- forge/cli/session_addendum.py +81 -0
- forge/cli/session_fork.py +750 -0
- forge/cli/session_handoff.py +141 -0
- forge/cli/session_lifecycle.py +2053 -0
- forge/cli/session_manage.py +1336 -0
- forge/cli/session_memory.py +201 -0
- forge/cli/status_line.py +1398 -0
- forge/cli/workflow.py +1964 -0
- forge/config/__init__.py +110 -0
- forge/config/dataclass_utils.py +88 -0
- forge/config/defaults/__init__.py +0 -0
- forge/config/defaults/backends/__init__.py +0 -0
- forge/config/defaults/backends/litellm.yaml +196 -0
- forge/config/defaults/templates/__init__.py +0 -0
- forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
- forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
- forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
- forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
- forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
- forge/config/defaults/templates/litellm-gemini.yaml +21 -0
- forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
- forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
- forge/config/defaults/templates/litellm-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
- forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
- forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
- forge/config/defaults/templates/openrouter-glm.yaml +23 -0
- forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
- forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
- forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
- forge/config/defaults/templates/openrouter-openai.yaml +28 -0
- forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
- forge/config/loader.py +675 -0
- forge/config/schema.py +448 -0
- forge/core/__init__.py +5 -0
- forge/core/auth/__init__.py +67 -0
- forge/core/auth/capabilities.py +219 -0
- forge/core/auth/credentials_file.py +244 -0
- forge/core/auth/protocols.py +18 -0
- forge/core/auth/secrets.py +243 -0
- forge/core/auth/template_secrets.py +112 -0
- forge/core/data/__init__.py +5 -0
- forge/core/data/model_catalog.yaml +1522 -0
- forge/core/data/pricing.yaml +140 -0
- forge/core/data/system_prompt_addendums/__init__.py +0 -0
- forge/core/data/system_prompt_addendums/gemini.md +330 -0
- forge/core/data/system_prompt_addendums/openai.md +328 -0
- forge/core/llm/__init__.py +231 -0
- forge/core/llm/clients/__init__.py +14 -0
- forge/core/llm/clients/base.py +115 -0
- forge/core/llm/clients/litellm.py +619 -0
- forge/core/llm/clients/openai_compat.py +244 -0
- forge/core/llm/clients/openrouter.py +234 -0
- forge/core/llm/credentials.py +439 -0
- forge/core/llm/detection.py +86 -0
- forge/core/llm/errors.py +44 -0
- forge/core/llm/protocols.py +80 -0
- forge/core/llm/types.py +176 -0
- forge/core/logging.py +146 -0
- forge/core/models/__init__.py +91 -0
- forge/core/models/catalog.py +467 -0
- forge/core/models/pricing.py +165 -0
- forge/core/models/types.py +167 -0
- forge/core/naming.py +212 -0
- forge/core/ops/__init__.py +73 -0
- forge/core/ops/context.py +141 -0
- forge/core/ops/gc.py +802 -0
- forge/core/ops/proxy.py +146 -0
- forge/core/ops/resolution.py +135 -0
- forge/core/ops/session.py +344 -0
- forge/core/ops/session_context.py +548 -0
- forge/core/paths.py +38 -0
- forge/core/process.py +54 -0
- forge/core/reactive/__init__.py +38 -0
- forge/core/reactive/cost_tracking.py +300 -0
- forge/core/reactive/env.py +180 -0
- forge/core/reactive/proxy.py +78 -0
- forge/core/reactive/routing.py +622 -0
- forge/core/reactive/session_runner.py +185 -0
- forge/core/reactive/structured_output.py +62 -0
- forge/core/reactive/tagger.py +94 -0
- forge/core/reactive/throttle.py +132 -0
- forge/core/state/__init__.py +59 -0
- forge/core/state/exceptions.py +59 -0
- forge/core/state/io.py +140 -0
- forge/core/state/lock.py +99 -0
- forge/core/state/timestamps.py +60 -0
- forge/core/transcript.py +78 -0
- forge/core/typing_helpers.py +24 -0
- forge/core/workqueue/__init__.py +67 -0
- forge/core/workqueue/queue.py +552 -0
- forge/core/workqueue/types.py +63 -0
- forge/guard/__init__.py +26 -0
- forge/guard/deterministic/__init__.py +26 -0
- forge/guard/deterministic/base.py +158 -0
- forge/guard/deterministic/coding_standards.py +256 -0
- forge/guard/deterministic/registry.py +148 -0
- forge/guard/deterministic/tdd.py +171 -0
- forge/guard/engine.py +216 -0
- forge/guard/protocols.py +91 -0
- forge/guard/queries.py +96 -0
- forge/guard/semantic/__init__.py +34 -0
- forge/guard/semantic/promotion.py +18 -0
- forge/guard/semantic/supervisor.py +813 -0
- forge/guard/semantic/verdict.py +183 -0
- forge/guard/store.py +124 -0
- forge/guard/team/__init__.py +6 -0
- forge/guard/team/config.py +24 -0
- forge/guard/team/handlers.py +209 -0
- forge/guard/team/prompts.py +41 -0
- forge/guard/types.py +125 -0
- forge/guard/workflow/__init__.py +17 -0
- forge/guard/workflow/branches.py +67 -0
- forge/guard/workflow/config.py +63 -0
- forge/guard/workflow/divergence.py +113 -0
- forge/guard/workflow/policy.py +87 -0
- forge/guard/workflow/stages.py +205 -0
- forge/install/__init__.py +55 -0
- forge/install/cli.py +281 -0
- forge/install/exceptions.py +163 -0
- forge/install/hooks.py +109 -0
- forge/install/installer.py +1037 -0
- forge/install/models.py +321 -0
- forge/install/preset.py +272 -0
- forge/install/settings_merge.py +831 -0
- forge/install/tracking.py +238 -0
- forge/install/version.py +141 -0
- forge/proxy/__init__.py +0 -0
- forge/proxy/base_client.py +181 -0
- forge/proxy/client_adapter.py +476 -0
- forge/proxy/client_factory.py +531 -0
- forge/proxy/converters.py +1206 -0
- forge/proxy/cost_logger.py +132 -0
- forge/proxy/cost_tracker.py +242 -0
- forge/proxy/data_models.py +338 -0
- forge/proxy/error_hints.py +92 -0
- forge/proxy/metrics.py +222 -0
- forge/proxy/model_spec.py +158 -0
- forge/proxy/proxies.py +333 -0
- forge/proxy/proxy_identity.py +134 -0
- forge/proxy/proxy_orchestrator.py +1018 -0
- forge/proxy/proxy_startup.py +54 -0
- forge/proxy/server.py +1561 -0
- forge/proxy/utils.py +537 -0
- forge/review/__init__.py +6 -0
- forge/review/adversarial.py +111 -0
- forge/review/consensus.py +236 -0
- forge/review/engine.py +356 -0
- forge/review/models.py +437 -0
- forge/review/resources/__init__.py +5 -0
- forge/review/resources/codereview-performance.md +85 -0
- forge/review/resources/codereview-quick.md +75 -0
- forge/review/resources/codereview-security.md +92 -0
- forge/review/resources/codereview.md +85 -0
- forge/review/resources/docreview-quick.md +75 -0
- forge/review/resources/docreview.md +86 -0
- forge/review/resources/thinkdeep.md +89 -0
- forge/review/routing.py +368 -0
- forge/review/synthesis.py +73 -0
- forge/runtime_config.py +438 -0
- forge/search/__init__.py +55 -0
- forge/search/bm25_store.py +264 -0
- forge/search/content_store.py +197 -0
- forge/search/engine.py +352 -0
- forge/search/exceptions.py +51 -0
- forge/search/extractor.py +234 -0
- forge/search/index_state.py +295 -0
- forge/search/store.py +215 -0
- forge/search/tokenizer.py +24 -0
- forge/session/__init__.py +130 -0
- forge/session/active.py +339 -0
- forge/session/artifacts.py +202 -0
- forge/session/claude/__init__.py +50 -0
- forge/session/claude/cleanup.py +105 -0
- forge/session/claude/invoke.py +236 -0
- forge/session/claude/paths.py +200 -0
- forge/session/cleanup.py +216 -0
- forge/session/config.py +34 -0
- forge/session/direct_model.py +107 -0
- forge/session/effective.py +169 -0
- forge/session/exceptions.py +255 -0
- forge/session/handoff.py +881 -0
- forge/session/handoff_agent.py +544 -0
- forge/session/hooks/__init__.py +35 -0
- forge/session/hooks/models.py +73 -0
- forge/session/hooks/session_start.py +507 -0
- forge/session/identity.py +84 -0
- forge/session/index.py +553 -0
- forge/session/manager.py +1506 -0
- forge/session/models.py +572 -0
- forge/session/overrides.py +344 -0
- forge/session/plan_resolution.py +286 -0
- forge/session/prev_sessions.py +128 -0
- forge/session/store.py +431 -0
- forge/session/validation.py +47 -0
- forge/session/worktree/__init__.py +65 -0
- forge/session/worktree/cleanup.py +262 -0
- forge/session/worktree/config_copy.py +203 -0
- forge/session/worktree/create.py +332 -0
- forge/sidecar/__init__.py +29 -0
- forge/sidecar/container.py +161 -0
- forge/sidecar/docker.py +86 -0
- forge/sidecar/secrets.py +19 -0
- multi_forge-0.2.0.dist-info/METADATA +242 -0
- multi_forge-0.2.0.dist-info/RECORD +311 -0
- multi_forge-0.2.0.dist-info/WHEEL +4 -0
- multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
- multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
- 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()
|