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,204 @@
|
|
|
1
|
+
"""Abstract base classes and shared data types for AI providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import AsyncIterator, Iterator
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any, ClassVar, Generic, TypeVar
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
from forgecli.core.errors import ProviderError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Role(str, Enum):
|
|
18
|
+
"""A role in a chat conversation."""
|
|
19
|
+
|
|
20
|
+
SYSTEM = "system"
|
|
21
|
+
USER = "user"
|
|
22
|
+
ASSISTANT = "assistant"
|
|
23
|
+
TOOL = "tool"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ChatMessage:
|
|
28
|
+
"""A single message in a chat conversation."""
|
|
29
|
+
|
|
30
|
+
role: Role
|
|
31
|
+
content: str
|
|
32
|
+
name: str | None = None
|
|
33
|
+
tool_call_id: str | None = None
|
|
34
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ChatRequest(BaseModel):
|
|
38
|
+
"""Provider-agnostic chat completion request."""
|
|
39
|
+
|
|
40
|
+
model_config = ConfigDict(extra="allow")
|
|
41
|
+
|
|
42
|
+
model: str | None = None
|
|
43
|
+
messages: list[ChatMessage] = Field(default_factory=list)
|
|
44
|
+
temperature: float | None = None
|
|
45
|
+
max_tokens: int | None = None
|
|
46
|
+
top_p: float | None = None
|
|
47
|
+
stop: list[str] | None = None
|
|
48
|
+
tools: list[dict[str, Any]] | None = None
|
|
49
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ChatResponse(BaseModel):
|
|
53
|
+
"""Provider-agnostic chat completion response."""
|
|
54
|
+
|
|
55
|
+
model_config = ConfigDict(extra="allow")
|
|
56
|
+
|
|
57
|
+
model: str
|
|
58
|
+
message: ChatMessage
|
|
59
|
+
finish_reason: str | None = None
|
|
60
|
+
prompt_tokens: int = 0
|
|
61
|
+
completion_tokens: int = 0
|
|
62
|
+
total_tokens: int = 0
|
|
63
|
+
raw: dict[str, Any] = Field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class StreamChunk:
|
|
68
|
+
"""A piece of a streamed chat response."""
|
|
69
|
+
|
|
70
|
+
delta: str
|
|
71
|
+
finish_reason: str | None = None
|
|
72
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class EmbeddingRequest(BaseModel):
|
|
76
|
+
"""Provider-agnostic embedding request."""
|
|
77
|
+
|
|
78
|
+
model_config = ConfigDict(extra="allow")
|
|
79
|
+
|
|
80
|
+
model: str | None = None
|
|
81
|
+
inputs: list[str] = Field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class EmbeddingResponse(BaseModel):
|
|
85
|
+
"""Provider-agnostic embedding response."""
|
|
86
|
+
|
|
87
|
+
model_config = ConfigDict(extra="allow")
|
|
88
|
+
|
|
89
|
+
model: str
|
|
90
|
+
vectors: list[list[float]] = Field(default_factory=list)
|
|
91
|
+
prompt_tokens: int = 0
|
|
92
|
+
total_tokens: int = 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class ModelInfo:
|
|
97
|
+
"""Metadata describing a model exposed by a provider."""
|
|
98
|
+
|
|
99
|
+
id: str
|
|
100
|
+
name: str | None = None
|
|
101
|
+
context_window: int | None = None
|
|
102
|
+
supports_tools: bool = False
|
|
103
|
+
supports_vision: bool = False
|
|
104
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
TConfig = TypeVar("TConfig")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class Provider(ABC, Generic[TConfig]):
|
|
111
|
+
"""Base class for AI providers.
|
|
112
|
+
|
|
113
|
+
Concrete providers should subclass :class:`Provider` with a concrete
|
|
114
|
+
``TConfig`` type and implement the abstract methods below.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
name: ClassVar[str] = "abstract"
|
|
118
|
+
|
|
119
|
+
def __init__(self, config: TConfig) -> None:
|
|
120
|
+
self._config = config
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def config(self) -> TConfig:
|
|
124
|
+
return self._config
|
|
125
|
+
|
|
126
|
+
@abstractmethod
|
|
127
|
+
async def chat(self, request: ChatRequest) -> ChatResponse:
|
|
128
|
+
"""Send ``request`` and return a :class:`ChatResponse`."""
|
|
129
|
+
|
|
130
|
+
async def stream(self, request: ChatRequest) -> AsyncIterator[StreamChunk]:
|
|
131
|
+
"""Stream a chat response. Default falls back to a single chunk."""
|
|
132
|
+
response = await self.chat(request)
|
|
133
|
+
yield StreamChunk(
|
|
134
|
+
delta=response.message.content,
|
|
135
|
+
finish_reason=response.finish_reason,
|
|
136
|
+
raw=response.raw,
|
|
137
|
+
)
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
@abstractmethod
|
|
141
|
+
async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse:
|
|
142
|
+
"""Compute embeddings for ``request.inputs``."""
|
|
143
|
+
|
|
144
|
+
async def list_models(self) -> list[ModelInfo]:
|
|
145
|
+
"""Return the list of models supported by this provider."""
|
|
146
|
+
return []
|
|
147
|
+
|
|
148
|
+
def validate(self) -> None:
|
|
149
|
+
"""Hook for pre-flight checks (API key, base URL, etc)."""
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def resolve_api_key(env_var: str | None) -> str | None:
|
|
154
|
+
"""Return the API key from ``env_var`` or ``None``."""
|
|
155
|
+
if not env_var:
|
|
156
|
+
return None
|
|
157
|
+
return os.environ.get(env_var)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class ProviderRegistry:
|
|
161
|
+
"""In-memory registry of provider classes keyed by name."""
|
|
162
|
+
|
|
163
|
+
def __init__(self) -> None:
|
|
164
|
+
self._providers: dict[str, type[Provider[Any]]] = {}
|
|
165
|
+
|
|
166
|
+
def register(self, name: str, provider_cls: type[Provider[Any]]) -> None:
|
|
167
|
+
if not issubclass(provider_cls, Provider):
|
|
168
|
+
raise ProviderError(f"{provider_cls!r} must subclass forgecli.providers.Provider")
|
|
169
|
+
if name in self._providers and self._providers[name] is not provider_cls:
|
|
170
|
+
raise ProviderError(f"Provider {name!r} already registered")
|
|
171
|
+
self._providers[name] = provider_cls
|
|
172
|
+
|
|
173
|
+
def unregister(self, name: str) -> None:
|
|
174
|
+
self._providers.pop(name, None)
|
|
175
|
+
|
|
176
|
+
def create(self, name: str, config: Any) -> Provider[Any]:
|
|
177
|
+
cls = self._providers.get(name)
|
|
178
|
+
if cls is None:
|
|
179
|
+
raise ProviderError(f"Unknown provider: {name!r}")
|
|
180
|
+
return cls(config)
|
|
181
|
+
|
|
182
|
+
def get(self, name: str) -> type[Provider[Any]]:
|
|
183
|
+
cls = self._providers.get(name)
|
|
184
|
+
if cls is None:
|
|
185
|
+
raise ProviderError(f"Unknown provider: {name!r}")
|
|
186
|
+
return cls
|
|
187
|
+
|
|
188
|
+
def names(self) -> list[str]:
|
|
189
|
+
return sorted(self._providers)
|
|
190
|
+
|
|
191
|
+
def has(self, name: str) -> bool:
|
|
192
|
+
return name in self._providers
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# A default registry instance used by ``AppContext`` wiring.
|
|
196
|
+
default_registry = ProviderRegistry()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def iter_chunked(items: list[str], size: int) -> Iterator[list[str]]:
|
|
200
|
+
"""Yield ``items`` in fixed-size chunks; useful for batched embedding calls."""
|
|
201
|
+
if size <= 0:
|
|
202
|
+
raise ValueError("chunk size must be positive")
|
|
203
|
+
for i in range(0, len(items), size):
|
|
204
|
+
yield items[i : i + size]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Built-in providers.
|
|
2
|
+
|
|
3
|
+
The package ships with the following implementations:
|
|
4
|
+
|
|
5
|
+
* :class:`MockProvider` — deterministic, offline-friendly, used in tests.
|
|
6
|
+
* :class:`OpenAIProvider` — OpenAI Chat Completions + Embeddings.
|
|
7
|
+
* :class:`AnthropicProvider` — Anthropic Messages API.
|
|
8
|
+
* :class:`GeminiProvider` — Google Gemini generateContent + embedContent.
|
|
9
|
+
|
|
10
|
+
All four register themselves into :data:`default_registry` on import.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from forgecli.providers.anthropic import AnthropicConfig, AnthropicProvider
|
|
14
|
+
from forgecli.providers.google import GeminiConfig, GeminiProvider
|
|
15
|
+
from forgecli.providers.mock import MockProvider
|
|
16
|
+
from forgecli.providers.openai import OpenAIConfig, OpenAIProvider
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AnthropicConfig",
|
|
20
|
+
"AnthropicProvider",
|
|
21
|
+
"GeminiConfig",
|
|
22
|
+
"GeminiProvider",
|
|
23
|
+
"MockProvider",
|
|
24
|
+
"OpenAIConfig",
|
|
25
|
+
"OpenAIProvider",
|
|
26
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Shared helpers for conversational mock/offline responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
_GREETINGS = frozenset(
|
|
8
|
+
{
|
|
9
|
+
"hi",
|
|
10
|
+
"hello",
|
|
11
|
+
"hey",
|
|
12
|
+
"howdy",
|
|
13
|
+
"greetings",
|
|
14
|
+
"yo",
|
|
15
|
+
"sup",
|
|
16
|
+
"how",
|
|
17
|
+
"are",
|
|
18
|
+
"you",
|
|
19
|
+
"morning",
|
|
20
|
+
"afternoon",
|
|
21
|
+
"evening",
|
|
22
|
+
"good",
|
|
23
|
+
"what's",
|
|
24
|
+
"up",
|
|
25
|
+
"whats",
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
_GREETING_PHRASES = (
|
|
30
|
+
"hi",
|
|
31
|
+
"hello",
|
|
32
|
+
"hey",
|
|
33
|
+
"howdy",
|
|
34
|
+
"good morning",
|
|
35
|
+
"good afternoon",
|
|
36
|
+
"good evening",
|
|
37
|
+
"how are you",
|
|
38
|
+
"what's up",
|
|
39
|
+
"whats up",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_greeting(text: str) -> bool:
|
|
44
|
+
"""Return True when ``text`` looks like a short greeting."""
|
|
45
|
+
normalized = (text or "").strip().lower()
|
|
46
|
+
if not normalized:
|
|
47
|
+
return False
|
|
48
|
+
if normalized in _GREETING_PHRASES:
|
|
49
|
+
return True
|
|
50
|
+
words = re.findall(r"\b\w+\b", normalized)
|
|
51
|
+
if not words or len(words) > 4:
|
|
52
|
+
return False
|
|
53
|
+
return any(word in _GREETINGS for word in words)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def greeting_reply(text: str) -> str:
|
|
57
|
+
"""Return a natural assistant greeting."""
|
|
58
|
+
normalized = (text or "").strip().lower()
|
|
59
|
+
if "how are you" in normalized:
|
|
60
|
+
return "I'm doing well, thanks for asking. How can I help with your project today?"
|
|
61
|
+
if any(p in normalized for p in ("good morning", "good afternoon", "good evening")):
|
|
62
|
+
return "Hello! How can I help you today?"
|
|
63
|
+
return "Hi! How can I help you today?"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def offline_build_notice() -> str:
|
|
67
|
+
"""Message returned when a build is requested without a configured provider."""
|
|
68
|
+
return (
|
|
69
|
+
"No AI provider is configured. Configure a provider and retry, "
|
|
70
|
+
"or pass `--mock` for offline mode."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["greeting_reply", "is_greeting", "offline_build_notice"]
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Google Gemini (Generative Language API) provider.
|
|
2
|
+
|
|
3
|
+
Targets the public ``https://generativelanguage.googleapis.com/v1beta``
|
|
4
|
+
endpoint. The provider follows the modern ``generateContent`` schema,
|
|
5
|
+
translating ChatRequest/Response into Gemini's ``contents`` array and
|
|
6
|
+
mapping the ``usageMetadata`` token counters back to the unified
|
|
7
|
+
format.
|
|
8
|
+
|
|
9
|
+
API keys are read from ``GOOGLE_API_KEY`` (with ``GEMINI_API_KEY`` as
|
|
10
|
+
a fallback) and are passed as the ``key`` query parameter, matching
|
|
11
|
+
the public REST contract.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import AsyncIterator
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from forgecli.providers.base import (
|
|
23
|
+
ChatMessage,
|
|
24
|
+
ChatRequest,
|
|
25
|
+
ChatResponse,
|
|
26
|
+
EmbeddingRequest,
|
|
27
|
+
EmbeddingResponse,
|
|
28
|
+
ModelInfo,
|
|
29
|
+
Role,
|
|
30
|
+
StreamChunk,
|
|
31
|
+
)
|
|
32
|
+
from forgecli.providers.http_base import HTTPChatProvider
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class GeminiConfig:
|
|
37
|
+
"""Configuration for the Google Gemini provider."""
|
|
38
|
+
|
|
39
|
+
api_key_env: str = "GOOGLE_API_KEY"
|
|
40
|
+
base_url: str = "https://generativelanguage.googleapis.com/v1beta"
|
|
41
|
+
default_model: str = "gemini-1.5-flash"
|
|
42
|
+
max_tokens: int = 4096
|
|
43
|
+
temperature: float = 0.2
|
|
44
|
+
embeddings_model: str = "text-embedding-004"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_GEMINI_ROLE_MAP: dict[Role, str] = {
|
|
48
|
+
Role.SYSTEM: "user", # Gemini has no "system" role; fold into a user turn
|
|
49
|
+
Role.USER: "user",
|
|
50
|
+
Role.ASSISTANT: "model",
|
|
51
|
+
Role.TOOL: "user",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class GeminiProvider(HTTPChatProvider):
|
|
56
|
+
"""Google Gemini generateContent + embedContent provider."""
|
|
57
|
+
|
|
58
|
+
name = "google"
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
config: GeminiConfig | None = None,
|
|
63
|
+
*,
|
|
64
|
+
api_key: str | None = None,
|
|
65
|
+
base_url: str | None = None,
|
|
66
|
+
client: httpx.AsyncClient | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
super().__init__(
|
|
69
|
+
config or GeminiConfig(),
|
|
70
|
+
api_key=api_key,
|
|
71
|
+
base_url=base_url,
|
|
72
|
+
client=client,
|
|
73
|
+
)
|
|
74
|
+
if not self._api_key:
|
|
75
|
+
# Fall back to the alternate env var; this is intentional.
|
|
76
|
+
self._api_key = _fallback_api_key(self.config.api_key_env)
|
|
77
|
+
|
|
78
|
+
def _default_base_url(self) -> str:
|
|
79
|
+
return "https://generativelanguage.googleapis.com/v1beta"
|
|
80
|
+
|
|
81
|
+
def _chat_url(self) -> str:
|
|
82
|
+
# The Gemini chat URL embeds the model name; we use
|
|
83
|
+
# ``_chat_url_for`` instead. The fallback is here so abstract
|
|
84
|
+
# callers can still get a URL when they have no request.
|
|
85
|
+
return (
|
|
86
|
+
f"{self._base_url}/models/{self.config.default_model}:generateContent"
|
|
87
|
+
f"?key={self._api_key or ''}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
def _chat_url_for(self, request) -> str: # type: ignore[override]
|
|
91
|
+
model = request.model or self.config.default_model
|
|
92
|
+
return f"{self._base_url}/models/{model}:generateContent?key={self._api_key or ''}"
|
|
93
|
+
|
|
94
|
+
def _embeddings_url(self) -> str:
|
|
95
|
+
return (
|
|
96
|
+
f"{self._base_url}/models/{self.config.embeddings_model}:embedContent"
|
|
97
|
+
f"?key={self._api_key or ''}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def _default_embedding_model(self) -> str:
|
|
101
|
+
return self.config.embeddings_model
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
# Auth
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
108
|
+
# Gemini uses the API key in the query string; no headers needed.
|
|
109
|
+
return {"Content-Type": "application/json"}
|
|
110
|
+
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
# Request / response
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
def _format_request(self, request: ChatRequest) -> dict[str, Any]:
|
|
116
|
+
contents: list[dict[str, Any]] = []
|
|
117
|
+
system_parts: list[str] = []
|
|
118
|
+
for message in request.messages:
|
|
119
|
+
if message.role is Role.SYSTEM:
|
|
120
|
+
if message.content:
|
|
121
|
+
system_parts.append(message.content)
|
|
122
|
+
continue
|
|
123
|
+
contents.append(
|
|
124
|
+
{
|
|
125
|
+
"role": _GEMINI_ROLE_MAP[message.role],
|
|
126
|
+
"parts": [{"text": message.content}],
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
body: dict[str, Any] = {
|
|
130
|
+
"contents": contents,
|
|
131
|
+
"generationConfig": {
|
|
132
|
+
"maxOutputTokens": request.max_tokens or self.config.max_tokens,
|
|
133
|
+
"temperature": (
|
|
134
|
+
request.temperature
|
|
135
|
+
if request.temperature is not None
|
|
136
|
+
else self.config.temperature
|
|
137
|
+
),
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
if system_parts:
|
|
141
|
+
body["systemInstruction"] = {"parts": [{"text": "\n\n".join(system_parts)}]}
|
|
142
|
+
if request.top_p is not None:
|
|
143
|
+
body["generationConfig"]["topP"] = request.top_p
|
|
144
|
+
if request.stop:
|
|
145
|
+
body["generationConfig"]["stopSequences"] = list(request.stop)
|
|
146
|
+
return body
|
|
147
|
+
|
|
148
|
+
def _parse_response(self, payload: dict[str, Any]) -> ChatResponse:
|
|
149
|
+
candidates = payload.get("candidates") or []
|
|
150
|
+
first = candidates[0] if candidates else {}
|
|
151
|
+
content = first.get("content") or {}
|
|
152
|
+
parts = content.get("parts") or []
|
|
153
|
+
text = "".join(str(p.get("text", "")) for p in parts if isinstance(p, dict))
|
|
154
|
+
usage = payload.get("usageMetadata") or {}
|
|
155
|
+
return ChatResponse(
|
|
156
|
+
model=str(payload.get("modelVersion", self.config.default_model)),
|
|
157
|
+
message=ChatMessage(role=Role.ASSISTANT, content=text),
|
|
158
|
+
finish_reason=first.get("finishReason"),
|
|
159
|
+
prompt_tokens=int(usage.get("promptTokenCount", 0) or 0),
|
|
160
|
+
completion_tokens=int(usage.get("candidatesTokenCount", 0) or 0),
|
|
161
|
+
total_tokens=int(usage.get("totalTokenCount", 0) or 0),
|
|
162
|
+
raw=payload,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
async def stream(self, request: ChatRequest) -> AsyncIterator[StreamChunk]:
|
|
166
|
+
from forgecli.core.errors import ProviderError
|
|
167
|
+
|
|
168
|
+
if not self._api_key:
|
|
169
|
+
self.validate()
|
|
170
|
+
body = self._format_request(request)
|
|
171
|
+
model = request.model or self.config.default_model
|
|
172
|
+
url = f"{self._base_url}/models/{model}:streamGenerateContent?key={self._api_key or ''}"
|
|
173
|
+
req = self._client.build_request("POST", url, json=body, headers=self._auth_headers())
|
|
174
|
+
response = await self._client.send(req, stream=True)
|
|
175
|
+
if response.status_code >= 400:
|
|
176
|
+
await response.aread()
|
|
177
|
+
raise ProviderError(
|
|
178
|
+
f"{self.name} stream failed ({response.status_code}): {response.text[:500]}"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
import json
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
async for line in response.aiter_lines():
|
|
185
|
+
line = line.strip()
|
|
186
|
+
if not line:
|
|
187
|
+
continue
|
|
188
|
+
if line.startswith(","):
|
|
189
|
+
line = line[1:].strip()
|
|
190
|
+
if line.startswith("[") or line.endswith("]"):
|
|
191
|
+
line = line.strip("[]").strip()
|
|
192
|
+
if not line:
|
|
193
|
+
continue
|
|
194
|
+
try:
|
|
195
|
+
payload = json.loads(line)
|
|
196
|
+
candidates = payload.get("candidates") or []
|
|
197
|
+
if candidates:
|
|
198
|
+
first = candidates[0]
|
|
199
|
+
content = first.get("content") or {}
|
|
200
|
+
parts = content.get("parts") or []
|
|
201
|
+
text = "".join(str(p.get("text", "")) for p in parts if isinstance(p, dict))
|
|
202
|
+
finish_reason = first.get("finishReason")
|
|
203
|
+
yield StreamChunk(delta=text, finish_reason=finish_reason, raw=payload)
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
|
206
|
+
finally:
|
|
207
|
+
await response.aclose()
|
|
208
|
+
|
|
209
|
+
def _format_embeddings(self, request: EmbeddingRequest) -> dict[str, Any]:
|
|
210
|
+
# The Gemini embedContent endpoint takes a single ``content``
|
|
211
|
+
# object; we issue one request per input to keep the wire
|
|
212
|
+
# format simple. Callers that need batching should compose it
|
|
213
|
+
# at a higher level.
|
|
214
|
+
if len(request.inputs) != 1:
|
|
215
|
+
from forgecli.core.errors import ProviderError
|
|
216
|
+
|
|
217
|
+
raise ProviderError(
|
|
218
|
+
f"{self.name} embed() expects exactly one input; got {len(request.inputs)}"
|
|
219
|
+
)
|
|
220
|
+
return {
|
|
221
|
+
"content": {
|
|
222
|
+
"parts": [{"text": request.inputs[0]}],
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
def _parse_embeddings(self, payload: dict[str, Any]) -> EmbeddingResponse:
|
|
227
|
+
embedding = payload.get("embedding") or {}
|
|
228
|
+
values = list(embedding.get("values", []))
|
|
229
|
+
return EmbeddingResponse(
|
|
230
|
+
model=str(payload.get("model", self.config.embeddings_model)),
|
|
231
|
+
vectors=[values],
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
async def list_models(self) -> list[ModelInfo]:
|
|
235
|
+
try:
|
|
236
|
+
url = f"{self._base_url}/models?key={self._api_key or ''}"
|
|
237
|
+
response = await self._client.get(url, timeout=5.0)
|
|
238
|
+
if response.status_code == 200:
|
|
239
|
+
data = response.json()
|
|
240
|
+
models_list = data.get("models", [])
|
|
241
|
+
if models_list:
|
|
242
|
+
out = []
|
|
243
|
+
for m in models_list:
|
|
244
|
+
m_name = m.get("name", "")
|
|
245
|
+
if m_name.startswith("models/"):
|
|
246
|
+
m_id = m_name.replace("models/", "")
|
|
247
|
+
out.append(
|
|
248
|
+
ModelInfo(
|
|
249
|
+
id=m_id,
|
|
250
|
+
name=m.get("displayName", m_id),
|
|
251
|
+
context_window=m.get("inputTokenLimit", 1_000_000),
|
|
252
|
+
supports_tools=True,
|
|
253
|
+
supports_vision=True,
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
if out:
|
|
257
|
+
return out
|
|
258
|
+
except Exception:
|
|
259
|
+
pass
|
|
260
|
+
return self._known_models()
|
|
261
|
+
|
|
262
|
+
def _known_models(self) -> list[ModelInfo]:
|
|
263
|
+
from forgecli.core.models import MODEL_CATALOG
|
|
264
|
+
|
|
265
|
+
return [
|
|
266
|
+
ModelInfo(
|
|
267
|
+
id=m.id,
|
|
268
|
+
name=m.display_name,
|
|
269
|
+
context_window=2_000_000 if "pro" in m.id else 1_000_000,
|
|
270
|
+
supports_tools=True,
|
|
271
|
+
supports_vision=True,
|
|
272
|
+
)
|
|
273
|
+
for m in MODEL_CATALOG
|
|
274
|
+
if m.provider == "google"
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _fallback_api_key(primary: str) -> str | None:
|
|
279
|
+
"""Read ``primary`` or ``GEMINI_API_KEY`` from the environment, or secure storage."""
|
|
280
|
+
import os
|
|
281
|
+
|
|
282
|
+
env_val = os.environ.get(primary) or os.environ.get("GEMINI_API_KEY")
|
|
283
|
+
if env_val:
|
|
284
|
+
return env_val
|
|
285
|
+
from forgecli.core.credentials import get_api_key
|
|
286
|
+
|
|
287
|
+
return get_api_key("google") or get_api_key("gemini")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
__all__ = ["GeminiConfig", "GeminiProvider"]
|