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/config/loader.py
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
"""Configuration loader for Forge.
|
|
2
|
+
|
|
3
|
+
This module handles loading configuration from three sources:
|
|
4
|
+
|
|
5
|
+
1. Template (for proxy creation): defaults/templates/{t}.yaml
|
|
6
|
+
2. Proxy file (runtime): ~/.forge/proxies/{id}/proxy.yaml
|
|
7
|
+
3. Secrets (env vars): *_API_KEY, *_AUTH_URL, FORGE_HOME
|
|
8
|
+
|
|
9
|
+
Schema defaults in dataclasses handle missing fields.
|
|
10
|
+
No user/project/local config file support - proxies own full config.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import tempfile
|
|
20
|
+
from importlib.abc import Traversable
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import yaml
|
|
25
|
+
from dotenv import load_dotenv
|
|
26
|
+
from ruamel.yaml import YAML
|
|
27
|
+
|
|
28
|
+
from forge.config.dataclass_utils import dict_to_dataclass
|
|
29
|
+
from forge.config.schema import (
|
|
30
|
+
ForgeConfig,
|
|
31
|
+
ProviderConfig,
|
|
32
|
+
ProxyConfig,
|
|
33
|
+
ProxyInstanceConfig,
|
|
34
|
+
SessionConfig,
|
|
35
|
+
TierModels,
|
|
36
|
+
TierOverride,
|
|
37
|
+
TierOverrides,
|
|
38
|
+
)
|
|
39
|
+
from forge.core.paths import get_forge_home
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def deep_merge(base: dict, overlay: dict) -> dict:
|
|
45
|
+
"""Deep merge overlay into base dict (kustomize-style).
|
|
46
|
+
|
|
47
|
+
- Dicts are merged recursively
|
|
48
|
+
- Other values are replaced
|
|
49
|
+
- None values in overlay are skipped (don't override with None)
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
base: Base dictionary
|
|
53
|
+
overlay: Overlay dictionary (takes precedence)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Merged dictionary (new dict, inputs not modified)
|
|
57
|
+
"""
|
|
58
|
+
result = base.copy()
|
|
59
|
+
|
|
60
|
+
for key, overlay_value in overlay.items():
|
|
61
|
+
if overlay_value is None:
|
|
62
|
+
continue
|
|
63
|
+
|
|
64
|
+
if key in result and isinstance(result[key], dict) and isinstance(overlay_value, dict):
|
|
65
|
+
result[key] = deep_merge(result[key], overlay_value)
|
|
66
|
+
else:
|
|
67
|
+
result[key] = overlay_value
|
|
68
|
+
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_yaml(path: Path) -> dict:
|
|
73
|
+
"""Load YAML file, returning empty dict if not found.
|
|
74
|
+
|
|
75
|
+
Notes:
|
|
76
|
+
- Missing file: returns {}
|
|
77
|
+
- Invalid YAML: returns {} (best-effort)
|
|
78
|
+
|
|
79
|
+
For strict parsing (fail fast), use load_yaml_strict().
|
|
80
|
+
"""
|
|
81
|
+
if not path.exists():
|
|
82
|
+
logger.debug(f"Config file not found: {path}")
|
|
83
|
+
return {}
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
with open(path, encoding="utf-8") as f:
|
|
87
|
+
data = yaml.safe_load(f)
|
|
88
|
+
return data if isinstance(data, dict) else {}
|
|
89
|
+
except yaml.YAMLError as e:
|
|
90
|
+
logger.warning(f"Failed to parse {path}: {e}")
|
|
91
|
+
return {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_yaml_strict(path: Path) -> dict:
|
|
95
|
+
"""Load YAML file strictly.
|
|
96
|
+
|
|
97
|
+
Raises:
|
|
98
|
+
ValueError: if the file exists but cannot be parsed as a dict.
|
|
99
|
+
"""
|
|
100
|
+
if not path.exists():
|
|
101
|
+
return {}
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
with open(path, encoding="utf-8") as f:
|
|
105
|
+
data = yaml.safe_load(f)
|
|
106
|
+
except yaml.YAMLError as e:
|
|
107
|
+
raise ValueError(f"Failed to parse YAML at {path}: {e}")
|
|
108
|
+
|
|
109
|
+
if data is None:
|
|
110
|
+
return {}
|
|
111
|
+
|
|
112
|
+
if not isinstance(data, dict):
|
|
113
|
+
raise ValueError(f"YAML at {path} must be a mapping (dict), got {type(data)}")
|
|
114
|
+
|
|
115
|
+
return data
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_defaults_dir() -> Path:
|
|
119
|
+
"""Get the defaults directory (relative to this module).
|
|
120
|
+
|
|
121
|
+
.. deprecated::
|
|
122
|
+
Prefer ``list_template_names()``, ``template_exists()``, and
|
|
123
|
+
``read_template()`` for template access. This helper is retained for
|
|
124
|
+
callers that need a concrete ``Path`` (e.g. display-only).
|
|
125
|
+
"""
|
|
126
|
+
return Path(__file__).parent / "defaults"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# --- Template access helpers (C3 — importlib.resources + user templates) ---
|
|
130
|
+
# Shipped templates live in the package (importlib.resources). User templates
|
|
131
|
+
# live at ~/.forge/templates/<name>.yaml and take precedence when present.
|
|
132
|
+
# A user template is a full replacement, not a YAML merge.
|
|
133
|
+
#
|
|
134
|
+
# User templates that shadow a shipped template are created via
|
|
135
|
+
# ``forge proxy template edit``; manually placed templates without a
|
|
136
|
+
# shipped counterpart also work (advanced/manual path).
|
|
137
|
+
|
|
138
|
+
TEMPLATE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def validate_template_name(name: str) -> None:
|
|
142
|
+
"""Validate template name to prevent path traversal.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ValueError: If name contains invalid characters or patterns.
|
|
146
|
+
"""
|
|
147
|
+
if not name:
|
|
148
|
+
raise ValueError("Template name cannot be empty")
|
|
149
|
+
if not TEMPLATE_NAME_PATTERN.match(name):
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"Invalid template name '{name}': must be 1-64 characters, "
|
|
152
|
+
"start with alphanumeric, and contain only alphanumeric, underscore, dot, or hyphen"
|
|
153
|
+
)
|
|
154
|
+
if "/" in name or "\\" in name or ".." in name:
|
|
155
|
+
raise ValueError(f"Invalid template name '{name}': contains path separator or parent reference")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _templates_path() -> "Traversable":
|
|
159
|
+
"""Return a traversable path to the shipped templates package."""
|
|
160
|
+
from importlib import resources
|
|
161
|
+
|
|
162
|
+
return resources.files("forge.config.defaults.templates")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _user_templates_dir() -> Path:
|
|
166
|
+
"""Return the user templates directory (~/.forge/templates/)."""
|
|
167
|
+
return get_forge_home() / "templates"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_user_template_path(name: str) -> Path:
|
|
171
|
+
"""Return the filesystem path for a user template.
|
|
172
|
+
|
|
173
|
+
Validates the name to prevent path traversal before constructing
|
|
174
|
+
the path. All user-template filesystem operations go through this
|
|
175
|
+
function, so it is the security boundary.
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
ValueError: If name fails validation (path traversal, etc.).
|
|
179
|
+
"""
|
|
180
|
+
validate_template_name(name)
|
|
181
|
+
return _user_templates_dir() / f"{name}.yaml"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def is_user_template(name: str) -> bool:
|
|
185
|
+
"""Check if a user-customized template exists for this name."""
|
|
186
|
+
return get_user_template_path(name).is_file()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def shipped_template_exists(name: str) -> bool:
|
|
190
|
+
"""Check if a shipped (built-in) template exists, ignoring user copies."""
|
|
191
|
+
tpl = _templates_path()
|
|
192
|
+
return tpl.joinpath(f"{name}.yaml").is_file()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def read_shipped_template(name: str) -> str:
|
|
196
|
+
"""Read shipped template content, ignoring any user copy.
|
|
197
|
+
|
|
198
|
+
Used by ``forge proxy template edit`` to seed the first user copy,
|
|
199
|
+
and by display logic to show the built-in baseline.
|
|
200
|
+
|
|
201
|
+
Raises:
|
|
202
|
+
FileNotFoundError: If no shipped template with this name exists.
|
|
203
|
+
"""
|
|
204
|
+
tpl = _templates_path()
|
|
205
|
+
target = tpl.joinpath(f"{name}.yaml")
|
|
206
|
+
if not target.is_file():
|
|
207
|
+
raise FileNotFoundError(f"Shipped template not found: {name}")
|
|
208
|
+
return target.read_text(encoding="utf-8")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def list_template_names(*, include_internal: bool = False) -> list[str]:
|
|
212
|
+
"""Return sorted list of available template names (shipped + user).
|
|
213
|
+
|
|
214
|
+
User templates at ~/.forge/templates/ are merged with shipped templates.
|
|
215
|
+
Deduplication ensures each name appears once. User templates bypass the
|
|
216
|
+
``internal`` filter (only shipped templates can be marked internal).
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
include_internal: If False (default), excludes shipped templates with
|
|
220
|
+
``internal: true`` at the top level (e.g. test-only templates).
|
|
221
|
+
"""
|
|
222
|
+
import yaml
|
|
223
|
+
|
|
224
|
+
names: set[str] = set()
|
|
225
|
+
|
|
226
|
+
# Shipped templates (importlib.resources)
|
|
227
|
+
tpl = _templates_path()
|
|
228
|
+
for p in tpl.iterdir():
|
|
229
|
+
if not (hasattr(p, "name") and p.name.endswith(".yaml")):
|
|
230
|
+
continue
|
|
231
|
+
if not include_internal:
|
|
232
|
+
try:
|
|
233
|
+
data = yaml.safe_load(p.read_text(encoding="utf-8"))
|
|
234
|
+
if isinstance(data, dict) and data.get("internal") is True:
|
|
235
|
+
continue
|
|
236
|
+
except Exception:
|
|
237
|
+
pass # If we can't parse it, include it
|
|
238
|
+
names.add(p.name.removesuffix(".yaml"))
|
|
239
|
+
|
|
240
|
+
# User templates (~/.forge/templates/)
|
|
241
|
+
# Skip files with invalid names (e.g. .hidden.yaml) to avoid
|
|
242
|
+
# downstream ValueError from validate_template_name().
|
|
243
|
+
user_dir = _user_templates_dir()
|
|
244
|
+
if user_dir.is_dir():
|
|
245
|
+
for p in user_dir.iterdir():
|
|
246
|
+
if p.suffix == ".yaml" and p.is_file():
|
|
247
|
+
stem = p.stem
|
|
248
|
+
if TEMPLATE_NAME_PATTERN.match(stem) and "/" not in stem and ".." not in stem:
|
|
249
|
+
names.add(stem)
|
|
250
|
+
|
|
251
|
+
return sorted(names)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def template_exists(template: str) -> bool:
|
|
255
|
+
"""Check if a template exists (user copy or shipped)."""
|
|
256
|
+
if is_user_template(template):
|
|
257
|
+
return True
|
|
258
|
+
tpl = _templates_path()
|
|
259
|
+
return tpl.joinpath(f"{template}.yaml").is_file()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def read_template(template: str) -> str:
|
|
263
|
+
"""Read template content, preferring user copy over shipped.
|
|
264
|
+
|
|
265
|
+
Resolution order: user copy at ~/.forge/templates/ first,
|
|
266
|
+
then shipped template in the package.
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
FileNotFoundError: If template does not exist in either location.
|
|
270
|
+
"""
|
|
271
|
+
user_path = get_user_template_path(template)
|
|
272
|
+
if user_path.is_file():
|
|
273
|
+
return user_path.read_text(encoding="utf-8")
|
|
274
|
+
tpl = _templates_path()
|
|
275
|
+
target = tpl.joinpath(f"{template}.yaml")
|
|
276
|
+
if not target.is_file():
|
|
277
|
+
raise FileNotFoundError(f"Template not found: {template}")
|
|
278
|
+
return target.read_text(encoding="utf-8")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def env_to_dict() -> dict:
|
|
282
|
+
"""Map secret environment variables to config dict.
|
|
283
|
+
|
|
284
|
+
Only maps secrets (API keys, auth URLs). Configuration belongs in
|
|
285
|
+
templates/proxies, not environment variables.
|
|
286
|
+
"""
|
|
287
|
+
result: dict = {"proxy": {"gemini": {}, "openai": {}, "litellm": {}}, "session": {}}
|
|
288
|
+
|
|
289
|
+
secret_mappings = {
|
|
290
|
+
# Auth URLs (remote endpoints)
|
|
291
|
+
"GEMINI_AUTH_URL": ("proxy", "gemini", "auth_url"),
|
|
292
|
+
"OPENAI_AUTH_URL": ("proxy", "openai", "auth_url"),
|
|
293
|
+
# Forge home (user-specific path override)
|
|
294
|
+
"FORGE_HOME": ("session", "forge_home"),
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for env_key, config_path in secret_mappings.items():
|
|
298
|
+
value = os.environ.get(env_key)
|
|
299
|
+
if value is not None:
|
|
300
|
+
# Secrets are opaque strings — no type coercion (H6: "007" must not become 7)
|
|
301
|
+
_set_nested(result, config_path, value)
|
|
302
|
+
|
|
303
|
+
return result
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _set_nested(d: dict, path: tuple, value: Any) -> None:
|
|
307
|
+
"""Set a value in a nested dict using a path tuple."""
|
|
308
|
+
for key in path[:-1]:
|
|
309
|
+
if key not in d:
|
|
310
|
+
d[key] = {}
|
|
311
|
+
d = d[key]
|
|
312
|
+
d[path[-1]] = value
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# --- PROXY FILE I/O (Full Ownership Model) ---
|
|
316
|
+
|
|
317
|
+
# Proxy ID validation: alphanumeric with underscores, dots, hyphens; 1-64 chars
|
|
318
|
+
# Must start with alphanumeric. Prevents path traversal attacks.
|
|
319
|
+
PROXY_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def validate_proxy_id(proxy_id: str) -> None:
|
|
323
|
+
"""Validate proxy_id to prevent path traversal attacks.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
proxy_id: The proxy identifier to validate
|
|
327
|
+
|
|
328
|
+
Raises:
|
|
329
|
+
ValueError: If proxy_id contains invalid characters or patterns
|
|
330
|
+
"""
|
|
331
|
+
if not proxy_id:
|
|
332
|
+
raise ValueError("Proxy ID cannot be empty")
|
|
333
|
+
if not PROXY_ID_PATTERN.match(proxy_id):
|
|
334
|
+
raise ValueError(
|
|
335
|
+
f"Invalid proxy ID '{proxy_id}': must be 1-64 characters, "
|
|
336
|
+
"start with alphanumeric, and contain only alphanumeric, underscore, dot, or hyphen"
|
|
337
|
+
)
|
|
338
|
+
# Extra safety: reject any path separators or parent references
|
|
339
|
+
if "/" in proxy_id or "\\" in proxy_id or ".." in proxy_id:
|
|
340
|
+
raise ValueError(f"Invalid proxy ID '{proxy_id}': contains path separator or parent reference")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def get_proxy_file_path(proxy_id: str) -> Path:
|
|
344
|
+
"""Return the proxy file path for a proxy id (new format).
|
|
345
|
+
|
|
346
|
+
New format uses proxy.yaml instead of config.yaml overlay.
|
|
347
|
+
The user owns the entire file (no template merge at runtime).
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
proxy_id: The proxy identifier (validated for safety)
|
|
351
|
+
|
|
352
|
+
Raises:
|
|
353
|
+
ValueError: If proxy_id is invalid (path traversal prevention)
|
|
354
|
+
"""
|
|
355
|
+
validate_proxy_id(proxy_id)
|
|
356
|
+
return get_forge_home() / "proxies" / proxy_id / "proxy.yaml"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def get_template_path(template: str) -> Path:
|
|
360
|
+
"""Return the active template file path (user copy if it exists, else shipped).
|
|
361
|
+
|
|
362
|
+
Display-only — internal resolution should use read_template() or
|
|
363
|
+
read_shipped_template() instead.
|
|
364
|
+
"""
|
|
365
|
+
if is_user_template(template):
|
|
366
|
+
return get_user_template_path(template)
|
|
367
|
+
return get_defaults_dir() / "templates" / f"{template}.yaml"
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def compute_template_digest(template: str) -> str:
|
|
371
|
+
"""Compute SHA256 digest of template file content.
|
|
372
|
+
|
|
373
|
+
Args:
|
|
374
|
+
template: Template name (e.g., "litellm-openai").
|
|
375
|
+
|
|
376
|
+
Returns a truncated digest in format "sha256:abc123..." (12 hex chars).
|
|
377
|
+
This enables drift detection for future `forge proxy rebase` functionality.
|
|
378
|
+
"""
|
|
379
|
+
content = read_template(template).encode("utf-8")
|
|
380
|
+
full_hash = hashlib.sha256(content).hexdigest()
|
|
381
|
+
return f"sha256:{full_hash[:12]}"
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def load_proxy_instance_config(proxy_id: str) -> "ProxyInstanceConfig | None":
|
|
385
|
+
"""Load and parse proxy.yaml for a proxy id.
|
|
386
|
+
|
|
387
|
+
Returns None if the file doesn't exist.
|
|
388
|
+
Raises ValueError for invalid YAML or schema violations.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
proxy_id: The proxy identifier
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
ProxyInstanceConfig instance or None if not found
|
|
395
|
+
"""
|
|
396
|
+
path = get_proxy_file_path(proxy_id)
|
|
397
|
+
if not path.exists():
|
|
398
|
+
logger.debug(f"Proxy file not found: {path}")
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
ruamel = YAML()
|
|
403
|
+
ruamel.preserve_quotes = True
|
|
404
|
+
with open(path, encoding="utf-8") as f:
|
|
405
|
+
data = ruamel.load(f)
|
|
406
|
+
except Exception as e:
|
|
407
|
+
raise ValueError(f"Failed to parse proxy file {path}: {e}")
|
|
408
|
+
|
|
409
|
+
if not isinstance(data, dict):
|
|
410
|
+
raise ValueError(f"Proxy file {path} must be a mapping, got {type(data)}")
|
|
411
|
+
|
|
412
|
+
return load_proxy_instance_config_from_dict(data)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def load_proxy_instance_config_from_dict(data: dict) -> "ProxyInstanceConfig":
|
|
416
|
+
"""Parse a dict into a validated ProxyInstanceConfig.
|
|
417
|
+
|
|
418
|
+
Used by both file loading and edit/set validation (CR-006).
|
|
419
|
+
Raises ValueError/TypeError on invalid data (__post_init__ validates).
|
|
420
|
+
"""
|
|
421
|
+
tiers_data = data.get("tiers", {})
|
|
422
|
+
tiers = TierModels(
|
|
423
|
+
haiku=tiers_data.get("haiku", ""),
|
|
424
|
+
sonnet=tiers_data.get("sonnet", ""),
|
|
425
|
+
opus=tiers_data.get("opus", ""),
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
tier_overrides_data = data.get("tier_overrides", {})
|
|
429
|
+
tier_overrides = TierOverrides(
|
|
430
|
+
haiku=TierOverride(**tier_overrides_data["haiku"]) if tier_overrides_data.get("haiku") else None,
|
|
431
|
+
sonnet=TierOverride(**tier_overrides_data["sonnet"]) if tier_overrides_data.get("sonnet") else None,
|
|
432
|
+
opus=TierOverride(**tier_overrides_data["opus"]) if tier_overrides_data.get("opus") else None,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
return ProxyInstanceConfig(
|
|
436
|
+
proxy_format=data.get("proxy_format", 1),
|
|
437
|
+
template=data.get("template", ""),
|
|
438
|
+
template_digest=data.get("template_digest", ""),
|
|
439
|
+
provider=data.get("provider", ""),
|
|
440
|
+
proxy_endpoint=data.get("proxy_endpoint", ""),
|
|
441
|
+
port=data.get("port", 0),
|
|
442
|
+
upstream_base_url=data.get("upstream_base_url", ""),
|
|
443
|
+
tiers=tiers,
|
|
444
|
+
family=data.get("family", ""),
|
|
445
|
+
tier_overrides=tier_overrides,
|
|
446
|
+
model_alternatives=data.get("model_alternatives", {}),
|
|
447
|
+
default_tier=data.get("default_tier", "sonnet"),
|
|
448
|
+
provider_settings=data.get("provider_settings", {}),
|
|
449
|
+
prompt_caching=data.get("prompt_caching", "passthrough"),
|
|
450
|
+
auto_cache_min_tokens=data.get("auto_cache_min_tokens", 1024),
|
|
451
|
+
costs=data.get("costs", {}),
|
|
452
|
+
created_at=data.get("created_at"),
|
|
453
|
+
updated_at=data.get("updated_at"),
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def write_proxy_instance_config(proxy_id: str, config: "ProxyInstanceConfig") -> Path:
|
|
458
|
+
"""Write proxy config to proxy.yaml with atomic write.
|
|
459
|
+
|
|
460
|
+
Uses temp file + rename for atomicity (POSIX).
|
|
461
|
+
Sets 0600 permissions for security (configs may contain sensitive data).
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
proxy_id: The proxy identifier
|
|
465
|
+
config: ProxyInstanceConfig to write
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
Path to the written file
|
|
469
|
+
"""
|
|
470
|
+
from dataclasses import asdict
|
|
471
|
+
|
|
472
|
+
path = get_proxy_file_path(proxy_id)
|
|
473
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
474
|
+
|
|
475
|
+
data = asdict(config)
|
|
476
|
+
|
|
477
|
+
# Clean up None values from tier_overrides for cleaner YAML
|
|
478
|
+
if data.get("tier_overrides"):
|
|
479
|
+
for tier in ("haiku", "sonnet", "opus"):
|
|
480
|
+
if data["tier_overrides"].get(tier) is None:
|
|
481
|
+
del data["tier_overrides"][tier]
|
|
482
|
+
elif data["tier_overrides"].get(tier):
|
|
483
|
+
data["tier_overrides"][tier] = {k: v for k, v in data["tier_overrides"][tier].items() if v is not None}
|
|
484
|
+
if not data["tier_overrides"][tier]:
|
|
485
|
+
del data["tier_overrides"][tier]
|
|
486
|
+
|
|
487
|
+
# Use ruamel.yaml for round-trip (preserves comments on re-read)
|
|
488
|
+
ruamel = YAML()
|
|
489
|
+
ruamel.default_flow_style = False
|
|
490
|
+
ruamel.preserve_quotes = True
|
|
491
|
+
|
|
492
|
+
# Write to unique temp file in same directory (same filesystem for atomic rename)
|
|
493
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
494
|
+
dir=str(path.parent),
|
|
495
|
+
prefix=f".{path.stem}.",
|
|
496
|
+
suffix=".tmp",
|
|
497
|
+
)
|
|
498
|
+
try:
|
|
499
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
500
|
+
# Add header comment
|
|
501
|
+
f.write("# Forge Proxy Configuration\n")
|
|
502
|
+
f.write("# This file is owned by the user - edit freely\n")
|
|
503
|
+
f.write("# Use `forge proxy edit` or edit directly\n\n")
|
|
504
|
+
ruamel.dump(data, f)
|
|
505
|
+
|
|
506
|
+
# Set permissions before rename (more secure)
|
|
507
|
+
os.chmod(tmp_path, 0o600)
|
|
508
|
+
|
|
509
|
+
# Atomic replace (works across filesystems, unlike Path.rename)
|
|
510
|
+
os.replace(tmp_path, str(path))
|
|
511
|
+
|
|
512
|
+
except Exception:
|
|
513
|
+
try:
|
|
514
|
+
os.unlink(tmp_path)
|
|
515
|
+
except OSError:
|
|
516
|
+
pass
|
|
517
|
+
raise
|
|
518
|
+
|
|
519
|
+
logger.debug(f"Wrote proxy config to {path}")
|
|
520
|
+
return path
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _proxy_instance_to_forge_config(
|
|
524
|
+
proxy_config: "ProxyInstanceConfig",
|
|
525
|
+
) -> "ForgeConfig":
|
|
526
|
+
"""Convert a ProxyInstanceConfig to a ForgeConfig.
|
|
527
|
+
|
|
528
|
+
This is used when loading from the new proxy.yaml format.
|
|
529
|
+
The ProxyInstanceConfig contains everything needed to configure the proxy.
|
|
530
|
+
Secrets (auth_url) are applied from environment variables.
|
|
531
|
+
"""
|
|
532
|
+
secrets = env_to_dict()
|
|
533
|
+
|
|
534
|
+
provider = proxy_config.provider
|
|
535
|
+
auth_url: str | None = None
|
|
536
|
+
if provider == "gemini":
|
|
537
|
+
auth_url = secrets.get("proxy", {}).get("gemini", {}).get("auth_url")
|
|
538
|
+
elif provider == "openai":
|
|
539
|
+
auth_url = secrets.get("proxy", {}).get("openai", {}).get("auth_url")
|
|
540
|
+
# Note: litellm uses underlying provider auth, not a separate auth_url
|
|
541
|
+
|
|
542
|
+
provider_config = ProviderConfig(
|
|
543
|
+
tiers=proxy_config.tiers,
|
|
544
|
+
tier_overrides=proxy_config.tier_overrides,
|
|
545
|
+
model_alternatives=proxy_config.model_alternatives,
|
|
546
|
+
base_url=proxy_config.upstream_base_url,
|
|
547
|
+
auth_url=auth_url or "", # Empty string if no secret set
|
|
548
|
+
openai_api_mode=proxy_config.provider_settings.get("openai_api_mode", "auto"),
|
|
549
|
+
prompt_caching=proxy_config.prompt_caching,
|
|
550
|
+
auto_cache_min_tokens=proxy_config.auto_cache_min_tokens,
|
|
551
|
+
error_hints=proxy_config.provider_settings.get("error_hints", False),
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
proxy_server_config = ProxyConfig(
|
|
555
|
+
family=proxy_config.family,
|
|
556
|
+
preferred_provider=proxy_config.provider,
|
|
557
|
+
active_template=proxy_config.template,
|
|
558
|
+
default_tier=proxy_config.default_tier,
|
|
559
|
+
default_port=proxy_config.port,
|
|
560
|
+
costs=proxy_config.costs,
|
|
561
|
+
)
|
|
562
|
+
|
|
563
|
+
if proxy_config.provider == "gemini":
|
|
564
|
+
proxy_server_config.gemini = provider_config
|
|
565
|
+
elif proxy_config.provider == "openai":
|
|
566
|
+
proxy_server_config.openai = provider_config
|
|
567
|
+
elif proxy_config.provider == "openrouter":
|
|
568
|
+
proxy_server_config.openrouter = provider_config
|
|
569
|
+
else: # litellm is default
|
|
570
|
+
proxy_server_config.litellm = provider_config
|
|
571
|
+
|
|
572
|
+
session_config = SessionConfig()
|
|
573
|
+
if secrets.get("session", {}).get("forge_home"):
|
|
574
|
+
session_config.forge_home = secrets["session"]["forge_home"]
|
|
575
|
+
|
|
576
|
+
return ForgeConfig(
|
|
577
|
+
proxy=proxy_server_config,
|
|
578
|
+
session=session_config,
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _load_template_config(template: str) -> "ForgeConfig":
|
|
583
|
+
"""Load template config (internal use for proxy creation).
|
|
584
|
+
|
|
585
|
+
This loads a template YAML and applies secrets from environment.
|
|
586
|
+
Used by `forge proxy create` to initialize new proxies.
|
|
587
|
+
|
|
588
|
+
Args:
|
|
589
|
+
template: Template name (e.g., "litellm-gemini")
|
|
590
|
+
|
|
591
|
+
Returns:
|
|
592
|
+
ForgeConfig populated from template + secrets
|
|
593
|
+
|
|
594
|
+
Raises:
|
|
595
|
+
ValueError: If template not found
|
|
596
|
+
"""
|
|
597
|
+
if not template_exists(template):
|
|
598
|
+
raise ValueError(f"Template not found: {template}")
|
|
599
|
+
|
|
600
|
+
content = read_template(template)
|
|
601
|
+
template_data = yaml.safe_load(content)
|
|
602
|
+
if not isinstance(template_data, dict):
|
|
603
|
+
raise ValueError(f"Template '{template}' must be a mapping (dict)")
|
|
604
|
+
template_data.pop("internal", None)
|
|
605
|
+
|
|
606
|
+
proxy_block = template_data.get("proxy")
|
|
607
|
+
if not isinstance(proxy_block, dict):
|
|
608
|
+
raise ValueError(f"Template '{template}' must have a 'proxy' mapping")
|
|
609
|
+
family = proxy_block.get("family", "")
|
|
610
|
+
if not isinstance(family, str) or not family.strip():
|
|
611
|
+
raise ValueError(f"Template '{template}' missing required 'proxy.family' field (must be a non-blank string)")
|
|
612
|
+
|
|
613
|
+
secrets = env_to_dict()
|
|
614
|
+
config_dict = deep_merge(template_data, secrets)
|
|
615
|
+
|
|
616
|
+
# Set active_template so proxy knows which template is in use
|
|
617
|
+
config_dict.setdefault("proxy", {})["active_template"] = template
|
|
618
|
+
|
|
619
|
+
return dict_to_dataclass(ForgeConfig, config_dict, strict=True)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def load_config(
|
|
623
|
+
*,
|
|
624
|
+
template: str | None = None,
|
|
625
|
+
proxy_id: str | None = None,
|
|
626
|
+
) -> "ForgeConfig":
|
|
627
|
+
"""Load configuration from proxy file or template.
|
|
628
|
+
|
|
629
|
+
Three-source model:
|
|
630
|
+
1. Proxy file: ~/.forge/proxies/{id}/proxy.yaml (user owns full config)
|
|
631
|
+
2. Template: defaults/templates/{t}.yaml (for proxy creation)
|
|
632
|
+
3. Secrets: env vars (*_AUTH_URL, FORGE_HOME)
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
proxy_id: Load from ~/.forge/proxies/{id}/proxy.yaml
|
|
636
|
+
template: Load template for proxy creation (internal use)
|
|
637
|
+
|
|
638
|
+
Returns:
|
|
639
|
+
ForgeConfig instance
|
|
640
|
+
|
|
641
|
+
Raises:
|
|
642
|
+
ValueError: If proxy_id provided but proxy.yaml not found (fail fast)
|
|
643
|
+
"""
|
|
644
|
+
# Do not override already-set environment variables — tests set FORGE_HOME / endpoints explicitly.
|
|
645
|
+
load_dotenv(override=False)
|
|
646
|
+
|
|
647
|
+
if proxy_id:
|
|
648
|
+
proxy_instance_config = load_proxy_instance_config(proxy_id)
|
|
649
|
+
if proxy_instance_config is None:
|
|
650
|
+
raise ValueError(f"Proxy not found: {proxy_id}")
|
|
651
|
+
logger.debug(f"Loaded config from proxy.yaml for proxy_id={proxy_id}")
|
|
652
|
+
return _proxy_instance_to_forge_config(proxy_instance_config)
|
|
653
|
+
|
|
654
|
+
if template:
|
|
655
|
+
return _load_template_config(template)
|
|
656
|
+
|
|
657
|
+
return ForgeConfig()
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def reload_config(
|
|
661
|
+
config: "ForgeConfig",
|
|
662
|
+
*,
|
|
663
|
+
template: str | None = None,
|
|
664
|
+
proxy_id: str | None = None,
|
|
665
|
+
) -> "ForgeConfig":
|
|
666
|
+
"""Reload configuration (for runtime updates).
|
|
667
|
+
|
|
668
|
+
Re-reads the proxy file or template to pick up changes.
|
|
669
|
+
"""
|
|
670
|
+
load_dotenv(override=False)
|
|
671
|
+
|
|
672
|
+
return load_config(
|
|
673
|
+
template=template or config.proxy.active_template,
|
|
674
|
+
proxy_id=proxy_id,
|
|
675
|
+
)
|