forgeoptimizer 1.0.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.
- forgecli/__init__.py +4 -0
- forgecli/build/__init__.py +135 -0
- forgecli/build/apply.py +218 -0
- forgecli/build/caveman_optimize.py +37 -0
- forgecli/build/diff_extract.py +218 -0
- forgecli/build/llm.py +167 -0
- forgecli/build/optimize.py +37 -0
- forgecli/build/pipeline.py +76 -0
- forgecli/build/retrieval.py +157 -0
- forgecli/build/summarize.py +76 -0
- forgecli/build/test_run.py +75 -0
- forgecli/builder/__init__.py +11 -0
- forgecli/builder/builder.py +53 -0
- forgecli/builder/editor.py +36 -0
- forgecli/builder/formatter.py +31 -0
- forgecli/cli/__init__.py +5 -0
- forgecli/cli/bootstrap.py +281 -0
- forgecli/cli/commands_graph.py +137 -0
- forgecli/cli/commands_wrappers.py +63 -0
- forgecli/cli/main.py +122 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +204 -0
- forgecli/config/writer.py +90 -0
- forgecli/core/__init__.py +35 -0
- forgecli/core/container.py +68 -0
- forgecli/core/context.py +54 -0
- forgecli/core/credentials.py +114 -0
- forgecli/core/errors.py +27 -0
- forgecli/core/events.py +67 -0
- forgecli/core/logging.py +47 -0
- forgecli/core/models.py +159 -0
- forgecli/core/plugins.py +56 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +119 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +171 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +526 -0
- forgecli/engine/plugins.py +146 -0
- forgecli/engine/runner.py +155 -0
- forgecli/engine/stages/__init__.py +33 -0
- forgecli/engine/stages/caveman_optimizer.py +47 -0
- forgecli/engine/stages/context_optimizer.py +44 -0
- forgecli/engine/stages/execution_engine_stage.py +108 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +66 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +89 -0
- forgecli/git/__init__.py +9 -0
- forgecli/git/repo.py +55 -0
- forgecli/git/service.py +45 -0
- forgecli/graph/__init__.py +56 -0
- forgecli/graph/backend_graphify.py +453 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +412 -0
- forgecli/graph/indexer.py +82 -0
- forgecli/graph/node.py +47 -0
- forgecli/graph/repository.py +164 -0
- forgecli/memory/__init__.py +12 -0
- forgecli/memory/cache.py +61 -0
- forgecli/memory/history.py +138 -0
- forgecli/memory/store.py +87 -0
- forgecli/optimizer/__init__.py +14 -0
- forgecli/optimizer/caveman/__init__.py +173 -0
- forgecli/optimizer/caveman/cli.py +64 -0
- forgecli/optimizer/caveman/decorator.py +67 -0
- forgecli/optimizer/caveman/factory.py +51 -0
- forgecli/optimizer/caveman/ruleset.py +156 -0
- forgecli/optimizer/caveman/state.py +52 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +183 -0
- forgecli/optimizer/ponytail/cli.py +168 -0
- forgecli/optimizer/ponytail/decorator.py +70 -0
- forgecli/optimizer/ponytail/factory.py +51 -0
- forgecli/optimizer/ponytail/ruleset.py +168 -0
- forgecli/optimizer/ponytail/state.py +53 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +707 -0
- forgecli/planner/__init__.py +56 -0
- forgecli/planner/agent.py +61 -0
- forgecli/planner/plan.py +65 -0
- forgecli/planner/planner.py +17 -0
- forgecli/planner/render.py +271 -0
- forgecli/planner/serialize.py +106 -0
- forgecli/planner/software.py +834 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +354 -0
- forgecli/platform/paths.py +236 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +252 -0
- forgecli/plugins/__init__.py +251 -0
- forgecli/prompts/__init__.py +11 -0
- forgecli/prompts/loader.py +28 -0
- forgecli/prompts/registry.py +32 -0
- forgecli/prompts/renderer.py +23 -0
- forgecli/providers/__init__.py +39 -0
- forgecli/providers/anthropic.py +206 -0
- forgecli/providers/base.py +206 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +78 -0
- forgecli/providers/google.py +295 -0
- forgecli/providers/http_base.py +211 -0
- forgecli/providers/mock.py +113 -0
- forgecli/providers/openai.py +202 -0
- forgecli/providers/openai_compatible.py +728 -0
- forgecli/providers/router.py +345 -0
- forgecli/providers/router_state.py +89 -0
- forgecli/review/__init__.py +45 -0
- forgecli/review/analyzer.py +124 -0
- forgecli/review/analyzers/__init__.py +1 -0
- forgecli/review/analyzers/architecture.py +258 -0
- forgecli/review/analyzers/complexity.py +167 -0
- forgecli/review/analyzers/dead_code.py +262 -0
- forgecli/review/analyzers/duplicates.py +163 -0
- forgecli/review/analyzers/performance.py +165 -0
- forgecli/review/analyzers/security.py +251 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +311 -0
- forgecli/review/repository.py +131 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +77 -0
- forgecli/runtime/prepare.py +199 -0
- forgecli/runtime/wrappers.py +152 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +243 -0
- forgecli/sdk/manager.py +693 -0
- forgecli/sdk/manifest.py +397 -0
- forgecli/sdk/sandbox.py +197 -0
- forgecli/sdk/version.py +316 -0
- forgecli/templates/__init__.py +9 -0
- forgecli/templates/engine.py +37 -0
- forgecli/templates/registry.py +32 -0
- forgecli/utils/__init__.py +19 -0
- forgecli/utils/fs.py +91 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +70 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
- forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
- forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
- forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Model routing and selection.
|
|
2
|
+
|
|
3
|
+
A :class:`ModelRouter` resolves a high-level request (provider name,
|
|
4
|
+
model alias, or ``"auto"``) into a concrete :class:`Provider` and
|
|
5
|
+
``model`` string. The router owns:
|
|
6
|
+
|
|
7
|
+
* the **registry** of installed providers (OpenAI, Anthropic, Google, mock, …);
|
|
8
|
+
* a static **cost model** keyed by ``(provider, model)``;
|
|
9
|
+
* a **selector** that, given a request's required capabilities, picks
|
|
10
|
+
the cheapest compatible option.
|
|
11
|
+
|
|
12
|
+
The router is decoupled from concrete providers; new providers can be
|
|
13
|
+
registered without touching the router itself.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from enum import Enum
|
|
21
|
+
|
|
22
|
+
from forgecli.providers.base import ProviderRegistry, default_registry
|
|
23
|
+
|
|
24
|
+
# A small, well-known per-1k-token price table (USD, in/out).
|
|
25
|
+
# Values are static defaults; users may override per-deployment. Source:
|
|
26
|
+
# public pricing pages as of 2024-2025. These are advisory — the router
|
|
27
|
+
# uses them only to break ties between equally-capable models.
|
|
28
|
+
DEFAULT_PRICING: dict[tuple[str, str], tuple[float, float]] = {
|
|
29
|
+
# OpenAI
|
|
30
|
+
("openai", "gpt-5.5"): (0.02, 0.10),
|
|
31
|
+
("openai", "gpt-5"): (0.015, 0.075),
|
|
32
|
+
("openai", "gpt-5-mini"): (0.00015, 0.0006),
|
|
33
|
+
("openai", "gpt-4.1"): (0.005, 0.015),
|
|
34
|
+
("openai", "gpt-4.1-mini"): (0.00015, 0.0006),
|
|
35
|
+
("openai", "gpt-4o"): (0.005, 0.015),
|
|
36
|
+
("openai", "gpt-4o-mini"): (0.00015, 0.0006),
|
|
37
|
+
("openai", "gpt-4-turbo"): (0.01, 0.03),
|
|
38
|
+
("openai", "o1"): (0.015, 0.06),
|
|
39
|
+
("openai", "o1-preview"): (0.015, 0.06),
|
|
40
|
+
("openai", "o1-mini"): (0.003, 0.012),
|
|
41
|
+
# Anthropic
|
|
42
|
+
("anthropic", "claude-opus-4.8"): (0.015, 0.075),
|
|
43
|
+
("anthropic", "claude-opus-4.6"): (0.015, 0.075),
|
|
44
|
+
("anthropic", "claude-sonnet-4.6"): (0.003, 0.015),
|
|
45
|
+
("anthropic", "claude-sonnet-4.5"): (0.003, 0.015),
|
|
46
|
+
("anthropic", "claude-haiku-4.5"): (0.0008, 0.004),
|
|
47
|
+
("anthropic", "claude-3-5-sonnet-latest"): (0.003, 0.015),
|
|
48
|
+
("anthropic", "claude-3-5-haiku-latest"): (0.0008, 0.004),
|
|
49
|
+
("anthropic", "claude-3-opus-latest"): (0.015, 0.075),
|
|
50
|
+
# Google
|
|
51
|
+
("google", "gemini-2.5-pro"): (0.00125, 0.005),
|
|
52
|
+
("google", "gemini-2.5-flash"): (0.000075, 0.0003),
|
|
53
|
+
("google", "gemini-2.5-flash-lite"): (0.00003, 0.0001),
|
|
54
|
+
("google", "gemini-2.0-flash"): (0.000075, 0.0003),
|
|
55
|
+
("google", "gemini-1.5-pro"): (0.00125, 0.005),
|
|
56
|
+
("google", "gemini-1.5-flash"): (0.000075, 0.0003),
|
|
57
|
+
("google", "gemini-2.0-flash-exp"): (0.0, 0.0),
|
|
58
|
+
# Mock
|
|
59
|
+
("mock", "mock-model"): (0.0, 0.0),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SelectionMode(str, Enum):
|
|
64
|
+
"""How the router picks a model."""
|
|
65
|
+
|
|
66
|
+
EXPLICIT = "explicit" # caller named provider + model
|
|
67
|
+
ALIAS = "alias" # caller named a provider alias
|
|
68
|
+
CHEAPEST = "cheapest" # "auto" — pick the cheapest compatible model
|
|
69
|
+
FALLBACK = "fallback" # an explicit selection failed; another matched
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class ModelCapabilities:
|
|
74
|
+
"""The capability hints a request expresses."""
|
|
75
|
+
|
|
76
|
+
max_tokens: int | None = None
|
|
77
|
+
needs_tools: bool = False
|
|
78
|
+
needs_vision: bool = False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class RouteDecision:
|
|
83
|
+
"""The router's resolution of a request."""
|
|
84
|
+
|
|
85
|
+
provider_name: str
|
|
86
|
+
model: str
|
|
87
|
+
mode: SelectionMode
|
|
88
|
+
cost_in: float = 0.0
|
|
89
|
+
cost_out: float = 0.0
|
|
90
|
+
candidates: tuple[tuple[str, str], ...] = ()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class ModelRouter:
|
|
95
|
+
"""Resolve high-level routing requests to provider + model."""
|
|
96
|
+
|
|
97
|
+
registry: ProviderRegistry = field(default_factory=lambda: default_registry)
|
|
98
|
+
pricing: dict[tuple[str, str], tuple[float, float]] = field(
|
|
99
|
+
default_factory=lambda: dict(DEFAULT_PRICING)
|
|
100
|
+
)
|
|
101
|
+
default_models: dict[str, str] = field(
|
|
102
|
+
default_factory=lambda: {
|
|
103
|
+
"openai": "gpt-5",
|
|
104
|
+
"anthropic": "claude-sonnet-4.6",
|
|
105
|
+
"google": "gemini-2.5-flash",
|
|
106
|
+
"openrouter": "glm-5.2",
|
|
107
|
+
"groq": "llama-4-scout",
|
|
108
|
+
"mistral": "mistral-large",
|
|
109
|
+
"ollama": "llama3",
|
|
110
|
+
"lmstudio": "local-model",
|
|
111
|
+
"vllm": "local-model",
|
|
112
|
+
"mock": "mock-model",
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
aliases: dict[str, str] = field(
|
|
116
|
+
default_factory=lambda: {
|
|
117
|
+
"claude": "anthropic",
|
|
118
|
+
"openai": "openai",
|
|
119
|
+
"gpt": "openai",
|
|
120
|
+
"gemini": "google",
|
|
121
|
+
"google": "google",
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------
|
|
126
|
+
# Discovery
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def available_providers(self) -> list[str]:
|
|
130
|
+
return self.registry.names()
|
|
131
|
+
|
|
132
|
+
def has_provider(self, name: str) -> bool:
|
|
133
|
+
return self.registry.has(name)
|
|
134
|
+
|
|
135
|
+
def resolve_alias(self, name: str) -> str:
|
|
136
|
+
return self.aliases.get(name.lower(), name.lower())
|
|
137
|
+
|
|
138
|
+
def default_model_for(self, provider_name: str) -> str:
|
|
139
|
+
return self.default_models.get(
|
|
140
|
+
provider_name.lower(),
|
|
141
|
+
_DEFAULT_MODEL_BY_PROVIDER.get(provider_name.lower(), "auto"),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# ------------------------------------------------------------------
|
|
145
|
+
# Selection
|
|
146
|
+
# ------------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def select(
|
|
149
|
+
self,
|
|
150
|
+
choice: str | None,
|
|
151
|
+
*,
|
|
152
|
+
capabilities: ModelCapabilities | None = None,
|
|
153
|
+
) -> RouteDecision:
|
|
154
|
+
"""Resolve a CLI choice (claude / openai / gemini / auto / …)."""
|
|
155
|
+
choice = (choice or "auto").strip()
|
|
156
|
+
caps = capabilities or ModelCapabilities()
|
|
157
|
+
|
|
158
|
+
if choice.lower() == "auto":
|
|
159
|
+
return self._select_cheapest(caps)
|
|
160
|
+
return self._select_explicit(choice, caps)
|
|
161
|
+
|
|
162
|
+
def cheapest(
|
|
163
|
+
self,
|
|
164
|
+
capabilities: ModelCapabilities | None = None,
|
|
165
|
+
*,
|
|
166
|
+
provider: str | None = None,
|
|
167
|
+
) -> RouteDecision:
|
|
168
|
+
"""Pick the cheapest compatible (provider, model) pair.
|
|
169
|
+
|
|
170
|
+
When ``provider`` is set, the algorithm restricts the search to
|
|
171
|
+
that single provider. If that provider has no credentials, the
|
|
172
|
+
router falls back to the mock with :data:`SelectionMode.FALLBACK`
|
|
173
|
+
so the CLI never hard-errors.
|
|
174
|
+
"""
|
|
175
|
+
if provider is not None:
|
|
176
|
+
return self._select_cheapest(
|
|
177
|
+
capabilities or ModelCapabilities(), provider=provider
|
|
178
|
+
)
|
|
179
|
+
return self._select_cheapest(capabilities or ModelCapabilities())
|
|
180
|
+
|
|
181
|
+
# ------------------------------------------------------------------
|
|
182
|
+
# Internal selectors
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def _select_explicit(
|
|
186
|
+
self, choice: str, caps: ModelCapabilities
|
|
187
|
+
) -> RouteDecision:
|
|
188
|
+
provider_name = self.resolve_alias(choice)
|
|
189
|
+
if not self.registry.has(provider_name):
|
|
190
|
+
return RouteDecision(
|
|
191
|
+
provider_name=provider_name,
|
|
192
|
+
model=self.default_model_for(provider_name),
|
|
193
|
+
mode=SelectionMode.ALIAS,
|
|
194
|
+
)
|
|
195
|
+
model = self.default_model_for(provider_name)
|
|
196
|
+
cost_in, cost_out = self.pricing.get(
|
|
197
|
+
(provider_name, model), (0.0, 0.0)
|
|
198
|
+
)
|
|
199
|
+
return RouteDecision(
|
|
200
|
+
provider_name=provider_name,
|
|
201
|
+
model=model,
|
|
202
|
+
mode=SelectionMode.ALIAS,
|
|
203
|
+
cost_in=cost_in,
|
|
204
|
+
cost_out=cost_out,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def _select_cheapest(
|
|
208
|
+
self,
|
|
209
|
+
caps: ModelCapabilities,
|
|
210
|
+
*,
|
|
211
|
+
provider: str | None = None,
|
|
212
|
+
) -> RouteDecision:
|
|
213
|
+
candidates = self._list_real_candidates(caps, provider=provider)
|
|
214
|
+
if candidates:
|
|
215
|
+
# Sort by (in_price, out_price, provider_name) for determinism.
|
|
216
|
+
candidates.sort(
|
|
217
|
+
key=lambda c: (
|
|
218
|
+
self.pricing.get(c, (0.0, 0.0))[0],
|
|
219
|
+
self.pricing.get(c, (0.0, 0.0))[1],
|
|
220
|
+
c[0],
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
chosen = candidates[0]
|
|
224
|
+
cost_in, cost_out = self.pricing.get(chosen, (0.0, 0.0))
|
|
225
|
+
return RouteDecision(
|
|
226
|
+
provider_name=chosen[0],
|
|
227
|
+
model=chosen[1],
|
|
228
|
+
mode=SelectionMode.CHEAPEST,
|
|
229
|
+
cost_in=cost_in,
|
|
230
|
+
cost_out=cost_out,
|
|
231
|
+
candidates=tuple(candidates),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# No real provider had credentials; fall back to the mock.
|
|
235
|
+
return RouteDecision(
|
|
236
|
+
provider_name="mock",
|
|
237
|
+
model=self.default_model_for("mock"),
|
|
238
|
+
mode=SelectionMode.FALLBACK,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def _list_real_candidates(
|
|
242
|
+
self,
|
|
243
|
+
caps: ModelCapabilities,
|
|
244
|
+
*,
|
|
245
|
+
provider: str | None = None,
|
|
246
|
+
) -> list[tuple[str, str]]:
|
|
247
|
+
"""Return the (provider, model) pairs that satisfy ``caps`` AND
|
|
248
|
+
have credentials available. Excludes the ``mock`` provider,
|
|
249
|
+
which is reserved as a fallback.
|
|
250
|
+
"""
|
|
251
|
+
out: list[tuple[str, str]] = []
|
|
252
|
+
for name in self.registry.names():
|
|
253
|
+
if name == "mock":
|
|
254
|
+
continue
|
|
255
|
+
if provider is not None and name != provider:
|
|
256
|
+
continue
|
|
257
|
+
if not _provider_has_credentials(name):
|
|
258
|
+
continue
|
|
259
|
+
model = self.default_model_for(name)
|
|
260
|
+
out.append((name, model))
|
|
261
|
+
return out
|
|
262
|
+
|
|
263
|
+
def _list_candidates(
|
|
264
|
+
self,
|
|
265
|
+
caps: ModelCapabilities,
|
|
266
|
+
*,
|
|
267
|
+
provider: str | None = None,
|
|
268
|
+
) -> list[tuple[str, str]]:
|
|
269
|
+
"""Public list of real candidates; for tests and CLI display."""
|
|
270
|
+
return self._list_real_candidates(caps, provider=provider)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ---------------------------------------------------------------------------
|
|
274
|
+
# Helpers
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
_DEFAULT_MODEL_BY_PROVIDER: dict[str, str] = {
|
|
279
|
+
"openai": "gpt-5",
|
|
280
|
+
"anthropic": "claude-sonnet-4.6",
|
|
281
|
+
"google": "gemini-2.5-flash",
|
|
282
|
+
"openrouter": "glm-5.2",
|
|
283
|
+
"groq": "llama-4-scout",
|
|
284
|
+
"mistral": "mistral-large",
|
|
285
|
+
"minimax": "abab6.5g-chat",
|
|
286
|
+
"xai": "grok-2",
|
|
287
|
+
"together": "llama-3.1-70b",
|
|
288
|
+
"fireworks": "llama-3.1-70b",
|
|
289
|
+
"cohere": "command-r-plus",
|
|
290
|
+
"nvidia": "llama-3.1-70b",
|
|
291
|
+
"ollama": "llama3",
|
|
292
|
+
"lmstudio": "local-model",
|
|
293
|
+
"vllm": "local-model",
|
|
294
|
+
"mock": "mock-model",
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
_PROVIDER_ENV_VARS: dict[str, tuple[str, ...]] = {
|
|
299
|
+
"openai": ("OPENAI_API_KEY",),
|
|
300
|
+
"anthropic": ("ANTHROPIC_API_KEY",),
|
|
301
|
+
"google": ("GOOGLE_API_KEY", "GEMINI_API_KEY"),
|
|
302
|
+
"azure": ("AZURE_OPENAI_API_KEY",),
|
|
303
|
+
"mistral": ("MISTRAL_API_KEY",),
|
|
304
|
+
"groq": ("GROQ_API_KEY",),
|
|
305
|
+
"openrouter": ("OPENROUTER_API_KEY",),
|
|
306
|
+
"minimax": ("MINIMAX_API_KEY",),
|
|
307
|
+
"xai": ("XAI_API_KEY",),
|
|
308
|
+
"together": ("TOGETHER_API_KEY",),
|
|
309
|
+
"fireworks": ("FIREWORKS_API_KEY",),
|
|
310
|
+
"cohere": ("COHERE_API_KEY",),
|
|
311
|
+
"nvidia": ("NVIDIA_API_KEY",),
|
|
312
|
+
"ollama": ("OLLAMA_API_KEY",),
|
|
313
|
+
"lmstudio": ("LMSTUDIO_API_KEY",),
|
|
314
|
+
"vllm": ("VLLM_API_KEY",),
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _provider_has_credentials(name: str) -> bool:
|
|
319
|
+
"""Return True if any known env var or secure storage key for ``name`` is non-empty."""
|
|
320
|
+
if name == "mock":
|
|
321
|
+
return True
|
|
322
|
+
if any(os.environ.get(env_var) for env_var in _PROVIDER_ENV_VARS.get(name, ())):
|
|
323
|
+
return True
|
|
324
|
+
from forgecli.core.credentials import get_api_key
|
|
325
|
+
return bool(get_api_key(name))
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def estimate_cost(
|
|
329
|
+
decision: RouteDecision,
|
|
330
|
+
*,
|
|
331
|
+
prompt_tokens: int,
|
|
332
|
+
completion_tokens: int,
|
|
333
|
+
) -> float:
|
|
334
|
+
"""Estimate USD cost for a finished call."""
|
|
335
|
+
return (decision.cost_in * prompt_tokens + decision.cost_out * completion_tokens) / 1000.0
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
__all__ = [
|
|
339
|
+
"DEFAULT_PRICING",
|
|
340
|
+
"ModelCapabilities",
|
|
341
|
+
"ModelRouter",
|
|
342
|
+
"RouteDecision",
|
|
343
|
+
"SelectionMode",
|
|
344
|
+
"estimate_cost",
|
|
345
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Live runtime state for the model router.
|
|
2
|
+
|
|
3
|
+
The active provider selection (claude / openai / gemini / auto) is
|
|
4
|
+
persisted to ``data_dir/router.json`` and surfaced on every
|
|
5
|
+
:class:`AppContext` via ``extras``. A selection of ``"auto"`` triggers
|
|
6
|
+
the cheapest-compatible algorithm on every chat call.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from forgecli.providers.router import ModelCapabilities, RouteDecision, SelectionMode
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class RouterState:
|
|
20
|
+
"""The user-chosen provider/model selection."""
|
|
21
|
+
|
|
22
|
+
choice: str | None = None
|
|
23
|
+
model: str | None = None
|
|
24
|
+
provider: str | None = None
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_extras(cls, extras: dict[str, object]) -> RouterState:
|
|
28
|
+
state = cls()
|
|
29
|
+
choice = extras.get("router.choice") or extras.get("router_choice")
|
|
30
|
+
if isinstance(choice, str):
|
|
31
|
+
state.choice = choice
|
|
32
|
+
model = extras.get("router.model") or extras.get("router_model")
|
|
33
|
+
if isinstance(model, str):
|
|
34
|
+
state.model = model
|
|
35
|
+
provider = extras.get("router.provider") or extras.get("router_provider")
|
|
36
|
+
if isinstance(provider, str):
|
|
37
|
+
state.provider = provider
|
|
38
|
+
return state
|
|
39
|
+
|
|
40
|
+
def to_extras(self) -> dict[str, str]:
|
|
41
|
+
out: dict[str, str] = {}
|
|
42
|
+
if self.choice:
|
|
43
|
+
out["router.choice"] = self.choice
|
|
44
|
+
if self.model:
|
|
45
|
+
out["router.model"] = self.model
|
|
46
|
+
if self.provider:
|
|
47
|
+
out["router.provider"] = self.provider
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_state(path: Path) -> RouterState:
|
|
52
|
+
if not path.exists():
|
|
53
|
+
return RouterState()
|
|
54
|
+
try:
|
|
55
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
56
|
+
except (OSError, json.JSONDecodeError):
|
|
57
|
+
return RouterState()
|
|
58
|
+
state = RouterState()
|
|
59
|
+
choice = payload.get("choice")
|
|
60
|
+
if isinstance(choice, str):
|
|
61
|
+
state.choice = choice
|
|
62
|
+
model = payload.get("model")
|
|
63
|
+
if isinstance(model, str):
|
|
64
|
+
state.model = model
|
|
65
|
+
provider = payload.get("provider")
|
|
66
|
+
if isinstance(provider, str):
|
|
67
|
+
state.provider = provider
|
|
68
|
+
return state
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def save_state(path: Path, state: RouterState) -> None:
|
|
72
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
path.write_text(
|
|
74
|
+
json.dumps(
|
|
75
|
+
{"choice": state.choice, "model": state.model, "provider": state.provider},
|
|
76
|
+
indent=2,
|
|
77
|
+
),
|
|
78
|
+
encoding="utf-8",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
"ModelCapabilities",
|
|
84
|
+
"RouteDecision",
|
|
85
|
+
"RouterState",
|
|
86
|
+
"SelectionMode",
|
|
87
|
+
"load_state",
|
|
88
|
+
"save_state",
|
|
89
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Code review and quality analysis."""
|
|
2
|
+
|
|
3
|
+
from forgecli.review.analyzer import AnalysisContext, Analyzer, SourceFile
|
|
4
|
+
from forgecli.review.analyzers.architecture import ArchitectureAnalyzer
|
|
5
|
+
from forgecli.review.analyzers.complexity import ComplexityAnalyzer
|
|
6
|
+
from forgecli.review.analyzers.dead_code import DeadCodeAnalyzer
|
|
7
|
+
from forgecli.review.analyzers.duplicates import DuplicatesAnalyzer
|
|
8
|
+
from forgecli.review.analyzers.performance import PerformanceAnalyzer
|
|
9
|
+
from forgecli.review.analyzers.security import SecurityAnalyzer
|
|
10
|
+
from forgecli.review.finding import Finding, Severity
|
|
11
|
+
from forgecli.review.report import (
|
|
12
|
+
print_review,
|
|
13
|
+
render_review,
|
|
14
|
+
review_to_json,
|
|
15
|
+
review_to_markdown,
|
|
16
|
+
)
|
|
17
|
+
from forgecli.review.repository import (
|
|
18
|
+
RepositoryReview,
|
|
19
|
+
default_analyzers,
|
|
20
|
+
review_repository,
|
|
21
|
+
)
|
|
22
|
+
from forgecli.review.suggestions import Suggestion, build_suggestions
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"AnalysisContext",
|
|
26
|
+
"Analyzer",
|
|
27
|
+
"ArchitectureAnalyzer",
|
|
28
|
+
"ComplexityAnalyzer",
|
|
29
|
+
"DeadCodeAnalyzer",
|
|
30
|
+
"DuplicatesAnalyzer",
|
|
31
|
+
"Finding",
|
|
32
|
+
"PerformanceAnalyzer",
|
|
33
|
+
"RepositoryReview",
|
|
34
|
+
"SecurityAnalyzer",
|
|
35
|
+
"Severity",
|
|
36
|
+
"SourceFile",
|
|
37
|
+
"Suggestion",
|
|
38
|
+
"build_suggestions",
|
|
39
|
+
"default_analyzers",
|
|
40
|
+
"print_review",
|
|
41
|
+
"render_review",
|
|
42
|
+
"review_repository",
|
|
43
|
+
"review_to_json",
|
|
44
|
+
"review_to_markdown",
|
|
45
|
+
]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Common types for the review analyzer layer.
|
|
2
|
+
|
|
3
|
+
Every concrete analyzer subclasses :class:`Analyzer` and implements
|
|
4
|
+
:meth:`run`. The framework hands each analyzer a shared
|
|
5
|
+
:class:`AnalysisContext` (a pre-loaded view of the project) and the
|
|
6
|
+
analyzer returns a list of :class:`Finding` objects.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import ClassVar
|
|
16
|
+
|
|
17
|
+
from forgecli.review.finding import Finding
|
|
18
|
+
|
|
19
|
+
# Directories we never want to recurse into.
|
|
20
|
+
_DEFAULT_IGNORES: tuple[str, ...] = (
|
|
21
|
+
".git",
|
|
22
|
+
".venv",
|
|
23
|
+
"venv",
|
|
24
|
+
"env",
|
|
25
|
+
"__pycache__",
|
|
26
|
+
".pytest_cache",
|
|
27
|
+
".mypy_cache",
|
|
28
|
+
".ruff_cache",
|
|
29
|
+
"build",
|
|
30
|
+
"dist",
|
|
31
|
+
".eggs",
|
|
32
|
+
"node_modules",
|
|
33
|
+
"target",
|
|
34
|
+
".idea",
|
|
35
|
+
".vscode",
|
|
36
|
+
"tests",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# File extensions we analyze.
|
|
41
|
+
_DEFAULT_EXTENSIONS: tuple[str, ...] = (
|
|
42
|
+
".py",
|
|
43
|
+
".pyi",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class SourceFile:
|
|
49
|
+
"""One source file under review."""
|
|
50
|
+
|
|
51
|
+
path: Path
|
|
52
|
+
text: str
|
|
53
|
+
lines: tuple[str, ...]
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def load(cls, path: Path) -> SourceFile:
|
|
57
|
+
try:
|
|
58
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
59
|
+
except OSError:
|
|
60
|
+
text = ""
|
|
61
|
+
return cls(path=path, text=text, lines=tuple(text.splitlines()))
|
|
62
|
+
|
|
63
|
+
def line_at(self, line_number: int) -> str:
|
|
64
|
+
if 1 <= line_number <= len(self.lines):
|
|
65
|
+
return self.lines[line_number - 1]
|
|
66
|
+
return ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class AnalysisContext:
|
|
71
|
+
"""The shared state passed to every analyzer."""
|
|
72
|
+
|
|
73
|
+
root: Path
|
|
74
|
+
files: list[SourceFile] = field(default_factory=list)
|
|
75
|
+
extras: dict[str, object] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def load(
|
|
79
|
+
cls,
|
|
80
|
+
root: Path,
|
|
81
|
+
*,
|
|
82
|
+
extensions: Iterable[str] = _DEFAULT_EXTENSIONS,
|
|
83
|
+
ignore: Iterable[str] = _DEFAULT_IGNORES,
|
|
84
|
+
) -> AnalysisContext:
|
|
85
|
+
"""Walk ``root`` and load every matching file."""
|
|
86
|
+
root = Path(root).resolve()
|
|
87
|
+
ignore_set = set(ignore)
|
|
88
|
+
files: list[SourceFile] = []
|
|
89
|
+
if root.is_file():
|
|
90
|
+
files.append(SourceFile.load(root))
|
|
91
|
+
return cls(root=root, files=files)
|
|
92
|
+
for path in sorted(root.rglob("*")):
|
|
93
|
+
if not path.is_file():
|
|
94
|
+
continue
|
|
95
|
+
if path.suffix.lower() not in extensions:
|
|
96
|
+
continue
|
|
97
|
+
if any(part in ignore_set for part in path.parts):
|
|
98
|
+
continue
|
|
99
|
+
files.append(SourceFile.load(path))
|
|
100
|
+
return cls(root=root, files=files)
|
|
101
|
+
|
|
102
|
+
def get(self, path: Path) -> SourceFile | None:
|
|
103
|
+
for file in self.files:
|
|
104
|
+
if file.path == path:
|
|
105
|
+
return file
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Analyzer(ABC):
|
|
110
|
+
"""Base class for review analyzers."""
|
|
111
|
+
|
|
112
|
+
name: ClassVar[str] = "abstract"
|
|
113
|
+
category: ClassVar[str] = "abstract"
|
|
114
|
+
|
|
115
|
+
@abstractmethod
|
|
116
|
+
def run(self, context: AnalysisContext) -> list[Finding]:
|
|
117
|
+
"""Return the findings produced by this analyzer."""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
__all__ = [
|
|
121
|
+
"AnalysisContext",
|
|
122
|
+
"Analyzer",
|
|
123
|
+
"SourceFile",
|
|
124
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Concrete analyzers shipped with ForgeCLI."""
|