forgeoptimizer 0.1.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 +149 -0
- forgecli/cli/commands_wrappers.py +80 -0
- forgecli/cli/daemon.py +744 -0
- forgecli/cli/main.py +234 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +206 -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 +214 -0
- forgecli/core/plugins.py +54 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +115 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +163 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +519 -0
- forgecli/engine/plugins.py +145 -0
- forgecli/engine/runner.py +158 -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 +102 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +65 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +87 -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 +448 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +405 -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 +136 -0
- forgecli/memory/store.py +85 -0
- forgecli/optimizer/__init__.py +13 -0
- forgecli/optimizer/caveman/__init__.py +169 -0
- forgecli/optimizer/caveman/cli.py +62 -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 +50 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +181 -0
- forgecli/optimizer/ponytail/cli.py +159 -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 +51 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +706 -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 +267 -0
- forgecli/planner/serialize.py +104 -0
- forgecli/planner/software.py +832 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +361 -0
- forgecli/platform/paths.py +234 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +253 -0
- forgecli/plugins/__init__.py +249 -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 +207 -0
- forgecli/providers/base.py +204 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +74 -0
- forgecli/providers/google.py +290 -0
- forgecli/providers/http_base.py +207 -0
- forgecli/providers/mock.py +111 -0
- forgecli/providers/openai.py +206 -0
- forgecli/providers/openai_compatible.py +787 -0
- forgecli/providers/router.py +340 -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 +255 -0
- forgecli/review/analyzers/complexity.py +162 -0
- forgecli/review/analyzers/dead_code.py +255 -0
- forgecli/review/analyzers/duplicates.py +161 -0
- forgecli/review/analyzers/performance.py +161 -0
- forgecli/review/analyzers/security.py +244 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +321 -0
- forgecli/review/repository.py +130 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +75 -0
- forgecli/runtime/mcp_config.py +118 -0
- forgecli/runtime/prepare.py +203 -0
- forgecli/runtime/wrappers.py +150 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +239 -0
- forgecli/sdk/manager.py +678 -0
- forgecli/sdk/manifest.py +395 -0
- forgecli/sdk/sandbox.py +247 -0
- forgecli/sdk/version.py +313 -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 +121 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +78 -0
- forgecli/utils/stats.py +179 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
- forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
- forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
- forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Plugin extension points for ForgeCLI.
|
|
2
|
+
|
|
3
|
+
A plugin is a Python package that exposes one or more of:
|
|
4
|
+
|
|
5
|
+
* :class:`Workflow` — a named, executable unit that orchestrates
|
|
6
|
+
the standard pipeline stages (retrieval, optimization, LLM call,
|
|
7
|
+
apply, test, summary) in a custom way. The top-level ``forge``
|
|
8
|
+
command dispatches to a Workflow based on the user's prompt and
|
|
9
|
+
the current :class:`Intent`.
|
|
10
|
+
* :class:`Provider` — an AI provider. The router picks from registered
|
|
11
|
+
providers based on the user's model choice.
|
|
12
|
+
* :class:`PromptOptimizer` — a prompt-rewriting strategy. Selected by
|
|
13
|
+
the configured intensity.
|
|
14
|
+
* :class:`Analyzer` — a code-review analyzer. The review command
|
|
15
|
+
runs every registered analyzer.
|
|
16
|
+
* :class:`IntentClassifier` — a plugin that returns a high-level
|
|
17
|
+
:class:`Intent` for a given prompt (e.g. "build", "ask", "plan").
|
|
18
|
+
Used by the top-level ``forge`` command.
|
|
19
|
+
|
|
20
|
+
Plugins are discovered via the ``forgecli.plugins`` entry-point
|
|
21
|
+
group; see :func:`discover_plugins`. A plugin's ``configure``
|
|
22
|
+
hook is invoked once at startup with the active
|
|
23
|
+
:class:`~forgecli.core.context.AppContext` so it can register
|
|
24
|
+
its own commands, providers, or analyzers.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from abc import ABC, abstractmethod
|
|
30
|
+
from collections.abc import Awaitable, Callable
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from enum import Enum
|
|
33
|
+
from typing import TYPE_CHECKING, Any
|
|
34
|
+
|
|
35
|
+
from forgecli.core.context import AppContext
|
|
36
|
+
from forgecli.providers.base import Provider
|
|
37
|
+
from forgecli.review.analyzer import Analyzer
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from forgecli.engine.execution import Stage, StageRegistry
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Intent(str, Enum):
|
|
44
|
+
"""A high-level description of what the user wants to do."""
|
|
45
|
+
|
|
46
|
+
BUILD = "build" # generate / modify code
|
|
47
|
+
ASK = "ask" # answer a question about the project
|
|
48
|
+
PLAN = "plan" # produce a plan without writing code
|
|
49
|
+
DOCS = "docs" # produce documentation
|
|
50
|
+
REVIEW = "review" # run the code review pipeline
|
|
51
|
+
EXPLAIN = "explain" # explain a single node / file
|
|
52
|
+
UNKNOWN = "unknown"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class IntentPrediction:
|
|
57
|
+
"""The router's guess at what the user wants."""
|
|
58
|
+
|
|
59
|
+
intent: Intent
|
|
60
|
+
confidence: float
|
|
61
|
+
rationale: tuple[str, ...] = ()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class PluginContext:
|
|
66
|
+
"""The shared state passed to a :class:`Workflow`."""
|
|
67
|
+
|
|
68
|
+
app_context: AppContext
|
|
69
|
+
prompt: str
|
|
70
|
+
intent: Intent
|
|
71
|
+
extras: dict[str, Any] = field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Base classes
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class Workflow(ABC):
|
|
80
|
+
"""A named, executable workflow.
|
|
81
|
+
|
|
82
|
+
The top-level ``forge`` command dispatches to the first registered
|
|
83
|
+
Workflow whose :meth:`can_handle` returns True for the active
|
|
84
|
+
:class:`Intent` and prompt.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
name: str = "abstract"
|
|
88
|
+
intents: tuple[Intent, ...] = ()
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
async def run(self, context: PluginContext) -> dict[str, Any]:
|
|
92
|
+
"""Execute the workflow; return a result payload for the CLI."""
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def can_handle(cls, intent: Intent, prompt: str) -> bool:
|
|
96
|
+
"""Return True if this workflow should run for ``intent``.
|
|
97
|
+
|
|
98
|
+
Implemented as a classmethod so the registry can iterate
|
|
99
|
+
over the workflow *classes* (not instances) and call
|
|
100
|
+
``can_handle`` directly.
|
|
101
|
+
"""
|
|
102
|
+
return intent in cls.intents
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class IntentClassifier(ABC):
|
|
106
|
+
"""A pluggable intent classifier."""
|
|
107
|
+
|
|
108
|
+
name: str = "abstract"
|
|
109
|
+
|
|
110
|
+
@abstractmethod
|
|
111
|
+
def classify(self, prompt: str, *, history: tuple[str, ...] = ()) -> IntentPrediction:
|
|
112
|
+
"""Return the most likely :class:`Intent` for ``prompt``."""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
# Registry
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class PluginRegistry:
|
|
122
|
+
"""In-memory registry of all loaded plugins.
|
|
123
|
+
|
|
124
|
+
When *engine_registry* is provided, every call to
|
|
125
|
+
:meth:`register_stage` or :meth:`replace_stage` is mirrored to
|
|
126
|
+
the engine's :class:`~forgecli.engine.execution.StageRegistry`.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
providers: dict[str, type[Provider]] = field(default_factory=dict)
|
|
130
|
+
optimizers: dict[str, type] = field(default_factory=dict)
|
|
131
|
+
analyzers: list[type[Analyzer]] = field(default_factory=list)
|
|
132
|
+
classifiers: list[IntentClassifier] = field(default_factory=list)
|
|
133
|
+
workflows: list[Workflow] = field(default_factory=list)
|
|
134
|
+
stages: dict[str, Any] = field(default_factory=dict)
|
|
135
|
+
configure_hooks: list[Callable[[AppContext], None]] = field(default_factory=list)
|
|
136
|
+
engine_registry: StageRegistry | None = None # StageRegistry, kept loose to avoid import cycles
|
|
137
|
+
|
|
138
|
+
def register_provider(self, name: str, provider_cls: type[Provider]) -> None:
|
|
139
|
+
self.providers[name] = provider_cls
|
|
140
|
+
|
|
141
|
+
def register_optimizer(self, name: str, optimizer_cls: type) -> None:
|
|
142
|
+
self.optimizers[name] = optimizer_cls
|
|
143
|
+
|
|
144
|
+
def register_analyzer(self, analyzer_cls: type[Analyzer]) -> None:
|
|
145
|
+
self.analyzers.append(analyzer_cls)
|
|
146
|
+
|
|
147
|
+
def register_classifier(self, classifier: IntentClassifier) -> None:
|
|
148
|
+
self.classifiers.append(classifier)
|
|
149
|
+
|
|
150
|
+
def register_workflow(self, workflow: Workflow) -> None:
|
|
151
|
+
self.workflows.append(workflow)
|
|
152
|
+
|
|
153
|
+
def register_stage(self, stage: Stage) -> None:
|
|
154
|
+
"""Register a :class:`Stage` so the engine can pick it up by name."""
|
|
155
|
+
if not getattr(stage, "name", None):
|
|
156
|
+
raise ValueError("Stage.name must be non-empty")
|
|
157
|
+
if stage.name in self.stages:
|
|
158
|
+
raise ValueError(f"Stage {stage.name!r} already registered")
|
|
159
|
+
self.stages[stage.name] = stage
|
|
160
|
+
self._sync_to_engine_registry(stage)
|
|
161
|
+
|
|
162
|
+
def replace_stage(self, stage: Stage) -> None:
|
|
163
|
+
"""Replace a registered :class:`Stage` (last-writer-wins)."""
|
|
164
|
+
if not getattr(stage, "name", None):
|
|
165
|
+
raise ValueError("Stage.name must be non-empty")
|
|
166
|
+
self.stages[stage.name] = stage
|
|
167
|
+
self._sync_to_engine_registry(stage, replace=True)
|
|
168
|
+
|
|
169
|
+
def _sync_to_engine_registry(self, stage: Stage, *, replace: bool = False) -> None:
|
|
170
|
+
engine_reg = self.engine_registry
|
|
171
|
+
if engine_reg is None:
|
|
172
|
+
return
|
|
173
|
+
try:
|
|
174
|
+
if replace:
|
|
175
|
+
engine_reg.replace(stage)
|
|
176
|
+
else:
|
|
177
|
+
engine_reg.register(stage)
|
|
178
|
+
except (ValueError, KeyError):
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
def register_configure_hook(self, hook: Callable[[AppContext], None]) -> None:
|
|
182
|
+
self.configure_hooks.append(hook)
|
|
183
|
+
|
|
184
|
+
def link_engine_registry(self, engine_registry: StageRegistry) -> None:
|
|
185
|
+
"""Link this plugin registry to an engine :class:`StageRegistry`.
|
|
186
|
+
|
|
187
|
+
All existing plugin stages are bulk-registered into the
|
|
188
|
+
engine registry; future calls to :meth:`register_stage`
|
|
189
|
+
and :meth:`replace_stage` are mirrored automatically.
|
|
190
|
+
"""
|
|
191
|
+
self.engine_registry = engine_registry
|
|
192
|
+
for _, stage in list(self.stages.items()):
|
|
193
|
+
try:
|
|
194
|
+
engine_registry.register(stage)
|
|
195
|
+
except (ValueError, KeyError):
|
|
196
|
+
engine_registry.replace(stage)
|
|
197
|
+
|
|
198
|
+
def classifiers_sorted(self) -> list[IntentClassifier]:
|
|
199
|
+
return sorted(self.classifiers, key=lambda c: getattr(c, "priority", 100))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# ---------------------------------------------------------------------------
|
|
203
|
+
# Discovery
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def discover_plugins(
|
|
208
|
+
registry: PluginRegistry,
|
|
209
|
+
group: str = "forgecli.plugins",
|
|
210
|
+
) -> list[str]:
|
|
211
|
+
"""Discover and load installed plugins via entry points.
|
|
212
|
+
|
|
213
|
+
Each entry point must resolve to a callable that takes a
|
|
214
|
+
:class:`PluginRegistry` (and optionally a
|
|
215
|
+
:class:`~forgecli.core.context.AppContext`) and returns nothing.
|
|
216
|
+
"""
|
|
217
|
+
import importlib.metadata as importlib_metadata
|
|
218
|
+
|
|
219
|
+
loaded: list[str] = []
|
|
220
|
+
try:
|
|
221
|
+
entries = importlib_metadata.entry_points(group=group)
|
|
222
|
+
except Exception:
|
|
223
|
+
return loaded
|
|
224
|
+
for ep in entries:
|
|
225
|
+
try:
|
|
226
|
+
plugin_factory = ep.load()
|
|
227
|
+
except Exception:
|
|
228
|
+
continue
|
|
229
|
+
try:
|
|
230
|
+
plugin_factory(registry)
|
|
231
|
+
loaded.append(ep.name)
|
|
232
|
+
except Exception:
|
|
233
|
+
continue
|
|
234
|
+
return loaded
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
__all__ = [
|
|
238
|
+
"Intent",
|
|
239
|
+
"IntentClassifier",
|
|
240
|
+
"IntentPrediction",
|
|
241
|
+
"PluginContext",
|
|
242
|
+
"PluginRegistry",
|
|
243
|
+
"Workflow",
|
|
244
|
+
"discover_plugins",
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# Silence unused-import warnings for symbols only used in some branches.
|
|
249
|
+
_ = Awaitable
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Prompt templates and rendering."""
|
|
2
|
+
|
|
3
|
+
from forgecli.prompts.loader import PromptLoader
|
|
4
|
+
from forgecli.prompts.registry import PromptRegistry
|
|
5
|
+
from forgecli.prompts.renderer import PromptRenderer
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"PromptLoader",
|
|
9
|
+
"PromptRegistry",
|
|
10
|
+
"PromptRenderer",
|
|
11
|
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Discover prompt templates on disk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from forgecli.core.service import Service
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PromptLoader(Service):
|
|
11
|
+
"""Load prompt templates by name from a base directory."""
|
|
12
|
+
|
|
13
|
+
name = "prompts.loader"
|
|
14
|
+
|
|
15
|
+
def __init__(self, base_dir: Path) -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
self._base_dir = Path(base_dir)
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def base_dir(self) -> Path:
|
|
21
|
+
return self._base_dir
|
|
22
|
+
|
|
23
|
+
def load(self, name: str) -> str:
|
|
24
|
+
"""Return the contents of the prompt file identified by ``name``."""
|
|
25
|
+
path = self._base_dir / name
|
|
26
|
+
if not path.exists():
|
|
27
|
+
raise FileNotFoundError(f"Prompt not found: {path}")
|
|
28
|
+
return path.read_text(encoding="utf-8")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""In-memory registry of named prompts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from forgecli.core.service import Service
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PromptRegistry(Service):
|
|
11
|
+
"""Stores prompts by name without touching the filesystem."""
|
|
12
|
+
|
|
13
|
+
name = "prompts.registry"
|
|
14
|
+
|
|
15
|
+
def __init__(self) -> None:
|
|
16
|
+
super().__init__()
|
|
17
|
+
self._prompts: dict[str, str] = {}
|
|
18
|
+
|
|
19
|
+
def register(self, name: str, template: str) -> None:
|
|
20
|
+
self._prompts[name] = template
|
|
21
|
+
|
|
22
|
+
def get(self, name: str) -> str:
|
|
23
|
+
try:
|
|
24
|
+
return self._prompts[name]
|
|
25
|
+
except KeyError as exc:
|
|
26
|
+
raise KeyError(f"Prompt not registered: {name!r}") from exc
|
|
27
|
+
|
|
28
|
+
def names(self) -> list[str]:
|
|
29
|
+
return sorted(self._prompts)
|
|
30
|
+
|
|
31
|
+
def items(self) -> Iterable[tuple[str, str]]:
|
|
32
|
+
return self._prompts.items()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Render prompt templates into strings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from forgecli.core.service import Service
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PromptRenderer(Service):
|
|
9
|
+
"""Render prompt templates with a small variable substitution.
|
|
10
|
+
|
|
11
|
+
A real implementation will use Jinja2; for now we support a tiny
|
|
12
|
+
``{var}`` syntax so the rest of the system can be exercised without
|
|
13
|
+
the dependency being required.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
name = "prompts.renderer"
|
|
17
|
+
|
|
18
|
+
def render(self, template: str, **variables: object) -> str:
|
|
19
|
+
"""Substitute ``{name}`` occurrences in ``template``."""
|
|
20
|
+
result = template
|
|
21
|
+
for key, value in variables.items():
|
|
22
|
+
result = result.replace("{" + key + "}", str(value))
|
|
23
|
+
return result
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""AI provider abstraction layer."""
|
|
2
|
+
|
|
3
|
+
from forgecli.providers.base import (
|
|
4
|
+
ChatMessage,
|
|
5
|
+
ChatRequest,
|
|
6
|
+
ChatResponse,
|
|
7
|
+
EmbeddingRequest,
|
|
8
|
+
EmbeddingResponse,
|
|
9
|
+
ModelInfo,
|
|
10
|
+
Provider,
|
|
11
|
+
ProviderRegistry,
|
|
12
|
+
StreamChunk,
|
|
13
|
+
)
|
|
14
|
+
from forgecli.providers.router import (
|
|
15
|
+
DEFAULT_PRICING,
|
|
16
|
+
ModelCapabilities,
|
|
17
|
+
ModelRouter,
|
|
18
|
+
RouteDecision,
|
|
19
|
+
SelectionMode,
|
|
20
|
+
estimate_cost,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"DEFAULT_PRICING",
|
|
25
|
+
"ChatMessage",
|
|
26
|
+
"ChatRequest",
|
|
27
|
+
"ChatResponse",
|
|
28
|
+
"EmbeddingRequest",
|
|
29
|
+
"EmbeddingResponse",
|
|
30
|
+
"ModelCapabilities",
|
|
31
|
+
"ModelInfo",
|
|
32
|
+
"ModelRouter",
|
|
33
|
+
"Provider",
|
|
34
|
+
"ProviderRegistry",
|
|
35
|
+
"RouteDecision",
|
|
36
|
+
"SelectionMode",
|
|
37
|
+
"StreamChunk",
|
|
38
|
+
"estimate_cost",
|
|
39
|
+
]
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Anthropic Messages API provider.
|
|
2
|
+
|
|
3
|
+
Targets the public ``https://api.anthropic.com/v1`` endpoint. The
|
|
4
|
+
provider uses ``x-api-key`` + ``anthropic-version`` headers per the
|
|
5
|
+
official API, and translates ChatRequest/Response into the Anthropic
|
|
6
|
+
``messages`` schema (system extracted from the message list).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import AsyncIterator
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from forgecli.providers.base import (
|
|
18
|
+
ChatMessage,
|
|
19
|
+
ChatRequest,
|
|
20
|
+
ChatResponse,
|
|
21
|
+
EmbeddingRequest,
|
|
22
|
+
EmbeddingResponse,
|
|
23
|
+
ModelInfo,
|
|
24
|
+
Role,
|
|
25
|
+
StreamChunk,
|
|
26
|
+
)
|
|
27
|
+
from forgecli.providers.http_base import HTTPChatProvider
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class AnthropicConfig:
|
|
32
|
+
"""Configuration for the Anthropic provider."""
|
|
33
|
+
|
|
34
|
+
api_key_env: str = "ANTHROPIC_API_KEY"
|
|
35
|
+
base_url: str = "https://api.anthropic.com"
|
|
36
|
+
default_model: str = "claude-3-5-haiku-latest"
|
|
37
|
+
max_tokens: int = 4096
|
|
38
|
+
temperature: float = 0.2
|
|
39
|
+
api_version: str = "2023-06-01"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AnthropicProvider(HTTPChatProvider):
|
|
43
|
+
"""Anthropic Messages API."""
|
|
44
|
+
|
|
45
|
+
name = "anthropic"
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
config: AnthropicConfig | None = None,
|
|
50
|
+
*,
|
|
51
|
+
api_key: str | None = None,
|
|
52
|
+
base_url: str | None = None,
|
|
53
|
+
client: httpx.AsyncClient | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
super().__init__(
|
|
56
|
+
config or AnthropicConfig(),
|
|
57
|
+
api_key=api_key,
|
|
58
|
+
base_url=base_url,
|
|
59
|
+
client=client,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def _default_base_url(self) -> str:
|
|
63
|
+
return "https://api.anthropic.com"
|
|
64
|
+
|
|
65
|
+
def _chat_url(self) -> str:
|
|
66
|
+
return f"{self._base_url}/v1/messages"
|
|
67
|
+
|
|
68
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
69
|
+
if not self._api_key:
|
|
70
|
+
return {}
|
|
71
|
+
return {
|
|
72
|
+
"x-api-key": self._api_key,
|
|
73
|
+
"anthropic-version": self.config.api_version,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
def _format_request(self, request: ChatRequest) -> dict[str, Any]:
|
|
77
|
+
system_parts: list[str] = []
|
|
78
|
+
messages: list[dict[str, Any]] = []
|
|
79
|
+
for message in request.messages:
|
|
80
|
+
if message.role is Role.SYSTEM:
|
|
81
|
+
if message.content:
|
|
82
|
+
system_parts.append(message.content)
|
|
83
|
+
continue
|
|
84
|
+
messages.append(
|
|
85
|
+
{
|
|
86
|
+
"role": message.role.value,
|
|
87
|
+
"content": message.content,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
body: dict[str, Any] = {
|
|
91
|
+
"model": request.model or self.config.default_model,
|
|
92
|
+
"messages": messages,
|
|
93
|
+
"max_tokens": request.max_tokens or self.config.max_tokens,
|
|
94
|
+
}
|
|
95
|
+
if system_parts:
|
|
96
|
+
body["system"] = "\n\n".join(system_parts)
|
|
97
|
+
body["temperature"] = (
|
|
98
|
+
request.temperature if request.temperature is not None else self.config.temperature
|
|
99
|
+
)
|
|
100
|
+
if request.top_p is not None:
|
|
101
|
+
body["top_p"] = request.top_p
|
|
102
|
+
if request.stop:
|
|
103
|
+
body["stop_sequences"] = list(request.stop)
|
|
104
|
+
return body
|
|
105
|
+
|
|
106
|
+
def _parse_response(self, payload: dict[str, Any]) -> ChatResponse:
|
|
107
|
+
content_blocks = payload.get("content") or []
|
|
108
|
+
text_parts: list[str] = []
|
|
109
|
+
for block in content_blocks:
|
|
110
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
111
|
+
text_parts.append(str(block.get("text", "")))
|
|
112
|
+
text = "".join(text_parts)
|
|
113
|
+
usage = payload.get("usage") or {}
|
|
114
|
+
return ChatResponse(
|
|
115
|
+
model=str(payload.get("model", self.config.default_model)),
|
|
116
|
+
message=ChatMessage(role=Role.ASSISTANT, content=text),
|
|
117
|
+
finish_reason=payload.get("stop_reason"),
|
|
118
|
+
prompt_tokens=int(usage.get("input_tokens", 0) or 0),
|
|
119
|
+
completion_tokens=int(usage.get("output_tokens", 0) or 0),
|
|
120
|
+
total_tokens=int(
|
|
121
|
+
(usage.get("input_tokens", 0) or 0) + (usage.get("output_tokens", 0) or 0)
|
|
122
|
+
),
|
|
123
|
+
raw=payload,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
async def stream(self, request: ChatRequest) -> AsyncIterator[StreamChunk]:
|
|
127
|
+
from forgecli.core.errors import ProviderError
|
|
128
|
+
|
|
129
|
+
if not self._api_key:
|
|
130
|
+
self.validate()
|
|
131
|
+
body = self._format_request(request)
|
|
132
|
+
body["stream"] = True
|
|
133
|
+
|
|
134
|
+
req = self._client.build_request(
|
|
135
|
+
"POST", self._chat_url(), json=body, headers=self._auth_headers()
|
|
136
|
+
)
|
|
137
|
+
response = await self._client.send(req, stream=True)
|
|
138
|
+
if response.status_code >= 400:
|
|
139
|
+
await response.aread()
|
|
140
|
+
raise ProviderError(
|
|
141
|
+
f"{self.name} stream failed ({response.status_code}): {response.text[:500]}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
import json
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
event_name = None
|
|
148
|
+
async for line in response.aiter_lines():
|
|
149
|
+
line = line.strip()
|
|
150
|
+
if not line:
|
|
151
|
+
continue
|
|
152
|
+
if line.startswith("event: "):
|
|
153
|
+
event_name = line[7:]
|
|
154
|
+
elif line.startswith("data: "):
|
|
155
|
+
data_str = line[6:]
|
|
156
|
+
try:
|
|
157
|
+
payload = json.loads(data_str)
|
|
158
|
+
if event_name == "content_block_delta":
|
|
159
|
+
delta = payload.get("delta", {})
|
|
160
|
+
if delta.get("type") == "text_delta":
|
|
161
|
+
text = delta.get("text", "")
|
|
162
|
+
yield StreamChunk(delta=text, raw=payload)
|
|
163
|
+
elif event_name == "message_delta":
|
|
164
|
+
delta = payload.get("delta", {})
|
|
165
|
+
finish_reason = delta.get("stop_reason")
|
|
166
|
+
if finish_reason:
|
|
167
|
+
yield StreamChunk(
|
|
168
|
+
delta="", finish_reason=finish_reason, raw=payload
|
|
169
|
+
)
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
finally:
|
|
173
|
+
await response.aclose()
|
|
174
|
+
|
|
175
|
+
# Anthropic does not expose a public embeddings endpoint; we
|
|
176
|
+
# surface a clear error rather than a confusing 404.
|
|
177
|
+
async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse: # type: ignore[override]
|
|
178
|
+
from forgecli.core.errors import ProviderError
|
|
179
|
+
|
|
180
|
+
raise ProviderError(f"{self.name} does not provide embeddings in this build")
|
|
181
|
+
|
|
182
|
+
def _format_embeddings(self, request: EmbeddingRequest) -> dict[str, Any]: # pragma: no cover
|
|
183
|
+
raise NotImplementedError
|
|
184
|
+
|
|
185
|
+
def _parse_embeddings(self, payload: dict[str, Any]) -> EmbeddingResponse: # pragma: no cover
|
|
186
|
+
raise NotImplementedError
|
|
187
|
+
|
|
188
|
+
def _default_embedding_model(self) -> str: # pragma: no cover
|
|
189
|
+
return ""
|
|
190
|
+
|
|
191
|
+
def _known_models(self) -> list[ModelInfo]:
|
|
192
|
+
from forgecli.core.models import MODEL_CATALOG
|
|
193
|
+
|
|
194
|
+
return [
|
|
195
|
+
ModelInfo(
|
|
196
|
+
id=m.id,
|
|
197
|
+
name=m.display_name,
|
|
198
|
+
context_window=200_000,
|
|
199
|
+
supports_tools=True,
|
|
200
|
+
supports_vision="haiku" not in m.id,
|
|
201
|
+
)
|
|
202
|
+
for m in MODEL_CATALOG
|
|
203
|
+
if m.provider == "anthropic"
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
__all__ = ["AnthropicConfig", "AnthropicProvider"]
|