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.
Files changed (159) hide show
  1. forgecli/__init__.py +4 -0
  2. forgecli/build/__init__.py +135 -0
  3. forgecli/build/apply.py +218 -0
  4. forgecli/build/caveman_optimize.py +37 -0
  5. forgecli/build/diff_extract.py +218 -0
  6. forgecli/build/llm.py +167 -0
  7. forgecli/build/optimize.py +37 -0
  8. forgecli/build/pipeline.py +76 -0
  9. forgecli/build/retrieval.py +157 -0
  10. forgecli/build/summarize.py +76 -0
  11. forgecli/build/test_run.py +75 -0
  12. forgecli/builder/__init__.py +11 -0
  13. forgecli/builder/builder.py +53 -0
  14. forgecli/builder/editor.py +36 -0
  15. forgecli/builder/formatter.py +31 -0
  16. forgecli/cli/__init__.py +5 -0
  17. forgecli/cli/bootstrap.py +281 -0
  18. forgecli/cli/commands_graph.py +149 -0
  19. forgecli/cli/commands_wrappers.py +80 -0
  20. forgecli/cli/daemon.py +744 -0
  21. forgecli/cli/main.py +234 -0
  22. forgecli/cli/ui.py +56 -0
  23. forgecli/config/__init__.py +28 -0
  24. forgecli/config/loader.py +85 -0
  25. forgecli/config/settings.py +206 -0
  26. forgecli/config/writer.py +90 -0
  27. forgecli/core/__init__.py +35 -0
  28. forgecli/core/container.py +68 -0
  29. forgecli/core/context.py +54 -0
  30. forgecli/core/credentials.py +114 -0
  31. forgecli/core/errors.py +27 -0
  32. forgecli/core/events.py +67 -0
  33. forgecli/core/logging.py +47 -0
  34. forgecli/core/models.py +214 -0
  35. forgecli/core/plugins.py +54 -0
  36. forgecli/core/service.py +22 -0
  37. forgecli/docs/__init__.py +5 -0
  38. forgecli/docs/generator.py +115 -0
  39. forgecli/engine/__init__.py +105 -0
  40. forgecli/engine/context.py +163 -0
  41. forgecli/engine/defaults.py +89 -0
  42. forgecli/engine/events.py +185 -0
  43. forgecli/engine/execution.py +519 -0
  44. forgecli/engine/plugins.py +145 -0
  45. forgecli/engine/runner.py +158 -0
  46. forgecli/engine/stages/__init__.py +33 -0
  47. forgecli/engine/stages/caveman_optimizer.py +47 -0
  48. forgecli/engine/stages/context_optimizer.py +44 -0
  49. forgecli/engine/stages/execution_engine_stage.py +102 -0
  50. forgecli/engine/stages/git_engine.py +30 -0
  51. forgecli/engine/stages/intent_analyzer.py +38 -0
  52. forgecli/engine/stages/model_router.py +65 -0
  53. forgecli/engine/stages/planning_engine.py +45 -0
  54. forgecli/engine/stages/repository_analyzer.py +54 -0
  55. forgecli/engine/stages/validation_engine.py +87 -0
  56. forgecli/git/__init__.py +9 -0
  57. forgecli/git/repo.py +55 -0
  58. forgecli/git/service.py +45 -0
  59. forgecli/graph/__init__.py +56 -0
  60. forgecli/graph/backend_graphify.py +448 -0
  61. forgecli/graph/edge.py +19 -0
  62. forgecli/graph/graph.py +85 -0
  63. forgecli/graph/graphify.py +405 -0
  64. forgecli/graph/indexer.py +82 -0
  65. forgecli/graph/node.py +47 -0
  66. forgecli/graph/repository.py +164 -0
  67. forgecli/memory/__init__.py +12 -0
  68. forgecli/memory/cache.py +61 -0
  69. forgecli/memory/history.py +136 -0
  70. forgecli/memory/store.py +85 -0
  71. forgecli/optimizer/__init__.py +13 -0
  72. forgecli/optimizer/caveman/__init__.py +169 -0
  73. forgecli/optimizer/caveman/cli.py +62 -0
  74. forgecli/optimizer/caveman/decorator.py +67 -0
  75. forgecli/optimizer/caveman/factory.py +51 -0
  76. forgecli/optimizer/caveman/ruleset.py +156 -0
  77. forgecli/optimizer/caveman/state.py +50 -0
  78. forgecli/optimizer/chunker.py +85 -0
  79. forgecli/optimizer/optimizer.py +81 -0
  80. forgecli/optimizer/ponytail/__init__.py +181 -0
  81. forgecli/optimizer/ponytail/cli.py +159 -0
  82. forgecli/optimizer/ponytail/decorator.py +70 -0
  83. forgecli/optimizer/ponytail/factory.py +51 -0
  84. forgecli/optimizer/ponytail/ruleset.py +168 -0
  85. forgecli/optimizer/ponytail/state.py +51 -0
  86. forgecli/optimizer/ranker.py +37 -0
  87. forgecli/optimizer/summarizer.py +44 -0
  88. forgecli/orchestrator/__init__.py +706 -0
  89. forgecli/planner/__init__.py +56 -0
  90. forgecli/planner/agent.py +61 -0
  91. forgecli/planner/plan.py +65 -0
  92. forgecli/planner/planner.py +17 -0
  93. forgecli/planner/render.py +267 -0
  94. forgecli/planner/serialize.py +104 -0
  95. forgecli/planner/software.py +832 -0
  96. forgecli/platform/__init__.py +91 -0
  97. forgecli/platform/core.py +201 -0
  98. forgecli/platform/deps.py +361 -0
  99. forgecli/platform/paths.py +234 -0
  100. forgecli/platform/shell.py +176 -0
  101. forgecli/platform/update.py +253 -0
  102. forgecli/plugins/__init__.py +249 -0
  103. forgecli/prompts/__init__.py +11 -0
  104. forgecli/prompts/loader.py +28 -0
  105. forgecli/prompts/registry.py +32 -0
  106. forgecli/prompts/renderer.py +23 -0
  107. forgecli/providers/__init__.py +39 -0
  108. forgecli/providers/anthropic.py +207 -0
  109. forgecli/providers/base.py +204 -0
  110. forgecli/providers/builtin.py +26 -0
  111. forgecli/providers/conversation.py +74 -0
  112. forgecli/providers/google.py +290 -0
  113. forgecli/providers/http_base.py +207 -0
  114. forgecli/providers/mock.py +111 -0
  115. forgecli/providers/openai.py +206 -0
  116. forgecli/providers/openai_compatible.py +787 -0
  117. forgecli/providers/router.py +340 -0
  118. forgecli/providers/router_state.py +89 -0
  119. forgecli/review/__init__.py +45 -0
  120. forgecli/review/analyzer.py +124 -0
  121. forgecli/review/analyzers/__init__.py +1 -0
  122. forgecli/review/analyzers/architecture.py +255 -0
  123. forgecli/review/analyzers/complexity.py +162 -0
  124. forgecli/review/analyzers/dead_code.py +255 -0
  125. forgecli/review/analyzers/duplicates.py +161 -0
  126. forgecli/review/analyzers/performance.py +161 -0
  127. forgecli/review/analyzers/security.py +244 -0
  128. forgecli/review/finding.py +68 -0
  129. forgecli/review/report.py +321 -0
  130. forgecli/review/repository.py +130 -0
  131. forgecli/review/suggestions.py +98 -0
  132. forgecli/runtime/__init__.py +6 -0
  133. forgecli/runtime/cache_store.py +75 -0
  134. forgecli/runtime/mcp_config.py +118 -0
  135. forgecli/runtime/prepare.py +203 -0
  136. forgecli/runtime/wrappers.py +150 -0
  137. forgecli/sdk/__init__.py +132 -0
  138. forgecli/sdk/events.py +206 -0
  139. forgecli/sdk/interfaces.py +282 -0
  140. forgecli/sdk/loader.py +239 -0
  141. forgecli/sdk/manager.py +678 -0
  142. forgecli/sdk/manifest.py +395 -0
  143. forgecli/sdk/sandbox.py +247 -0
  144. forgecli/sdk/version.py +313 -0
  145. forgecli/templates/__init__.py +9 -0
  146. forgecli/templates/engine.py +37 -0
  147. forgecli/templates/registry.py +32 -0
  148. forgecli/utils/__init__.py +19 -0
  149. forgecli/utils/fs.py +121 -0
  150. forgecli/utils/ids.py +11 -0
  151. forgecli/utils/io.py +27 -0
  152. forgecli/utils/paths.py +78 -0
  153. forgecli/utils/stats.py +179 -0
  154. forgecli/utils/timing.py +25 -0
  155. forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
  156. forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
  157. forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
  158. forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
  159. 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"]