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,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"]