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,243 @@
|
|
|
1
|
+
"""SecretsProvider protocol and implementations.
|
|
2
|
+
|
|
3
|
+
This module provides a unified interface for accessing secrets (API keys,
|
|
4
|
+
auth URLs) from multiple sources with explicit precedence.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from forge.core.auth import EnvSecretsProvider, ChainSecretsProvider
|
|
8
|
+
|
|
9
|
+
# Simple env-only access
|
|
10
|
+
secrets = EnvSecretsProvider()
|
|
11
|
+
api_key = secrets.require("ANTHROPIC_API_KEY")
|
|
12
|
+
|
|
13
|
+
# Chain with file-based credentials
|
|
14
|
+
from forge.core.auth.secrets import FileSecretsProvider
|
|
15
|
+
secrets = ChainSecretsProvider(
|
|
16
|
+
EnvSecretsProvider(), # Env wins (user can override)
|
|
17
|
+
FileSecretsProvider(), # File-based fallback
|
|
18
|
+
)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import logging
|
|
24
|
+
import os
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from forge.config.schema import ForgeConfig
|
|
29
|
+
from forge.core.auth.protocols import SecretsProvider
|
|
30
|
+
from forge.core.llm.errors import NoApiKeyError
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _format_missing_credential_detail(
|
|
36
|
+
key: str,
|
|
37
|
+
*,
|
|
38
|
+
profile: str | None = None,
|
|
39
|
+
env_ignored: bool = False,
|
|
40
|
+
) -> str | None:
|
|
41
|
+
"""Best-effort actionable message for known credential env vars."""
|
|
42
|
+
try:
|
|
43
|
+
from forge.core.auth.capabilities import (
|
|
44
|
+
credential_for_env_var,
|
|
45
|
+
format_missing_credential_error,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
credential = credential_for_env_var(key)
|
|
49
|
+
if credential is None:
|
|
50
|
+
return None
|
|
51
|
+
return format_missing_credential_error(
|
|
52
|
+
credential,
|
|
53
|
+
missing_vars=[key],
|
|
54
|
+
profile=profile,
|
|
55
|
+
env_ignored=env_ignored,
|
|
56
|
+
)
|
|
57
|
+
except Exception as e:
|
|
58
|
+
logger.debug("Could not format missing credential detail for %s: %s", key, e)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class EnvSecretsProvider:
|
|
63
|
+
"""Reads secrets from os.environ.
|
|
64
|
+
|
|
65
|
+
Expects dotenv to already be loaded (by CLI main or config loader).
|
|
66
|
+
Does NOT call load_dotenv() itself to avoid import-time side effects.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
ignore_env: When True, all lookups return the default value.
|
|
70
|
+
Used by ``auth_ignore_env`` to bypass shell env vars.
|
|
71
|
+
When None (default), reads from runtime config on each call
|
|
72
|
+
so config changes take effect without restarting.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self, *, ignore_env: bool | None = None) -> None:
|
|
76
|
+
self._ignore_env = ignore_env
|
|
77
|
+
|
|
78
|
+
def _should_ignore(self) -> bool:
|
|
79
|
+
if self._ignore_env is not None:
|
|
80
|
+
return self._ignore_env
|
|
81
|
+
try:
|
|
82
|
+
from forge.runtime_config import get_runtime_config
|
|
83
|
+
|
|
84
|
+
return get_runtime_config().auth_ignore_env
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.debug("Could not read auth_ignore_env; using environment credentials: %s", e)
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
90
|
+
"""Get secret from environment, returning default if not found or empty."""
|
|
91
|
+
if self._should_ignore():
|
|
92
|
+
return default
|
|
93
|
+
value = os.environ.get(key)
|
|
94
|
+
# Treat empty string as not-set (consistent with config schema defaults)
|
|
95
|
+
return value if value else default
|
|
96
|
+
|
|
97
|
+
def require(self, key: str) -> str:
|
|
98
|
+
"""Get required secret from environment, raising if not found or empty."""
|
|
99
|
+
if self._should_ignore():
|
|
100
|
+
raise NoApiKeyError(
|
|
101
|
+
provider="env",
|
|
102
|
+
env_var=key,
|
|
103
|
+
detail=_format_missing_credential_detail(key, env_ignored=True),
|
|
104
|
+
)
|
|
105
|
+
value = os.environ.get(key)
|
|
106
|
+
if not value:
|
|
107
|
+
raise NoApiKeyError(
|
|
108
|
+
provider="env",
|
|
109
|
+
env_var=key,
|
|
110
|
+
detail=_format_missing_credential_detail(key),
|
|
111
|
+
)
|
|
112
|
+
return value
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ConfigSecretsProvider:
|
|
116
|
+
"""Reads secrets injected into ForgeConfig by the config loader.
|
|
117
|
+
|
|
118
|
+
The config loader maps certain env vars into ForgeConfig fields:
|
|
119
|
+
- OPENAI_AUTH_URL -> config.proxy.openai.auth_url
|
|
120
|
+
- GEMINI_AUTH_URL -> config.proxy.gemini.auth_url
|
|
121
|
+
|
|
122
|
+
This provider reads those config paths, allowing env vars to be the
|
|
123
|
+
primary source while config-injected values serve as fallbacks.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
config: ForgeConfig instance (explicitly injected to avoid circular deps)
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
# Mapping of secret keys to config accessor lambdas
|
|
130
|
+
_KEY_MAPPING: dict[str, str] = {
|
|
131
|
+
"OPENAI_AUTH_URL": "proxy.openai.auth_url",
|
|
132
|
+
"GEMINI_AUTH_URL": "proxy.gemini.auth_url",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
def __init__(self, config: ForgeConfig) -> None:
|
|
136
|
+
self._config = config
|
|
137
|
+
|
|
138
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
139
|
+
"""Get secret from config-injected value, returning default if not found."""
|
|
140
|
+
if key not in self._KEY_MAPPING:
|
|
141
|
+
return default
|
|
142
|
+
|
|
143
|
+
# Navigate the config path
|
|
144
|
+
path = self._KEY_MAPPING[key]
|
|
145
|
+
value = self._get_nested_attr(path)
|
|
146
|
+
|
|
147
|
+
# Treat empty string as not-set
|
|
148
|
+
return value if value else default
|
|
149
|
+
|
|
150
|
+
def require(self, key: str) -> str:
|
|
151
|
+
"""Get required secret from config, raising if not found or empty."""
|
|
152
|
+
value = self.get(key)
|
|
153
|
+
if not value:
|
|
154
|
+
raise NoApiKeyError(
|
|
155
|
+
provider="config",
|
|
156
|
+
env_var=key,
|
|
157
|
+
detail=_format_missing_credential_detail(key),
|
|
158
|
+
)
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
def _get_nested_attr(self, path: str) -> Any:
|
|
162
|
+
"""Navigate dotted path on config object."""
|
|
163
|
+
obj: Any = self._config
|
|
164
|
+
for part in path.split("."):
|
|
165
|
+
obj = getattr(obj, part, None)
|
|
166
|
+
if obj is None:
|
|
167
|
+
return None
|
|
168
|
+
return obj
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class FileSecretsProvider:
|
|
172
|
+
"""Read secrets from ~/.forge/credentials.yaml for a named profile.
|
|
173
|
+
|
|
174
|
+
Reads from disk on each call (no caching) — CredentialManager's TTL
|
|
175
|
+
cache gates call frequency. This ensures freshly-saved credentials
|
|
176
|
+
(via ``forge auth login``) are picked up without restart.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def __init__(self, profile: str | None = None, *, path: Path | None = None) -> None:
|
|
180
|
+
from forge.core.auth.credentials_file import resolve_profile
|
|
181
|
+
|
|
182
|
+
self._profile = resolve_profile(profile)
|
|
183
|
+
self._path = path
|
|
184
|
+
|
|
185
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
186
|
+
"""Get secret from credential file, returning default if not found or empty."""
|
|
187
|
+
from forge.core.auth.credentials_file import load_profile
|
|
188
|
+
|
|
189
|
+
secrets = load_profile(self._profile, path=self._path)
|
|
190
|
+
value = secrets.get(key)
|
|
191
|
+
return value if value else default
|
|
192
|
+
|
|
193
|
+
def require(self, key: str) -> str:
|
|
194
|
+
"""Get required secret from credential file, raising if not found or empty."""
|
|
195
|
+
value = self.get(key)
|
|
196
|
+
if not value:
|
|
197
|
+
raise NoApiKeyError(
|
|
198
|
+
provider=f"file:{self._profile}",
|
|
199
|
+
env_var=key,
|
|
200
|
+
detail=_format_missing_credential_detail(key, profile=self._profile),
|
|
201
|
+
)
|
|
202
|
+
return value
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class ChainSecretsProvider:
|
|
206
|
+
"""Chain of providers with explicit precedence.
|
|
207
|
+
|
|
208
|
+
Returns the first truthy (non-empty) value found across the provider chain.
|
|
209
|
+
Both None and empty string "" are treated as "not set".
|
|
210
|
+
|
|
211
|
+
Typical usage:
|
|
212
|
+
secrets = ChainSecretsProvider(
|
|
213
|
+
EnvSecretsProvider(), # Env wins
|
|
214
|
+
ConfigSecretsProvider(config), # Config fallback
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
*providers: SecretsProvider instances in priority order (first wins)
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
def __init__(self, *providers: SecretsProvider) -> None:
|
|
222
|
+
if not providers:
|
|
223
|
+
raise ValueError("ChainSecretsProvider requires at least one provider")
|
|
224
|
+
self._providers = providers
|
|
225
|
+
|
|
226
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
227
|
+
"""Get secret from first provider that has a truthy value."""
|
|
228
|
+
for provider in self._providers:
|
|
229
|
+
value = provider.get(key)
|
|
230
|
+
if value: # Truthy check: treats "" and None as not-set
|
|
231
|
+
return value
|
|
232
|
+
return default
|
|
233
|
+
|
|
234
|
+
def require(self, key: str) -> str:
|
|
235
|
+
"""Get required secret, raising if no provider has a truthy value."""
|
|
236
|
+
value = self.get(key)
|
|
237
|
+
if not value:
|
|
238
|
+
raise NoApiKeyError(
|
|
239
|
+
provider="chain",
|
|
240
|
+
env_var=key,
|
|
241
|
+
detail=_format_missing_credential_detail(key),
|
|
242
|
+
)
|
|
243
|
+
return value
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Template-to-credential mapping and credential resolution.
|
|
2
|
+
|
|
3
|
+
Maps proxy templates to required environment variable names and provides
|
|
4
|
+
``resolve_env_or_credential()`` — the single lookup that checks os.environ
|
|
5
|
+
first, then falls back to ``~/.forge/credentials.yaml``.
|
|
6
|
+
|
|
7
|
+
Extracted from ``forge.sidecar.secrets`` so proxy orchestration, review
|
|
8
|
+
engine, and sidecar can all share the same resolution logic.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
TEMPLATE_SECRETS: dict[str, list[str]] = {
|
|
19
|
+
"litellm-openai": ["LITELLM_API_KEY", "LITELLM_BASE_URL"],
|
|
20
|
+
"litellm-gemini": ["LITELLM_API_KEY", "LITELLM_BASE_URL"],
|
|
21
|
+
"litellm-anthropic": ["LITELLM_API_KEY", "LITELLM_BASE_URL"],
|
|
22
|
+
"litellm-gemini-local": ["GEMINI_API_KEY"],
|
|
23
|
+
"litellm-gemini-test": ["GEMINI_API_KEY"],
|
|
24
|
+
"litellm-gemini-flash-local": ["GEMINI_API_KEY"],
|
|
25
|
+
"litellm-openai-local": ["OPENAI_API_KEY"],
|
|
26
|
+
"litellm-openai-codex-local": ["OPENAI_API_KEY"],
|
|
27
|
+
"litellm-anthropic-local": ["ANTHROPIC_API_KEY"],
|
|
28
|
+
"openrouter-anthropic": ["OPENROUTER_API_KEY"],
|
|
29
|
+
"openrouter-openai": ["OPENROUTER_API_KEY"],
|
|
30
|
+
"openrouter-gemini": ["OPENROUTER_API_KEY"],
|
|
31
|
+
"openrouter-openai-codex": ["OPENROUTER_API_KEY"],
|
|
32
|
+
"openrouter-gemini-flash": ["OPENROUTER_API_KEY"],
|
|
33
|
+
"openrouter-deepseek": ["OPENROUTER_API_KEY"],
|
|
34
|
+
"openrouter-kimi": ["OPENROUTER_API_KEY"],
|
|
35
|
+
"openrouter-glm": ["OPENROUTER_API_KEY"],
|
|
36
|
+
"openrouter-minimax": ["OPENROUTER_API_KEY"],
|
|
37
|
+
"openrouter-qwen": ["OPENROUTER_API_KEY"],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_file_secrets() -> dict[str, str]:
|
|
42
|
+
"""Load all secrets from the credential file for the active profile.
|
|
43
|
+
|
|
44
|
+
Returns empty dict on any error so callers never fail due to
|
|
45
|
+
credential file issues.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
from forge.core.auth.credentials_file import load_profile, resolve_profile
|
|
49
|
+
|
|
50
|
+
profile = resolve_profile()
|
|
51
|
+
return load_profile(profile)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
logger.debug("Credential file load failed (non-critical): %s", e)
|
|
54
|
+
return {}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _auth_ignore_env() -> bool:
|
|
58
|
+
"""Check if auth_ignore_env is active (lazy import to avoid cycles)."""
|
|
59
|
+
try:
|
|
60
|
+
from forge.runtime_config import get_runtime_config
|
|
61
|
+
|
|
62
|
+
return get_runtime_config().auth_ignore_env
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.debug("Could not read auth_ignore_env; using environment credentials: %s", e)
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_env_or_credential(var_name: str) -> str | None:
|
|
69
|
+
"""Resolve a single value from environment, then credential file.
|
|
70
|
+
|
|
71
|
+
When ``auth_ignore_env`` is active, skips os.environ and reads from
|
|
72
|
+
the credential file only.
|
|
73
|
+
|
|
74
|
+
Returns the first truthy (non-empty) value found, or None.
|
|
75
|
+
"""
|
|
76
|
+
if not _auth_ignore_env():
|
|
77
|
+
value = os.environ.get(var_name)
|
|
78
|
+
if value:
|
|
79
|
+
return value
|
|
80
|
+
return _get_file_secrets().get(var_name) or None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_secrets_for_template(template: str) -> dict[str, str]:
|
|
84
|
+
"""Get credentials required by a template.
|
|
85
|
+
|
|
86
|
+
Resolves each key from environment first, then falls back to the
|
|
87
|
+
credential file. When ``auth_ignore_env`` is active, skips environment.
|
|
88
|
+
Only includes values that resolve to non-empty strings.
|
|
89
|
+
"""
|
|
90
|
+
required = TEMPLATE_SECRETS.get(template, [])
|
|
91
|
+
if not required:
|
|
92
|
+
return {}
|
|
93
|
+
|
|
94
|
+
ignore_env = _auth_ignore_env()
|
|
95
|
+
secrets: dict[str, str] = {}
|
|
96
|
+
file_secrets: dict[str, str] | None = None
|
|
97
|
+
|
|
98
|
+
for key in required:
|
|
99
|
+
if not ignore_env:
|
|
100
|
+
value = os.environ.get(key)
|
|
101
|
+
if value:
|
|
102
|
+
secrets[key] = value
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
if file_secrets is None:
|
|
106
|
+
file_secrets = _get_file_secrets()
|
|
107
|
+
value = file_secrets.get(key)
|
|
108
|
+
if value:
|
|
109
|
+
logger.debug("Credential %s resolved from credential file", key)
|
|
110
|
+
secrets[key] = value
|
|
111
|
+
|
|
112
|
+
return secrets
|