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