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,622 @@
|
|
|
1
|
+
"""Shared subprocess routing primitives.
|
|
2
|
+
|
|
3
|
+
Defines ``ModelRoute`` and ``RoutingResult`` used by all subprocess
|
|
4
|
+
types (workflow workers, supervisor, handoff agent). The resolution
|
|
5
|
+
chain in ``resolve_subprocess_routing()`` replaces ad-hoc resolution
|
|
6
|
+
in each subprocess launcher.
|
|
7
|
+
|
|
8
|
+
Dependency boundary: this module imports from ``core.auth``,
|
|
9
|
+
``core.reactive.proxy``, ``proxy.proxies``, and ``config.loader``.
|
|
10
|
+
It must never import from ``forge.review.*``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
from forge.core.reactive.env import (
|
|
21
|
+
FORGE_LAUNCH_MODE_VAR,
|
|
22
|
+
FORGE_SIDECAR_VAR,
|
|
23
|
+
FORGE_SUBPROCESS_BASE_URL_VAR,
|
|
24
|
+
FORGE_SUBPROCESS_PROXY_ID_VAR,
|
|
25
|
+
FORGE_SUBPROCESS_PROXY_VAR,
|
|
26
|
+
FORGE_SUBPROCESS_TEMPLATE_VAR,
|
|
27
|
+
)
|
|
28
|
+
from forge.proxy.proxies import ProxyEntry
|
|
29
|
+
|
|
30
|
+
_log = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
RoutingSource = Literal[
|
|
33
|
+
"explicit",
|
|
34
|
+
"subprocess_proxy",
|
|
35
|
+
"preferred_proxy",
|
|
36
|
+
"route_scan",
|
|
37
|
+
"session_proxy",
|
|
38
|
+
"direct",
|
|
39
|
+
"unresolved",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class ModelRoute:
|
|
45
|
+
"""Derived routing option for a model through a specific provider/template.
|
|
46
|
+
|
|
47
|
+
Generated by ``derive_model_routes()`` in ``forge.review.routing``,
|
|
48
|
+
consumed by ``resolve_subprocess_routing()`` here for route scan
|
|
49
|
+
and compatibility validation.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
provider: str
|
|
53
|
+
credential: str
|
|
54
|
+
family: str
|
|
55
|
+
template_id: str | None
|
|
56
|
+
template_family: str | None
|
|
57
|
+
model_ref: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class RoutingResult:
|
|
62
|
+
"""Outcome of the subprocess routing resolution chain.
|
|
63
|
+
|
|
64
|
+
``route=None`` means unresolved fallback -- NOT valid direct execution.
|
|
65
|
+
Direct-capable models get a real ``ModelRoute(provider="direct")``.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
base_url: str | None
|
|
69
|
+
proxy_id: str | None
|
|
70
|
+
template: str | None
|
|
71
|
+
source: RoutingSource
|
|
72
|
+
route: ModelRoute | None
|
|
73
|
+
credential: str | None
|
|
74
|
+
warning: str | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── Proxy entry helpers ──────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_sidecar_mode() -> bool:
|
|
81
|
+
return bool(os.environ.get(FORGE_SIDECAR_VAR)) or os.environ.get(FORGE_LAUNCH_MODE_VAR) == "sidecar"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def lookup_proxy_entry(proxy: str) -> "ProxyEntry | None":
|
|
85
|
+
"""Resolve a proxy name to a full registry entry (soft, returns None)."""
|
|
86
|
+
from forge.proxy.proxies import ProxyRegistryStore, resolve_proxy_optional
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
registry = ProxyRegistryStore().read()
|
|
90
|
+
return resolve_proxy_optional(registry, proxy)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
_log.debug("Proxy lookup failed for '%s': %s", proxy, e)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def lookup_proxy_entry_strict(proxy: str) -> ProxyEntry:
|
|
97
|
+
"""Resolve a proxy name to a full registry entry (strict, raises)."""
|
|
98
|
+
from forge.proxy.proxies import ProxyRegistryStore, resolve_proxy
|
|
99
|
+
|
|
100
|
+
registry = ProxyRegistryStore().read()
|
|
101
|
+
return resolve_proxy(registry, proxy)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _check_proxy_reachable(entry: ProxyEntry, timeout_s: float = 1.0) -> bool:
|
|
105
|
+
"""HTTP health check on a proxy entry."""
|
|
106
|
+
from forge.proxy.proxy_orchestrator import check_proxy_health
|
|
107
|
+
|
|
108
|
+
return check_proxy_health(
|
|
109
|
+
base_url=entry.base_url,
|
|
110
|
+
expected_template=entry.template,
|
|
111
|
+
expected_proxy_id=entry.proxy_id,
|
|
112
|
+
timeout_s=timeout_s,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _probe_proxy_metadata(base_url: str, timeout_s: float = 1.0) -> dict[str, Any] | None:
|
|
117
|
+
"""Live GET / probe for Forge proxy metadata.
|
|
118
|
+
|
|
119
|
+
Returns {"template": ..., "proxy_id": ...} if the endpoint is a
|
|
120
|
+
Forge proxy, or None if unreachable or not a Forge proxy.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
import httpx
|
|
124
|
+
|
|
125
|
+
with httpx.Client(timeout=httpx.Timeout(timeout_s)) as client:
|
|
126
|
+
resp = client.get(f"{base_url}/")
|
|
127
|
+
if resp.status_code != 200:
|
|
128
|
+
return None
|
|
129
|
+
data = resp.json()
|
|
130
|
+
if data.get("is_proxy") is not True:
|
|
131
|
+
return None
|
|
132
|
+
result: dict[str, Any] = {}
|
|
133
|
+
if data.get("template"):
|
|
134
|
+
result["template"] = str(data["template"])
|
|
135
|
+
proxy_block = data.get("proxy", {})
|
|
136
|
+
if isinstance(proxy_block, dict) and proxy_block.get("proxy_id"):
|
|
137
|
+
result["proxy_id"] = str(proxy_block["proxy_id"])
|
|
138
|
+
if isinstance(proxy_block, dict) and not result.get("template") and proxy_block.get("template"):
|
|
139
|
+
result["template"] = str(proxy_block["template"])
|
|
140
|
+
|
|
141
|
+
advertised_models = _extract_advertised_models(data)
|
|
142
|
+
if advertised_models:
|
|
143
|
+
result["advertised_models"] = tuple(sorted(advertised_models))
|
|
144
|
+
return result if result else None
|
|
145
|
+
except Exception as e:
|
|
146
|
+
_log.debug("Session proxy GET / probe failed for %s: %s", base_url, e)
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _extract_advertised_models(data: dict[str, Any]) -> set[str]:
|
|
151
|
+
"""Extract model refs advertised by GET / tier mappings."""
|
|
152
|
+
models: set[str] = set()
|
|
153
|
+
|
|
154
|
+
def collect(mapping: Any) -> None:
|
|
155
|
+
if not isinstance(mapping, dict):
|
|
156
|
+
return
|
|
157
|
+
for value in mapping.values():
|
|
158
|
+
if isinstance(value, str) and value:
|
|
159
|
+
models.add(value)
|
|
160
|
+
elif isinstance(value, dict):
|
|
161
|
+
model = value.get("model")
|
|
162
|
+
if isinstance(model, str) and model:
|
|
163
|
+
models.add(model)
|
|
164
|
+
|
|
165
|
+
collect(data.get("tiers"))
|
|
166
|
+
runtime = data.get("runtime")
|
|
167
|
+
if isinstance(runtime, dict):
|
|
168
|
+
collect(runtime.get("tier_mappings"))
|
|
169
|
+
|
|
170
|
+
return models
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ── Compatibility validation ─────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _find_matching_route(
|
|
177
|
+
template: str,
|
|
178
|
+
routes: tuple[ModelRoute, ...],
|
|
179
|
+
) -> ModelRoute | None:
|
|
180
|
+
"""Find the first route whose template_id matches the proxy's template.
|
|
181
|
+
|
|
182
|
+
Exact template_id match only. Cross-family routing (e.g., gpt-5.5
|
|
183
|
+
through openrouter-anthropic) works because derive_model_routes()
|
|
184
|
+
already emits routes with those cross-family template_ids. Credential
|
|
185
|
+
matching alone is insufficient: litellm-gemini and litellm-openai
|
|
186
|
+
share litellm-remote but serve different model families.
|
|
187
|
+
"""
|
|
188
|
+
for route in routes:
|
|
189
|
+
if route.template_id == template:
|
|
190
|
+
return route
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _make_result_from_entry(
|
|
195
|
+
entry: ProxyEntry,
|
|
196
|
+
route: ModelRoute | None,
|
|
197
|
+
source: RoutingSource,
|
|
198
|
+
warning: str | None = None,
|
|
199
|
+
*,
|
|
200
|
+
advisory_check: bool = False,
|
|
201
|
+
) -> RoutingResult:
|
|
202
|
+
"""Build a RoutingResult from a ProxyEntry."""
|
|
203
|
+
return RoutingResult(
|
|
204
|
+
base_url=entry.base_url,
|
|
205
|
+
proxy_id=entry.proxy_id,
|
|
206
|
+
template=entry.template,
|
|
207
|
+
source=source,
|
|
208
|
+
route=route,
|
|
209
|
+
credential=route.credential if route else None,
|
|
210
|
+
warning=_route_warning(
|
|
211
|
+
route,
|
|
212
|
+
base_url=entry.base_url,
|
|
213
|
+
advisory_check=advisory_check,
|
|
214
|
+
warning=warning,
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _join_warnings(*warnings: str | None) -> str | None:
|
|
220
|
+
parts = [warning.strip() for warning in warnings if warning and warning.strip()]
|
|
221
|
+
return " ".join(parts) or None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _cross_family_warning(route: ModelRoute | None) -> str | None:
|
|
225
|
+
if route is None or route.template_id is None or route.template_family is None:
|
|
226
|
+
return None
|
|
227
|
+
if route.template_family == route.family:
|
|
228
|
+
return None
|
|
229
|
+
return (
|
|
230
|
+
f"Model family '{route.family}' is routed through template '{route.template_id}' "
|
|
231
|
+
f"({route.template_family}); tier overrides may differ."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _live_advisory_warning(
|
|
236
|
+
base_url: str,
|
|
237
|
+
route: ModelRoute | None,
|
|
238
|
+
metadata: dict[str, Any] | None = None,
|
|
239
|
+
) -> str | None:
|
|
240
|
+
if route is None:
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
metadata = metadata or _probe_proxy_metadata(base_url)
|
|
244
|
+
if not metadata:
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
advertised = metadata.get("advertised_models")
|
|
248
|
+
if not isinstance(advertised, (tuple, list, set, frozenset)):
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
advertised_models = {str(model) for model in advertised if model}
|
|
252
|
+
if advertised_models and route.model_ref not in advertised_models:
|
|
253
|
+
return (
|
|
254
|
+
f"Proxy tier mappings do not advertise model '{route.model_ref}'; "
|
|
255
|
+
"request routing may still work, but tier overrides may not apply."
|
|
256
|
+
)
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _route_warning(
|
|
261
|
+
route: ModelRoute | None,
|
|
262
|
+
*,
|
|
263
|
+
base_url: str | None = None,
|
|
264
|
+
metadata: dict[str, Any] | None = None,
|
|
265
|
+
advisory_check: bool = False,
|
|
266
|
+
warning: str | None = None,
|
|
267
|
+
) -> str | None:
|
|
268
|
+
live_warning = _live_advisory_warning(base_url, route, metadata) if advisory_check and base_url else None
|
|
269
|
+
return _join_warnings(warning, _cross_family_warning(route), live_warning)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ── Resolution chain ─────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class ProxyRoutingError(ValueError):
|
|
276
|
+
"""Raised when a proxy fails strict validation during routing."""
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def resolve_subprocess_routing(
|
|
280
|
+
explicit_base_url: str | None = None,
|
|
281
|
+
explicit_proxy: str | None = None,
|
|
282
|
+
preferred_proxy: str | None = None,
|
|
283
|
+
routes: tuple[ModelRoute, ...] = (),
|
|
284
|
+
*,
|
|
285
|
+
require_route: bool = False,
|
|
286
|
+
use_environment: bool = True,
|
|
287
|
+
advisory_check: bool = False,
|
|
288
|
+
) -> RoutingResult:
|
|
289
|
+
"""Unified routing resolution for all Forge subprocesses.
|
|
290
|
+
|
|
291
|
+
Walks the resolution chain and returns a structured route/proxy decision.
|
|
292
|
+
Callers decide fail-open vs fail-closed based on ``source`` and their
|
|
293
|
+
use case.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
explicit_base_url: Highest-priority URL override (supervisor's
|
|
297
|
+
``config.base_url``). Opaque -- no compatibility check.
|
|
298
|
+
explicit_proxy: User-chosen proxy (``--proxy``, ``--supervisor-proxy``).
|
|
299
|
+
Strict: hard error if missing, unreachable, or incompatible.
|
|
300
|
+
preferred_proxy: Catalog hint (``ModelSpec.preferred_proxy``).
|
|
301
|
+
Soft: warn and continue if missing.
|
|
302
|
+
routes: Derived model routes for compatibility and route scan.
|
|
303
|
+
require_route: When True (workflows), opaque session proxy without
|
|
304
|
+
a matching route is treated as unresolved. When False
|
|
305
|
+
(supervisor, handoff), it is accepted as-is.
|
|
306
|
+
use_environment: When False, skip ambient FORGE_SUBPROCESS_PROXY
|
|
307
|
+
and ANTHROPIC_BASE_URL lookups. Useful for callers that need
|
|
308
|
+
to preserve their own explicit fallback ordering.
|
|
309
|
+
advisory_check: When True, perform non-blocking live GET / checks
|
|
310
|
+
on selected proxies to surface tier-mapping compatibility warnings.
|
|
311
|
+
"""
|
|
312
|
+
sidecar = _is_sidecar_mode()
|
|
313
|
+
|
|
314
|
+
# Step 1: Explicit base URL (highest priority, opaque)
|
|
315
|
+
if explicit_base_url:
|
|
316
|
+
return RoutingResult(
|
|
317
|
+
base_url=explicit_base_url,
|
|
318
|
+
proxy_id=None,
|
|
319
|
+
template=None,
|
|
320
|
+
source="explicit",
|
|
321
|
+
route=None,
|
|
322
|
+
credential=None,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
# Step 2: Explicit proxy (strict)
|
|
326
|
+
if explicit_proxy:
|
|
327
|
+
if sidecar:
|
|
328
|
+
injected = _resolve_injected_subprocess_proxy(
|
|
329
|
+
expected_proxy=explicit_proxy,
|
|
330
|
+
routes=routes,
|
|
331
|
+
source="explicit",
|
|
332
|
+
require_route=require_route,
|
|
333
|
+
advisory_check=advisory_check,
|
|
334
|
+
)
|
|
335
|
+
if injected is not None:
|
|
336
|
+
return injected
|
|
337
|
+
raise ProxyRoutingError(
|
|
338
|
+
f"Proxy '{explicit_proxy}' cannot be resolved inside sidecar.\n"
|
|
339
|
+
"Tip: Start the session with '--subprocess-proxy' or run the workflow on the host."
|
|
340
|
+
)
|
|
341
|
+
return _resolve_strict_proxy(explicit_proxy, routes, "explicit", advisory_check=advisory_check)
|
|
342
|
+
|
|
343
|
+
# Step 3: Subprocess proxy (strict)
|
|
344
|
+
subprocess_proxy = os.environ.get(FORGE_SUBPROCESS_PROXY_VAR) if use_environment else None
|
|
345
|
+
if subprocess_proxy:
|
|
346
|
+
if sidecar:
|
|
347
|
+
injected = _resolve_injected_subprocess_proxy(
|
|
348
|
+
expected_proxy=subprocess_proxy,
|
|
349
|
+
routes=routes,
|
|
350
|
+
source="subprocess_proxy",
|
|
351
|
+
require_route=require_route,
|
|
352
|
+
advisory_check=advisory_check,
|
|
353
|
+
)
|
|
354
|
+
if injected is not None:
|
|
355
|
+
return injected
|
|
356
|
+
raise ProxyRoutingError(
|
|
357
|
+
f"Subprocess proxy '{subprocess_proxy}' configured but not resolvable inside sidecar.\n"
|
|
358
|
+
"Tip: Ensure the host injects FORGE_SUBPROCESS_BASE_URL into the sidecar environment."
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
return _resolve_strict_proxy(subprocess_proxy, routes, "subprocess_proxy", advisory_check=advisory_check)
|
|
362
|
+
elif sidecar:
|
|
363
|
+
injected = _resolve_injected_subprocess_proxy(
|
|
364
|
+
expected_proxy=None,
|
|
365
|
+
routes=routes,
|
|
366
|
+
source="subprocess_proxy",
|
|
367
|
+
require_route=require_route,
|
|
368
|
+
advisory_check=advisory_check,
|
|
369
|
+
)
|
|
370
|
+
if injected is not None:
|
|
371
|
+
return injected
|
|
372
|
+
|
|
373
|
+
# Step 4: Preferred proxy (soft)
|
|
374
|
+
if preferred_proxy and not sidecar:
|
|
375
|
+
result = _resolve_soft_proxy(preferred_proxy, routes, advisory_check=advisory_check)
|
|
376
|
+
if result is not None:
|
|
377
|
+
return result
|
|
378
|
+
|
|
379
|
+
# Step 5: Route scan (registry-based)
|
|
380
|
+
if routes and not sidecar:
|
|
381
|
+
result = _scan_routes_for_proxy(routes, advisory_check=advisory_check)
|
|
382
|
+
if result is not None:
|
|
383
|
+
return result
|
|
384
|
+
|
|
385
|
+
# Step 6: Session proxy (inherited ANTHROPIC_BASE_URL)
|
|
386
|
+
session_base_url = os.environ.get("ANTHROPIC_BASE_URL") if use_environment else None
|
|
387
|
+
if session_base_url:
|
|
388
|
+
return _resolve_session_proxy(session_base_url, routes, require_route, advisory_check=advisory_check)
|
|
389
|
+
|
|
390
|
+
# Step 7: Unresolved
|
|
391
|
+
return RoutingResult(
|
|
392
|
+
base_url=None,
|
|
393
|
+
proxy_id=None,
|
|
394
|
+
template=None,
|
|
395
|
+
source="unresolved",
|
|
396
|
+
route=None,
|
|
397
|
+
credential=None,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _resolve_strict_proxy(
|
|
402
|
+
proxy: str,
|
|
403
|
+
routes: tuple[ModelRoute, ...],
|
|
404
|
+
source: RoutingSource,
|
|
405
|
+
*,
|
|
406
|
+
advisory_check: bool = False,
|
|
407
|
+
) -> RoutingResult:
|
|
408
|
+
"""Resolve a user-chosen proxy with strict validation."""
|
|
409
|
+
entry = lookup_proxy_entry_strict(proxy)
|
|
410
|
+
|
|
411
|
+
if not _check_proxy_reachable(entry):
|
|
412
|
+
raise ProxyRoutingError(
|
|
413
|
+
f"Proxy '{proxy}' is not reachable at {entry.base_url}.\n"
|
|
414
|
+
f"Tip: Run 'forge proxy start {proxy}' or check if the process is running."
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
route = _find_matching_route(entry.template, routes)
|
|
418
|
+
if routes and route is None:
|
|
419
|
+
template_desc = entry.template
|
|
420
|
+
route_desc = ", ".join(f"{r.template_id}/{r.credential}" for r in routes if r.template_id)
|
|
421
|
+
raise ProxyRoutingError(
|
|
422
|
+
f"Proxy '{proxy}' (template: {template_desc}) is not compatible with this model.\n"
|
|
423
|
+
f" Compatible routes: {route_desc or '(direct only)'}\n"
|
|
424
|
+
f"Tip: Use '--proxy' with a compatible proxy, or 'forge proxy create <template>'."
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
return _make_result_from_entry(entry, route, source, advisory_check=advisory_check)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _resolve_soft_proxy(
|
|
431
|
+
proxy: str,
|
|
432
|
+
routes: tuple[ModelRoute, ...],
|
|
433
|
+
*,
|
|
434
|
+
advisory_check: bool = False,
|
|
435
|
+
) -> RoutingResult | None:
|
|
436
|
+
"""Resolve a catalog-recommended proxy (warn and skip on failure)."""
|
|
437
|
+
entry = lookup_proxy_entry(proxy)
|
|
438
|
+
if entry is None:
|
|
439
|
+
_log.debug("Preferred proxy '%s' not found in registry; skipping", proxy)
|
|
440
|
+
return None
|
|
441
|
+
|
|
442
|
+
if not _check_proxy_reachable(entry):
|
|
443
|
+
_log.debug("Preferred proxy '%s' not reachable; skipping", proxy)
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
route = _find_matching_route(entry.template, routes)
|
|
447
|
+
if routes and route is None:
|
|
448
|
+
_log.debug("Preferred proxy '%s' not compatible with model routes; skipping", proxy)
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
return _make_result_from_entry(entry, route, "preferred_proxy", advisory_check=advisory_check)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _scan_routes_for_proxy(
|
|
455
|
+
routes: tuple[ModelRoute, ...],
|
|
456
|
+
*,
|
|
457
|
+
advisory_check: bool = False,
|
|
458
|
+
) -> RoutingResult | None:
|
|
459
|
+
"""Find a running proxy that matches one of the model's derived routes.
|
|
460
|
+
|
|
461
|
+
Only returns reachable proxies (HTTP health check). Ranking:
|
|
462
|
+
1. Route preference order (from derive_model_routes)
|
|
463
|
+
2. Alphabetical proxy_id tiebreaker
|
|
464
|
+
"""
|
|
465
|
+
from forge.proxy.proxies import ROUTABLE_STATUSES, ProxyRegistryStore
|
|
466
|
+
|
|
467
|
+
try:
|
|
468
|
+
registry = ProxyRegistryStore().read()
|
|
469
|
+
except Exception as e:
|
|
470
|
+
_log.debug("Route scan: registry read failed: %s", e)
|
|
471
|
+
return None
|
|
472
|
+
|
|
473
|
+
template_to_routes: dict[str, ModelRoute] = {}
|
|
474
|
+
for route in routes:
|
|
475
|
+
if route.template_id and route.template_id not in template_to_routes:
|
|
476
|
+
template_to_routes[route.template_id] = route
|
|
477
|
+
|
|
478
|
+
if not template_to_routes:
|
|
479
|
+
return None
|
|
480
|
+
|
|
481
|
+
candidates: list[tuple[int, str, ProxyEntry, ModelRoute]] = []
|
|
482
|
+
|
|
483
|
+
for entry in registry.proxies.values():
|
|
484
|
+
if entry.status not in ROUTABLE_STATUSES:
|
|
485
|
+
continue
|
|
486
|
+
|
|
487
|
+
matched_route = template_to_routes.get(entry.template)
|
|
488
|
+
if matched_route is None:
|
|
489
|
+
continue
|
|
490
|
+
|
|
491
|
+
if not _check_proxy_reachable(entry, timeout_s=1.0):
|
|
492
|
+
_log.debug("Route scan: proxy '%s' matches but is not reachable", entry.proxy_id)
|
|
493
|
+
continue
|
|
494
|
+
|
|
495
|
+
route_rank = next(
|
|
496
|
+
(i for i, r in enumerate(routes) if r.template_id == entry.template),
|
|
497
|
+
len(routes),
|
|
498
|
+
)
|
|
499
|
+
candidates.append((route_rank, entry.proxy_id, entry, matched_route))
|
|
500
|
+
|
|
501
|
+
if not candidates:
|
|
502
|
+
return None
|
|
503
|
+
|
|
504
|
+
candidates.sort(key=lambda c: (c[0], c[1]))
|
|
505
|
+
_, _, best_entry, best_route = candidates[0]
|
|
506
|
+
|
|
507
|
+
return _make_result_from_entry(best_entry, best_route, "route_scan", advisory_check=advisory_check)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _resolve_injected_subprocess_proxy(
|
|
511
|
+
*,
|
|
512
|
+
expected_proxy: str | None,
|
|
513
|
+
routes: tuple[ModelRoute, ...],
|
|
514
|
+
source: RoutingSource,
|
|
515
|
+
require_route: bool,
|
|
516
|
+
advisory_check: bool,
|
|
517
|
+
) -> RoutingResult | None:
|
|
518
|
+
"""Resolve host-injected subprocess proxy metadata inside sidecar mode."""
|
|
519
|
+
base_url = os.environ.get(FORGE_SUBPROCESS_BASE_URL_VAR)
|
|
520
|
+
if not base_url:
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
proxy_id = os.environ.get(FORGE_SUBPROCESS_PROXY_ID_VAR) or None
|
|
524
|
+
template = os.environ.get(FORGE_SUBPROCESS_TEMPLATE_VAR) or None
|
|
525
|
+
if expected_proxy and expected_proxy not in {value for value in (proxy_id, template) if value}:
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
route = _find_matching_route(template, routes) if template and routes else None
|
|
529
|
+
if require_route and routes and route is None:
|
|
530
|
+
route_desc = ", ".join(f"{r.template_id}/{r.credential}" for r in routes if r.template_id)
|
|
531
|
+
raise ProxyRoutingError(
|
|
532
|
+
"Injected subprocess proxy metadata is not compatible with this model.\n"
|
|
533
|
+
f" Proxy: {proxy_id or expected_proxy or '(unknown)'}"
|
|
534
|
+
f"{f' (template: {template})' if template else ''}\n"
|
|
535
|
+
f" Compatible routes: {route_desc or '(direct only)'}"
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
return RoutingResult(
|
|
539
|
+
base_url=base_url,
|
|
540
|
+
proxy_id=proxy_id,
|
|
541
|
+
template=template,
|
|
542
|
+
source=source,
|
|
543
|
+
route=route,
|
|
544
|
+
credential=route.credential if route else None,
|
|
545
|
+
warning=_route_warning(route, base_url=base_url, advisory_check=advisory_check),
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _resolve_session_proxy(
|
|
550
|
+
base_url: str,
|
|
551
|
+
routes: tuple[ModelRoute, ...],
|
|
552
|
+
require_route: bool,
|
|
553
|
+
*,
|
|
554
|
+
advisory_check: bool = False,
|
|
555
|
+
) -> RoutingResult:
|
|
556
|
+
"""Resolve inherited ANTHROPIC_BASE_URL with registry + live metadata probe."""
|
|
557
|
+
from forge.proxy.proxies import ProxyRegistryStore, lookup_proxy_by_base_url
|
|
558
|
+
|
|
559
|
+
proxy_id: str | None = None
|
|
560
|
+
template: str | None = None
|
|
561
|
+
route: ModelRoute | None = None
|
|
562
|
+
warning: str | None = None
|
|
563
|
+
probed: dict[str, Any] | None = None
|
|
564
|
+
|
|
565
|
+
# Try registry lookup first (cheapest)
|
|
566
|
+
try:
|
|
567
|
+
registry = ProxyRegistryStore().read()
|
|
568
|
+
entry = lookup_proxy_by_base_url(registry, base_url)
|
|
569
|
+
if entry:
|
|
570
|
+
if require_route and not _check_proxy_reachable(entry):
|
|
571
|
+
warning = (
|
|
572
|
+
f"Session proxy registry match '{entry.proxy_id}' at {base_url} did not pass health validation; "
|
|
573
|
+
"treating as unresolved unless live metadata matches."
|
|
574
|
+
)
|
|
575
|
+
_log.debug("Session proxy registry match '%s' not reachable; probing live metadata", entry.proxy_id)
|
|
576
|
+
else:
|
|
577
|
+
proxy_id = entry.proxy_id
|
|
578
|
+
template = entry.template
|
|
579
|
+
route = _find_matching_route(entry.template, routes)
|
|
580
|
+
except Exception as e:
|
|
581
|
+
_log.debug("Session proxy registry lookup failed: %s", e)
|
|
582
|
+
|
|
583
|
+
# If registry miss, try live GET / probe for Forge proxy metadata
|
|
584
|
+
if template is None:
|
|
585
|
+
probed = _probe_proxy_metadata(base_url)
|
|
586
|
+
if probed:
|
|
587
|
+
probed_template = probed.get("template")
|
|
588
|
+
probed_proxy_id = probed.get("proxy_id")
|
|
589
|
+
template = probed_template if isinstance(probed_template, str) else None
|
|
590
|
+
proxy_id = proxy_id or (probed_proxy_id if isinstance(probed_proxy_id, str) else None)
|
|
591
|
+
if template and routes:
|
|
592
|
+
route = _find_matching_route(template, routes)
|
|
593
|
+
|
|
594
|
+
if require_route and route is None:
|
|
595
|
+
return RoutingResult(
|
|
596
|
+
base_url=None,
|
|
597
|
+
proxy_id=None,
|
|
598
|
+
template=None,
|
|
599
|
+
source="unresolved",
|
|
600
|
+
route=None,
|
|
601
|
+
credential=None,
|
|
602
|
+
warning=_join_warnings(
|
|
603
|
+
warning,
|
|
604
|
+
f"Session proxy at {base_url} has no matching route; treating as unresolved.",
|
|
605
|
+
),
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
return RoutingResult(
|
|
609
|
+
base_url=base_url,
|
|
610
|
+
proxy_id=proxy_id,
|
|
611
|
+
template=template,
|
|
612
|
+
source="session_proxy",
|
|
613
|
+
route=route,
|
|
614
|
+
credential=route.credential if route else None,
|
|
615
|
+
warning=_route_warning(
|
|
616
|
+
route,
|
|
617
|
+
base_url=base_url,
|
|
618
|
+
metadata=probed,
|
|
619
|
+
advisory_check=advisory_check,
|
|
620
|
+
warning=warning,
|
|
621
|
+
),
|
|
622
|
+
)
|