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,439 @@
1
+ """Credential manager for LLM providers.
2
+
3
+ Injectable singleton with TTL caching and proactive refresh.
4
+ Per-provider async locks prevent thundering herd on token refresh.
5
+
6
+ Note: Does NOT call load_dotenv() - that's the responsibility of the
7
+ CLI entrypoint or config loader to avoid import-time side effects.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ import os
15
+ import time
16
+ from typing import Any
17
+
18
+ from forge.core.auth.protocols import SecretsProvider
19
+
20
+ from .detection import ProviderType
21
+ from .errors import NoApiKeyError
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ DEFAULT_TTL = 3600.0
26
+
27
+
28
+ def _get_litellm_remote_base_url() -> str:
29
+ """Get remote LiteLLM base URL.
30
+
31
+ Resolution order:
32
+ 1. Template/proxy config (config.proxy.litellm.base_url)
33
+ 2. LITELLM_BASE_URL environment variable
34
+ 3. Credential file (~/.forge/credentials.yaml)
35
+ 4. Error if none set
36
+ """
37
+ try:
38
+ from forge.config import config
39
+
40
+ base_url = config.proxy.litellm.base_url
41
+ if base_url:
42
+ logger.debug(f"Using LiteLLM base_url from template: {base_url}")
43
+ return base_url
44
+ except (ImportError, AttributeError):
45
+ pass
46
+
47
+ from forge.core.auth.template_secrets import resolve_env_or_credential
48
+
49
+ resolved_url = resolve_env_or_credential("LITELLM_BASE_URL")
50
+ if resolved_url:
51
+ logger.debug("Using LiteLLM base_url from env/credential file")
52
+ return resolved_url
53
+
54
+ raise ValueError(
55
+ "LiteLLM remote base_url not configured. "
56
+ "Use 'forge proxy create <template> --base-url <url>' or "
57
+ "'forge auth login -c litellm-remote' to store it."
58
+ )
59
+
60
+
61
+ def _get_litellm_local_base_url() -> str:
62
+ """Get local LiteLLM base URL.
63
+
64
+ Resolution order:
65
+ 1. Template config (config.proxy.litellm.base_url)
66
+ 2. LITELLM_LOCAL_BASE_URL environment variable
67
+ 3. Derive from backend_dependency.port (http://localhost:{port})
68
+ 4. Error if none available
69
+ """
70
+ try:
71
+ from forge.config import config
72
+
73
+ base_url = config.proxy.litellm.base_url
74
+ if base_url:
75
+ logger.debug(f"Using LiteLLM base_url from template: {base_url}")
76
+ return base_url
77
+ except (ImportError, AttributeError):
78
+ pass
79
+
80
+ env_url = os.environ.get("LITELLM_LOCAL_BASE_URL")
81
+ if env_url:
82
+ logger.debug(f"Using LiteLLM base_url from LITELLM_LOCAL_BASE_URL: {env_url}")
83
+ return env_url
84
+
85
+ try:
86
+ from forge.config import config
87
+
88
+ dep = config.proxy.backend_dependency
89
+ if dep and dep.port:
90
+ derived = f"http://localhost:{dep.port}"
91
+ logger.debug(f"Using LiteLLM base_url derived from backend_dependency: {derived}")
92
+ return derived
93
+ except (ImportError, AttributeError):
94
+ pass
95
+
96
+ raise ValueError(
97
+ "LiteLLM local base_url not configured. "
98
+ "Set LITELLM_LOCAL_BASE_URL environment variable or use a template with backend_dependency."
99
+ )
100
+
101
+
102
+ OPENROUTER_DEFAULT_BASE_URL = "https://openrouter.ai/api/v1"
103
+
104
+
105
+ def _format_credential_error(
106
+ env_var: str,
107
+ *,
108
+ context: str | None = None,
109
+ extra_hint: str | None = None,
110
+ profile: str | None = None,
111
+ ) -> str | None:
112
+ """Build actionable error using capabilities registry. Returns None on failure."""
113
+ try:
114
+ from forge.core.auth.capabilities import (
115
+ credential_for_env_var,
116
+ format_missing_credential_error,
117
+ )
118
+
119
+ cred = credential_for_env_var(env_var)
120
+ if not cred:
121
+ return None
122
+
123
+ try:
124
+ from forge.runtime_config import get_runtime_config
125
+
126
+ ignore_env = get_runtime_config().auth_ignore_env
127
+ except Exception as e:
128
+ logger.debug("Could not read auth_ignore_env; formatting credential error without env-ignored note: %s", e)
129
+ ignore_env = False
130
+
131
+ return format_missing_credential_error(
132
+ cred,
133
+ missing_vars=[env_var],
134
+ context=context,
135
+ extra_hint=extra_hint,
136
+ profile=profile,
137
+ env_ignored=ignore_env,
138
+ )
139
+ except Exception as e:
140
+ logger.debug("Could not format missing credential error for %s: %s", env_var, e)
141
+ return None
142
+
143
+
144
+ def _get_openrouter_base_url() -> str:
145
+ """Get OpenRouter base URL.
146
+
147
+ Resolution order:
148
+ 1. Template/proxy config (config.proxy.openrouter.base_url)
149
+ 2. OPENROUTER_BASE_URL environment variable or credential file
150
+ 3. Default: https://openrouter.ai/api/v1
151
+ """
152
+ try:
153
+ from forge.config import config
154
+
155
+ base_url = config.proxy.openrouter.base_url
156
+ if base_url:
157
+ logger.debug(f"Using OpenRouter base_url from template: {base_url}")
158
+ return base_url
159
+ except (ImportError, AttributeError):
160
+ pass
161
+
162
+ from forge.core.auth.template_secrets import resolve_env_or_credential
163
+
164
+ resolved = resolve_env_or_credential("OPENROUTER_BASE_URL")
165
+ if resolved:
166
+ logger.debug(f"Using OpenRouter base_url from env/credential file: {resolved}")
167
+ return resolved
168
+
169
+ return OPENROUTER_DEFAULT_BASE_URL
170
+
171
+
172
+ class CredentialManager:
173
+ """Injectable credential manager with TTL caching and proactive refresh.
174
+
175
+ Use CredentialManager.default() to get the global instance,
176
+ or create your own instance for testing.
177
+
178
+ Configuration:
179
+ Base URLs are read from templates (config.proxy.litellm.base_url).
180
+ Remote templates can also fall back to LITELLM_BASE_URL env var.
181
+
182
+ Environment Variables (Secrets Only):
183
+ CREDENTIAL_CACHE_TTL: Global default TTL in seconds (default: 3600)
184
+ LITELLM_API_KEY: Remote LiteLLM API key (secret)
185
+ LITELLM_LOCAL_API_KEY: Local LiteLLM API key (optional secret)
186
+
187
+ Args:
188
+ default_ttl: Default TTL for cached credentials in seconds.
189
+ secrets: Optional SecretsProvider for reading secrets. If not provided,
190
+ falls back to reading directly from os.environ.
191
+ """
192
+
193
+ _default_instance: "CredentialManager | None" = None
194
+
195
+ def __init__(
196
+ self,
197
+ default_ttl: float = DEFAULT_TTL,
198
+ secrets: SecretsProvider | None = None,
199
+ ) -> None:
200
+ # Cache structure: provider -> (credentials, fetch_time, ttl)
201
+ self._cache: dict[str, tuple[dict[str, Any], float, float]] = {}
202
+ self._default_ttl = float(os.getenv("CREDENTIAL_CACHE_TTL", str(default_ttl)))
203
+ # Per-provider locks to prevent concurrent credential fetches
204
+ self._locks: dict[str, asyncio.Lock] = {}
205
+ self._secrets = secrets
206
+
207
+ @classmethod
208
+ def default(cls) -> "CredentialManager":
209
+ """Get or create the default global instance.
210
+
211
+ Wires EnvSecretsProvider -> FileSecretsProvider chain so credentials
212
+ from ~/.forge/credentials.yaml are available as fallback to env vars.
213
+ ``EnvSecretsProvider`` reads ``auth_ignore_env`` lazily per-call,
214
+ so config changes take effect without resetting the singleton.
215
+ """
216
+ if cls._default_instance is None:
217
+ from forge.core.auth import ChainSecretsProvider, EnvSecretsProvider
218
+ from forge.core.auth.secrets import FileSecretsProvider
219
+
220
+ secrets = ChainSecretsProvider(
221
+ EnvSecretsProvider(),
222
+ FileSecretsProvider(),
223
+ )
224
+ cls._default_instance = cls(secrets=secrets)
225
+ return cls._default_instance
226
+
227
+ @classmethod
228
+ def reset_default(cls) -> None:
229
+ """Reset the default instance (for testing)."""
230
+ cls._default_instance = None
231
+
232
+ def _get_lock(self, provider: str) -> asyncio.Lock:
233
+ """Get or create a lock for the given provider."""
234
+ if provider not in self._locks:
235
+ self._locks[provider] = asyncio.Lock()
236
+ return self._locks[provider]
237
+
238
+ def _resolve_secrets(self) -> SecretsProvider:
239
+ """Return configured secrets provider, falling back to env-only."""
240
+ if self._secrets is not None:
241
+ return self._secrets
242
+ from forge.core.auth import EnvSecretsProvider
243
+
244
+ return EnvSecretsProvider()
245
+
246
+ async def get_credentials(
247
+ self,
248
+ provider: ProviderType,
249
+ ) -> dict[str, Any]:
250
+ """Get credentials for a provider, refreshing if needed.
251
+
252
+ Args:
253
+ provider: Provider type to get credentials for.
254
+
255
+ Returns:
256
+ Dictionary with provider-specific credentials.
257
+
258
+ Raises:
259
+ NoApiKeyError: If required credentials are not configured.
260
+ """
261
+ # Check without lock; common path avoids lock contention.
262
+ if provider in self._cache:
263
+ creds, fetch_time, ttl = self._cache[provider]
264
+ age = time.monotonic() - fetch_time
265
+ if age < ttl:
266
+ logger.debug(f"Using cached credentials for {provider} (age: {age:.0f}s)")
267
+ return creds
268
+
269
+ async with self._get_lock(provider):
270
+ # Double-checked locking: another coroutine may have refreshed while we waited.
271
+ if provider in self._cache:
272
+ creds, fetch_time, ttl = self._cache[provider]
273
+ if time.monotonic() - fetch_time < ttl:
274
+ return creds
275
+
276
+ creds = await self._fetch_credentials(provider)
277
+ self._cache[provider] = (creds, time.monotonic(), self._default_ttl)
278
+ logger.info(f"Cached fresh credentials for {provider}")
279
+ return creds
280
+
281
+ async def _fetch_credentials(self, provider: ProviderType) -> dict[str, Any]:
282
+ """Fetch credentials for a provider from environment.
283
+
284
+ Args:
285
+ provider: Provider type to fetch credentials for.
286
+
287
+ Returns:
288
+ Dictionary with provider-specific credentials.
289
+
290
+ Raises:
291
+ NoApiKeyError: If required credentials are not configured.
292
+ """
293
+ if provider == "litellm_remote":
294
+ return self._get_litellm_remote_credentials()
295
+ elif provider == "litellm_local":
296
+ return self._get_litellm_local_credentials()
297
+ elif provider == "anthropic":
298
+ return self._get_anthropic_credentials()
299
+ elif provider == "openrouter":
300
+ return self._get_openrouter_credentials()
301
+ else:
302
+ raise ValueError(f"Unknown provider: {provider}")
303
+
304
+ def _get_litellm_remote_credentials(self) -> dict[str, Any]:
305
+ """Get credentials for remote LiteLLM.
306
+
307
+ Uses unified config for base_url (set by template), with LITELLM_BASE_URL env fallback.
308
+ API key is required for remote endpoints (non-localhost).
309
+ SSL certificate from SSL_CERT_FILE or REQUESTS_CA_BUNDLE for remote proxies.
310
+ """
311
+ secrets = self._resolve_secrets()
312
+ base_url = _get_litellm_remote_base_url()
313
+ api_key = secrets.get("LITELLM_API_KEY", "")
314
+
315
+ # For remote LiteLLM, API key is required (unless localhost)
316
+ is_local = "localhost" in base_url or "127.0.0.1" in base_url
317
+ if not api_key and not is_local:
318
+ raise NoApiKeyError(
319
+ "litellm_remote",
320
+ "LITELLM_API_KEY",
321
+ detail=_format_credential_error("LITELLM_API_KEY"),
322
+ )
323
+
324
+ # SSL cert paths are non-secret, read directly from env
325
+ ssl_cert = os.getenv("SSL_CERT_FILE") or os.getenv("REQUESTS_CA_BUNDLE")
326
+
327
+ result = {
328
+ "base_url": base_url,
329
+ "api_key": api_key,
330
+ }
331
+
332
+ if ssl_cert:
333
+ result["ssl_cert"] = ssl_cert
334
+ logger.debug(f"Using SSL certificate for remote LiteLLM: {ssl_cert}")
335
+
336
+ return result
337
+
338
+ def _get_litellm_local_credentials(self) -> dict[str, Any]:
339
+ """Get credentials for local LiteLLM (personal API keys).
340
+
341
+ Uses unified config for base_url (set by template overlay), with env fallback.
342
+ API key is optional for local - proxy handles auth via GEMINI_API_KEY etc.
343
+ """
344
+ secrets = self._resolve_secrets()
345
+ base_url = _get_litellm_local_base_url()
346
+ api_key = secrets.get("LITELLM_LOCAL_API_KEY", "not-needed")
347
+
348
+ return {
349
+ "base_url": base_url,
350
+ "api_key": api_key,
351
+ }
352
+
353
+ def _get_anthropic_credentials(self) -> dict[str, Any]:
354
+ """Get credentials for direct Anthropic API."""
355
+ secrets = self._resolve_secrets()
356
+ api_key = secrets.get("ANTHROPIC_API_KEY")
357
+ if not api_key:
358
+ raise NoApiKeyError(
359
+ "anthropic",
360
+ "ANTHROPIC_API_KEY",
361
+ detail=_format_credential_error(
362
+ "ANTHROPIC_API_KEY",
363
+ extra_hint="Or use --subprocess-proxy to route through an existing proxy.",
364
+ ),
365
+ )
366
+
367
+ return {
368
+ "api_key": api_key,
369
+ }
370
+
371
+ def _get_openrouter_credentials(self) -> dict[str, Any]:
372
+ """Get credentials for OpenRouter.
373
+
374
+ Resolution mirrors the LiteLLM remote pattern: config base_url first,
375
+ then env var, then default. API key is always required.
376
+ """
377
+ secrets = self._resolve_secrets()
378
+ api_key = secrets.get("OPENROUTER_API_KEY")
379
+ if not api_key:
380
+ raise NoApiKeyError(
381
+ "openrouter",
382
+ "OPENROUTER_API_KEY",
383
+ detail=_format_credential_error("OPENROUTER_API_KEY"),
384
+ )
385
+
386
+ base_url = _get_openrouter_base_url()
387
+
388
+ return {
389
+ "api_key": api_key,
390
+ "base_url": base_url,
391
+ "extra_headers": {
392
+ "HTTP-Referer": "https://github.com/hapa1i/multi-forge",
393
+ "X-OpenRouter-Title": "Multi-Forge",
394
+ },
395
+ }
396
+
397
+ async def invalidate(
398
+ self,
399
+ provider: ProviderType,
400
+ ) -> None:
401
+ """Invalidate cached credentials for a provider.
402
+
403
+ Call this when authentication fails to force a refresh.
404
+
405
+ Args:
406
+ provider: Provider whose credentials should be invalidated.
407
+ """
408
+ async with self._get_lock(provider):
409
+ if provider in self._cache:
410
+ del self._cache[provider]
411
+ logger.info(f"Invalidated cached credentials for {provider}")
412
+
413
+ def get_cache_status(self) -> dict[str, Any]:
414
+ """Get current cache status for monitoring.
415
+
416
+ Returns:
417
+ Dictionary with cache information per provider.
418
+ """
419
+ status: dict[str, Any] = {
420
+ "default_ttl": self._default_ttl,
421
+ "providers": {},
422
+ }
423
+ current_time = time.monotonic()
424
+
425
+ for provider, (_, fetch_time, ttl) in self._cache.items():
426
+ age = current_time - fetch_time
427
+ status["providers"][provider] = {
428
+ "age_seconds": round(age, 1),
429
+ "ttl_seconds": ttl,
430
+ "remaining_seconds": round(max(0, ttl - age), 1),
431
+ "expired": age >= ttl,
432
+ }
433
+
434
+ return status
435
+
436
+ def clear_cache(self) -> None:
437
+ """Clear all cached credentials."""
438
+ self._cache.clear()
439
+ logger.info("Cleared all cached credentials")
@@ -0,0 +1,86 @@
1
+ """Provider detection for LLM client routing.
2
+
3
+ This module provides prefix-based provider detection for model IDs.
4
+ core.llm only supports prefixed canonical IDs (e.g., "openai/gpt-5.2").
5
+ """
6
+
7
+ from typing import Literal
8
+
9
+ # Provider type - all supported providers (some may not be implemented yet)
10
+ ProviderType = Literal["litellm_remote", "litellm_local", "anthropic", "openrouter"]
11
+
12
+ # Prefixes that route to remote LiteLLM
13
+ LITELLM_REMOTE_PREFIXES = (
14
+ "openai/",
15
+ "anthropic/",
16
+ "vertex_ai/",
17
+ "bedrock/",
18
+ "replicate/",
19
+ "together_ai/",
20
+ )
21
+
22
+ # Prefixes that route to local LiteLLM (personal API keys)
23
+ LITELLM_LOCAL_PREFIXES = ("gemini/",)
24
+
25
+
26
+ def detect_provider(model: str) -> ProviderType:
27
+ """Detect provider from prefixed model ID.
28
+
29
+ IMPORTANT: core.llm only supports prefixed canonical IDs.
30
+ Unprefixed models (claude-*, gpt-*) are NOT supported in v1.
31
+
32
+ Args:
33
+ model: Model identifier with provider prefix (e.g., "openai/gpt-5.2")
34
+
35
+ Returns:
36
+ ProviderType indicating which provider should handle this model.
37
+
38
+ Raises:
39
+ ValueError: If model ID is not prefixed (unprefixed models not supported).
40
+
41
+ Examples:
42
+ >>> detect_provider("openai/gpt-5.2")
43
+ 'litellm_remote'
44
+ >>> detect_provider("vertex_ai/gemini-3.1-pro-preview")
45
+ 'litellm_remote'
46
+ >>> detect_provider("gemini/gemini-2.0-flash")
47
+ 'litellm_local'
48
+ >>> detect_provider("anthropic/claude-sonnet-4")
49
+ 'litellm_remote'
50
+ """
51
+ clean_name = model.lower()
52
+
53
+ # Check for remote LiteLLM prefixes
54
+ if any(clean_name.startswith(prefix) for prefix in LITELLM_REMOTE_PREFIXES):
55
+ return "litellm_remote"
56
+
57
+ # Check for local LiteLLM prefixes
58
+ if any(clean_name.startswith(prefix) for prefix in LITELLM_LOCAL_PREFIXES):
59
+ return "litellm_local"
60
+
61
+ # Unprefixed models are not supported in core.llm v1
62
+ # The user should use prefixed model IDs
63
+ if "/" not in model:
64
+ raise ValueError(
65
+ f"Unprefixed model ID '{model}' not supported in core.llm. "
66
+ f"Use prefixed canonical IDs like 'openai/{model}' or 'anthropic/{model}'."
67
+ )
68
+
69
+ # Unknown prefix -- fail-closed (reject rather than silently route to wrong backend)
70
+ known = sorted({*LITELLM_REMOTE_PREFIXES, *LITELLM_LOCAL_PREFIXES})
71
+ raise ValueError(
72
+ f"Unknown model prefix in '{model}'. Known prefixes: {', '.join(known)}. "
73
+ "Use a prefixed canonical ID like 'openai/gpt-5.2' or 'gemini/gemini-3.1-pro-preview'."
74
+ )
75
+
76
+
77
+ def is_implemented(provider: ProviderType) -> bool:
78
+ """Check if a provider has an implemented client.
79
+
80
+ Args:
81
+ provider: Provider type to check.
82
+
83
+ Returns:
84
+ True if the provider's client is implemented, False otherwise.
85
+ """
86
+ return provider in ("litellm_remote", "litellm_local", "openrouter")
@@ -0,0 +1,44 @@
1
+ """Exception hierarchy for LLM client abstraction."""
2
+
3
+
4
+ class LLMError(Exception):
5
+ """Base exception for all LLM-related errors."""
6
+
7
+ pass
8
+
9
+
10
+ class NoApiKeyError(LLMError):
11
+ """Raised when required API key is not configured."""
12
+
13
+ def __init__(self, provider: str, env_var: str, *, detail: str | None = None) -> None:
14
+ self.provider = provider
15
+ self.env_var = env_var
16
+ self.detail = detail
17
+ msg = detail if detail else f"API key not configured for {provider}. Set {env_var}."
18
+ super().__init__(msg)
19
+
20
+
21
+ class AuthenticationError(LLMError):
22
+ """Raised when authentication fails."""
23
+
24
+ def __init__(self, provider: str, message: str) -> None:
25
+ self.provider = provider
26
+ super().__init__(f"Authentication failed for {provider}: {message}")
27
+
28
+
29
+ class ProviderError(LLMError):
30
+ """Wrapper for provider-specific errors."""
31
+
32
+ def __init__(self, provider: str, original: Exception) -> None:
33
+ self.provider = provider
34
+ self.original = original
35
+ super().__init__(f"{provider} error: {original}")
36
+
37
+
38
+ class UnsupportedParamError(LLMError):
39
+ """Raised when strict mode encounters unsupported parameter."""
40
+
41
+ def __init__(self, param: str, provider: str) -> None:
42
+ self.param = param
43
+ self.provider = provider
44
+ super().__init__(f"Parameter '{param}' not supported by {provider}")
@@ -0,0 +1,80 @@
1
+ """LLM client protocol definition."""
2
+
3
+ from typing import Any, AsyncGenerator, Protocol
4
+
5
+ from .types import CompletionResponse, Message, ModelHyperparameters, StreamEvent
6
+
7
+
8
+ class LLMClient(Protocol):
9
+ """Async-first LLM client protocol.
10
+
11
+ All provider implementations must implement this interface.
12
+ The client is async-first; use SyncAdapter for synchronous usage.
13
+ """
14
+
15
+ @property
16
+ def model(self) -> str:
17
+ """The model this client is configured for."""
18
+ ...
19
+
20
+ async def complete(
21
+ self,
22
+ messages: list[Message],
23
+ *,
24
+ tools: list[dict[str, Any]] | None = None,
25
+ hyperparams: ModelHyperparameters | None = None,
26
+ ) -> CompletionResponse:
27
+ """Non-streaming completion.
28
+
29
+ Args:
30
+ messages: List of messages in the conversation.
31
+ tools: Optional list of tool definitions (JSON Schema format).
32
+ hyperparams: Optional hyperparameters to override client defaults.
33
+
34
+ Returns:
35
+ CompletionResponse with text, optional tool_calls, and usage.
36
+ """
37
+ ...
38
+
39
+ def stream(
40
+ self,
41
+ messages: list[Message],
42
+ *,
43
+ tools: list[dict[str, Any]] | None = None,
44
+ hyperparams: ModelHyperparameters | None = None,
45
+ ) -> AsyncGenerator[StreamEvent, None]:
46
+ """Streaming completion.
47
+
48
+ Yields canonical StreamEvent objects. For tool calls, accumulate
49
+ ToolCallDelta events until response_end, then parse into ToolCall.
50
+
51
+ Note: Returns an async generator directly (not an async def).
52
+ Use `async for event in client.stream(...)` to iterate.
53
+
54
+ Args:
55
+ messages: List of messages in the conversation.
56
+ tools: Optional list of tool definitions (JSON Schema format).
57
+ hyperparams: Optional hyperparameters to override client defaults.
58
+
59
+ Yields:
60
+ StreamEvent objects (text_delta, tool_call_delta, response_end, usage, error).
61
+ """
62
+ ...
63
+
64
+ async def count_tokens(
65
+ self,
66
+ messages: list[Message],
67
+ tools: list[dict[str, Any]] | None = None,
68
+ ) -> int:
69
+ """Estimate token count for messages and tools.
70
+
71
+ Accuracy varies by provider. Use for rough estimates only.
72
+
73
+ Args:
74
+ messages: List of messages to count.
75
+ tools: Optional list of tool definitions to include in count.
76
+
77
+ Returns:
78
+ Estimated token count.
79
+ """
80
+ ...