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.
Files changed (311) hide show
  1. forge/__init__.py +3 -0
  2. forge/_extensions/agents/.gitkeep +0 -0
  3. forge/_extensions/commands/.gitkeep +0 -0
  4. forge/_extensions/skills/analyze/SKILL.md +87 -0
  5. forge/_extensions/skills/challenge/SKILL.md +91 -0
  6. forge/_extensions/skills/consensus/SKILL.md +120 -0
  7. forge/_extensions/skills/consensus/resources/code_consensus_evaluation.md +94 -0
  8. forge/_extensions/skills/consensus/resources/consensus_evaluation.md +70 -0
  9. forge/_extensions/skills/consensus/resources/synthesis.md +101 -0
  10. forge/_extensions/skills/debate/SKILL.md +116 -0
  11. forge/_extensions/skills/debate/resources/code_debate_evaluation.md +101 -0
  12. forge/_extensions/skills/debate/resources/debate_evaluation.md +90 -0
  13. forge/_extensions/skills/panel/SKILL.md +141 -0
  14. forge/_extensions/skills/panel/resources/synthesis.md +103 -0
  15. forge/_extensions/skills/qa/SKILL.md +704 -0
  16. forge/_extensions/skills/qa/resources/checklist/0-enable.md +78 -0
  17. forge/_extensions/skills/qa/resources/checklist/1-preflight.md +24 -0
  18. forge/_extensions/skills/qa/resources/checklist/10-resume.md +143 -0
  19. forge/_extensions/skills/qa/resources/checklist/11-config.md +150 -0
  20. forge/_extensions/skills/qa/resources/checklist/12-search.md +58 -0
  21. forge/_extensions/skills/qa/resources/checklist/13-guard.md +237 -0
  22. forge/_extensions/skills/qa/resources/checklist/14-workflow.md +305 -0
  23. forge/_extensions/skills/qa/resources/checklist/15-skills.md +155 -0
  24. forge/_extensions/skills/qa/resources/checklist/16-handoff.md +224 -0
  25. forge/_extensions/skills/qa/resources/checklist/17-info.md +50 -0
  26. forge/_extensions/skills/qa/resources/checklist/18-disable.md +84 -0
  27. forge/_extensions/skills/qa/resources/checklist/19-uninstall.md +146 -0
  28. forge/_extensions/skills/qa/resources/checklist/2-extensions.md +188 -0
  29. forge/_extensions/skills/qa/resources/checklist/20-cleanup.md +36 -0
  30. forge/_extensions/skills/qa/resources/checklist/3-auth.md +234 -0
  31. forge/_extensions/skills/qa/resources/checklist/4-proxy.md +481 -0
  32. forge/_extensions/skills/qa/resources/checklist/5-session.md +541 -0
  33. forge/_extensions/skills/qa/resources/checklist/6-hooks.md +275 -0
  34. forge/_extensions/skills/qa/resources/checklist/7-costs.md +309 -0
  35. forge/_extensions/skills/qa/resources/checklist/8-status-line.md +174 -0
  36. forge/_extensions/skills/qa/resources/checklist/9-direct-commands.md +146 -0
  37. forge/_extensions/skills/qa/resources/checklist.md +103 -0
  38. forge/_extensions/skills/qa/resources/report-template.md +62 -0
  39. forge/_extensions/skills/qa/scripts/start-container.sh +529 -0
  40. forge/_extensions/skills/qa/scripts/walkthrough-state.py +1137 -0
  41. forge/_extensions/skills/review/SKILL.md +125 -0
  42. forge/_extensions/skills/review/references/claude-4.6.md +474 -0
  43. forge/_extensions/skills/review/references/claude-4.7.md +710 -0
  44. forge/_extensions/skills/review/references/gemini-3.1.md +546 -0
  45. forge/_extensions/skills/review/references/gpt-5.5.md +490 -0
  46. forge/_extensions/skills/review/references/skills-writing-guide.md +1588 -0
  47. forge/_extensions/skills/review/resources/code-anthropic.md +160 -0
  48. forge/_extensions/skills/review/resources/code-gemini.md +184 -0
  49. forge/_extensions/skills/review/resources/code-openai.md +203 -0
  50. forge/_extensions/skills/review/resources/code.md +160 -0
  51. forge/_extensions/skills/review-docs/SKILL.md +121 -0
  52. forge/_extensions/skills/review-docs/resources/docs-anthropic.md +170 -0
  53. forge/_extensions/skills/review-docs/resources/docs-gemini.md +204 -0
  54. forge/_extensions/skills/review-docs/resources/docs-openai.md +231 -0
  55. forge/_extensions/skills/review-docs/resources/docs.md +170 -0
  56. forge/_extensions/skills/smoke-test/SKILL.md +27 -0
  57. forge/_extensions/skills/smoke-test/scripts/smoke-test.sh +118 -0
  58. forge/_extensions/skills/understand/SKILL.md +148 -0
  59. forge/_extensions/skills/understand/resources/code-anthropic.md +163 -0
  60. forge/_extensions/skills/understand/resources/code-gemini.md +194 -0
  61. forge/_extensions/skills/understand/resources/code-openai.md +181 -0
  62. forge/_extensions/skills/understand/resources/code.md +163 -0
  63. forge/_extensions/skills/understand/resources/docs-anthropic.md +177 -0
  64. forge/_extensions/skills/understand/resources/docs-gemini.md +202 -0
  65. forge/_extensions/skills/understand/resources/docs-openai.md +191 -0
  66. forge/_extensions/skills/understand/resources/docs.md +177 -0
  67. forge/_extensions/skills/walkthrough/SKILL.md +599 -0
  68. forge/_extensions/skills/walkthrough/resources/checklist.md +765 -0
  69. forge/_extensions/skills/walkthrough/scripts/run-in-repo.sh +118 -0
  70. forge/_extensions/skills/walkthrough/scripts/setup-test-repo.sh +198 -0
  71. forge/_extensions/skills/walkthrough/scripts/walkthrough-state.py +1137 -0
  72. forge/backend/__init__.py +174 -0
  73. forge/backend/adapters/__init__.py +38 -0
  74. forge/backend/adapters/litellm.py +158 -0
  75. forge/backend/creation.py +89 -0
  76. forge/backend/registry.py +178 -0
  77. forge/cli/__init__.py +16 -0
  78. forge/cli/auth.py +483 -0
  79. forge/cli/backend.py +298 -0
  80. forge/cli/claude.py +411 -0
  81. forge/cli/config_cmd.py +303 -0
  82. forge/cli/extensions.py +1001 -0
  83. forge/cli/gc.py +165 -0
  84. forge/cli/guard.py +1018 -0
  85. forge/cli/guards.py +106 -0
  86. forge/cli/handoff.py +110 -0
  87. forge/cli/hooks/__init__.py +36 -0
  88. forge/cli/hooks/_group.py +20 -0
  89. forge/cli/hooks/_helpers.py +149 -0
  90. forge/cli/hooks/commands.py +1677 -0
  91. forge/cli/hooks/direct_commands.py +1304 -0
  92. forge/cli/hooks/install.py +232 -0
  93. forge/cli/hooks/policy.py +151 -0
  94. forge/cli/hooks/read_hygiene.py +74 -0
  95. forge/cli/hooks/verification.py +370 -0
  96. forge/cli/logs.py +406 -0
  97. forge/cli/main.py +292 -0
  98. forge/cli/proxy.py +1821 -0
  99. forge/cli/proxy_costs.py +313 -0
  100. forge/cli/search.py +416 -0
  101. forge/cli/session.py +892 -0
  102. forge/cli/session_addendum.py +81 -0
  103. forge/cli/session_fork.py +750 -0
  104. forge/cli/session_handoff.py +141 -0
  105. forge/cli/session_lifecycle.py +2053 -0
  106. forge/cli/session_manage.py +1336 -0
  107. forge/cli/session_memory.py +201 -0
  108. forge/cli/status_line.py +1398 -0
  109. forge/cli/workflow.py +1964 -0
  110. forge/config/__init__.py +110 -0
  111. forge/config/dataclass_utils.py +88 -0
  112. forge/config/defaults/__init__.py +0 -0
  113. forge/config/defaults/backends/__init__.py +0 -0
  114. forge/config/defaults/backends/litellm.yaml +196 -0
  115. forge/config/defaults/templates/__init__.py +0 -0
  116. forge/config/defaults/templates/litellm-anthropic-local.yaml +33 -0
  117. forge/config/defaults/templates/litellm-anthropic.yaml +24 -0
  118. forge/config/defaults/templates/litellm-gemini-flash-local.yaml +37 -0
  119. forge/config/defaults/templates/litellm-gemini-local.yaml +32 -0
  120. forge/config/defaults/templates/litellm-gemini-test.yaml +34 -0
  121. forge/config/defaults/templates/litellm-gemini.yaml +21 -0
  122. forge/config/defaults/templates/litellm-openai-codex-local.yaml +36 -0
  123. forge/config/defaults/templates/litellm-openai-local.yaml +38 -0
  124. forge/config/defaults/templates/litellm-openai.yaml +28 -0
  125. forge/config/defaults/templates/openrouter-anthropic.yaml +23 -0
  126. forge/config/defaults/templates/openrouter-deepseek.yaml +26 -0
  127. forge/config/defaults/templates/openrouter-gemini-flash.yaml +26 -0
  128. forge/config/defaults/templates/openrouter-gemini.yaml +23 -0
  129. forge/config/defaults/templates/openrouter-glm.yaml +23 -0
  130. forge/config/defaults/templates/openrouter-kimi.yaml +30 -0
  131. forge/config/defaults/templates/openrouter-minimax.yaml +26 -0
  132. forge/config/defaults/templates/openrouter-openai-codex.yaml +23 -0
  133. forge/config/defaults/templates/openrouter-openai.yaml +28 -0
  134. forge/config/defaults/templates/openrouter-qwen.yaml +25 -0
  135. forge/config/loader.py +675 -0
  136. forge/config/schema.py +448 -0
  137. forge/core/__init__.py +5 -0
  138. forge/core/auth/__init__.py +67 -0
  139. forge/core/auth/capabilities.py +219 -0
  140. forge/core/auth/credentials_file.py +244 -0
  141. forge/core/auth/protocols.py +18 -0
  142. forge/core/auth/secrets.py +243 -0
  143. forge/core/auth/template_secrets.py +112 -0
  144. forge/core/data/__init__.py +5 -0
  145. forge/core/data/model_catalog.yaml +1522 -0
  146. forge/core/data/pricing.yaml +140 -0
  147. forge/core/data/system_prompt_addendums/__init__.py +0 -0
  148. forge/core/data/system_prompt_addendums/gemini.md +330 -0
  149. forge/core/data/system_prompt_addendums/openai.md +328 -0
  150. forge/core/llm/__init__.py +231 -0
  151. forge/core/llm/clients/__init__.py +14 -0
  152. forge/core/llm/clients/base.py +115 -0
  153. forge/core/llm/clients/litellm.py +619 -0
  154. forge/core/llm/clients/openai_compat.py +244 -0
  155. forge/core/llm/clients/openrouter.py +234 -0
  156. forge/core/llm/credentials.py +439 -0
  157. forge/core/llm/detection.py +86 -0
  158. forge/core/llm/errors.py +44 -0
  159. forge/core/llm/protocols.py +80 -0
  160. forge/core/llm/types.py +176 -0
  161. forge/core/logging.py +146 -0
  162. forge/core/models/__init__.py +91 -0
  163. forge/core/models/catalog.py +467 -0
  164. forge/core/models/pricing.py +165 -0
  165. forge/core/models/types.py +167 -0
  166. forge/core/naming.py +212 -0
  167. forge/core/ops/__init__.py +73 -0
  168. forge/core/ops/context.py +141 -0
  169. forge/core/ops/gc.py +802 -0
  170. forge/core/ops/proxy.py +146 -0
  171. forge/core/ops/resolution.py +135 -0
  172. forge/core/ops/session.py +344 -0
  173. forge/core/ops/session_context.py +548 -0
  174. forge/core/paths.py +38 -0
  175. forge/core/process.py +54 -0
  176. forge/core/reactive/__init__.py +38 -0
  177. forge/core/reactive/cost_tracking.py +300 -0
  178. forge/core/reactive/env.py +180 -0
  179. forge/core/reactive/proxy.py +78 -0
  180. forge/core/reactive/routing.py +622 -0
  181. forge/core/reactive/session_runner.py +185 -0
  182. forge/core/reactive/structured_output.py +62 -0
  183. forge/core/reactive/tagger.py +94 -0
  184. forge/core/reactive/throttle.py +132 -0
  185. forge/core/state/__init__.py +59 -0
  186. forge/core/state/exceptions.py +59 -0
  187. forge/core/state/io.py +140 -0
  188. forge/core/state/lock.py +99 -0
  189. forge/core/state/timestamps.py +60 -0
  190. forge/core/transcript.py +78 -0
  191. forge/core/typing_helpers.py +24 -0
  192. forge/core/workqueue/__init__.py +67 -0
  193. forge/core/workqueue/queue.py +552 -0
  194. forge/core/workqueue/types.py +63 -0
  195. forge/guard/__init__.py +26 -0
  196. forge/guard/deterministic/__init__.py +26 -0
  197. forge/guard/deterministic/base.py +158 -0
  198. forge/guard/deterministic/coding_standards.py +256 -0
  199. forge/guard/deterministic/registry.py +148 -0
  200. forge/guard/deterministic/tdd.py +171 -0
  201. forge/guard/engine.py +216 -0
  202. forge/guard/protocols.py +91 -0
  203. forge/guard/queries.py +96 -0
  204. forge/guard/semantic/__init__.py +34 -0
  205. forge/guard/semantic/promotion.py +18 -0
  206. forge/guard/semantic/supervisor.py +813 -0
  207. forge/guard/semantic/verdict.py +183 -0
  208. forge/guard/store.py +124 -0
  209. forge/guard/team/__init__.py +6 -0
  210. forge/guard/team/config.py +24 -0
  211. forge/guard/team/handlers.py +209 -0
  212. forge/guard/team/prompts.py +41 -0
  213. forge/guard/types.py +125 -0
  214. forge/guard/workflow/__init__.py +17 -0
  215. forge/guard/workflow/branches.py +67 -0
  216. forge/guard/workflow/config.py +63 -0
  217. forge/guard/workflow/divergence.py +113 -0
  218. forge/guard/workflow/policy.py +87 -0
  219. forge/guard/workflow/stages.py +205 -0
  220. forge/install/__init__.py +55 -0
  221. forge/install/cli.py +281 -0
  222. forge/install/exceptions.py +163 -0
  223. forge/install/hooks.py +109 -0
  224. forge/install/installer.py +1037 -0
  225. forge/install/models.py +321 -0
  226. forge/install/preset.py +272 -0
  227. forge/install/settings_merge.py +831 -0
  228. forge/install/tracking.py +238 -0
  229. forge/install/version.py +141 -0
  230. forge/proxy/__init__.py +0 -0
  231. forge/proxy/base_client.py +181 -0
  232. forge/proxy/client_adapter.py +476 -0
  233. forge/proxy/client_factory.py +531 -0
  234. forge/proxy/converters.py +1206 -0
  235. forge/proxy/cost_logger.py +132 -0
  236. forge/proxy/cost_tracker.py +242 -0
  237. forge/proxy/data_models.py +338 -0
  238. forge/proxy/error_hints.py +92 -0
  239. forge/proxy/metrics.py +222 -0
  240. forge/proxy/model_spec.py +158 -0
  241. forge/proxy/proxies.py +333 -0
  242. forge/proxy/proxy_identity.py +134 -0
  243. forge/proxy/proxy_orchestrator.py +1018 -0
  244. forge/proxy/proxy_startup.py +54 -0
  245. forge/proxy/server.py +1561 -0
  246. forge/proxy/utils.py +537 -0
  247. forge/review/__init__.py +6 -0
  248. forge/review/adversarial.py +111 -0
  249. forge/review/consensus.py +236 -0
  250. forge/review/engine.py +356 -0
  251. forge/review/models.py +437 -0
  252. forge/review/resources/__init__.py +5 -0
  253. forge/review/resources/codereview-performance.md +85 -0
  254. forge/review/resources/codereview-quick.md +75 -0
  255. forge/review/resources/codereview-security.md +92 -0
  256. forge/review/resources/codereview.md +85 -0
  257. forge/review/resources/docreview-quick.md +75 -0
  258. forge/review/resources/docreview.md +86 -0
  259. forge/review/resources/thinkdeep.md +89 -0
  260. forge/review/routing.py +368 -0
  261. forge/review/synthesis.py +73 -0
  262. forge/runtime_config.py +438 -0
  263. forge/search/__init__.py +55 -0
  264. forge/search/bm25_store.py +264 -0
  265. forge/search/content_store.py +197 -0
  266. forge/search/engine.py +352 -0
  267. forge/search/exceptions.py +51 -0
  268. forge/search/extractor.py +234 -0
  269. forge/search/index_state.py +295 -0
  270. forge/search/store.py +215 -0
  271. forge/search/tokenizer.py +24 -0
  272. forge/session/__init__.py +130 -0
  273. forge/session/active.py +339 -0
  274. forge/session/artifacts.py +202 -0
  275. forge/session/claude/__init__.py +50 -0
  276. forge/session/claude/cleanup.py +105 -0
  277. forge/session/claude/invoke.py +236 -0
  278. forge/session/claude/paths.py +200 -0
  279. forge/session/cleanup.py +216 -0
  280. forge/session/config.py +34 -0
  281. forge/session/direct_model.py +107 -0
  282. forge/session/effective.py +169 -0
  283. forge/session/exceptions.py +255 -0
  284. forge/session/handoff.py +881 -0
  285. forge/session/handoff_agent.py +544 -0
  286. forge/session/hooks/__init__.py +35 -0
  287. forge/session/hooks/models.py +73 -0
  288. forge/session/hooks/session_start.py +507 -0
  289. forge/session/identity.py +84 -0
  290. forge/session/index.py +553 -0
  291. forge/session/manager.py +1506 -0
  292. forge/session/models.py +572 -0
  293. forge/session/overrides.py +344 -0
  294. forge/session/plan_resolution.py +286 -0
  295. forge/session/prev_sessions.py +128 -0
  296. forge/session/store.py +431 -0
  297. forge/session/validation.py +47 -0
  298. forge/session/worktree/__init__.py +65 -0
  299. forge/session/worktree/cleanup.py +262 -0
  300. forge/session/worktree/config_copy.py +203 -0
  301. forge/session/worktree/create.py +332 -0
  302. forge/sidecar/__init__.py +29 -0
  303. forge/sidecar/container.py +161 -0
  304. forge/sidecar/docker.py +86 -0
  305. forge/sidecar/secrets.py +19 -0
  306. multi_forge-0.2.0.dist-info/METADATA +242 -0
  307. multi_forge-0.2.0.dist-info/RECORD +311 -0
  308. multi_forge-0.2.0.dist-info/WHEEL +4 -0
  309. multi_forge-0.2.0.dist-info/entry_points.txt +2 -0
  310. multi_forge-0.2.0.dist-info/licenses/LICENSE +203 -0
  311. 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
@@ -0,0 +1,5 @@
1
+ """Package marker for core data files.
2
+
3
+ This package contains repo-owned data files (YAML, JSON, etc.)
4
+ that are loaded at runtime via importlib.resources.
5
+ """