forgeoptimizer 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forgecli/__init__.py +4 -0
- forgecli/build/__init__.py +135 -0
- forgecli/build/apply.py +218 -0
- forgecli/build/caveman_optimize.py +37 -0
- forgecli/build/diff_extract.py +218 -0
- forgecli/build/llm.py +167 -0
- forgecli/build/optimize.py +37 -0
- forgecli/build/pipeline.py +76 -0
- forgecli/build/retrieval.py +157 -0
- forgecli/build/summarize.py +76 -0
- forgecli/build/test_run.py +75 -0
- forgecli/builder/__init__.py +11 -0
- forgecli/builder/builder.py +53 -0
- forgecli/builder/editor.py +36 -0
- forgecli/builder/formatter.py +31 -0
- forgecli/cli/__init__.py +5 -0
- forgecli/cli/bootstrap.py +281 -0
- forgecli/cli/commands_graph.py +137 -0
- forgecli/cli/commands_wrappers.py +63 -0
- forgecli/cli/main.py +122 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +204 -0
- forgecli/config/writer.py +90 -0
- forgecli/core/__init__.py +35 -0
- forgecli/core/container.py +68 -0
- forgecli/core/context.py +54 -0
- forgecli/core/credentials.py +114 -0
- forgecli/core/errors.py +27 -0
- forgecli/core/events.py +67 -0
- forgecli/core/logging.py +47 -0
- forgecli/core/models.py +159 -0
- forgecli/core/plugins.py +56 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +119 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +171 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +526 -0
- forgecli/engine/plugins.py +146 -0
- forgecli/engine/runner.py +155 -0
- forgecli/engine/stages/__init__.py +33 -0
- forgecli/engine/stages/caveman_optimizer.py +47 -0
- forgecli/engine/stages/context_optimizer.py +44 -0
- forgecli/engine/stages/execution_engine_stage.py +108 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +66 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +89 -0
- forgecli/git/__init__.py +9 -0
- forgecli/git/repo.py +55 -0
- forgecli/git/service.py +45 -0
- forgecli/graph/__init__.py +56 -0
- forgecli/graph/backend_graphify.py +453 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +412 -0
- forgecli/graph/indexer.py +82 -0
- forgecli/graph/node.py +47 -0
- forgecli/graph/repository.py +164 -0
- forgecli/memory/__init__.py +12 -0
- forgecli/memory/cache.py +61 -0
- forgecli/memory/history.py +138 -0
- forgecli/memory/store.py +87 -0
- forgecli/optimizer/__init__.py +14 -0
- forgecli/optimizer/caveman/__init__.py +173 -0
- forgecli/optimizer/caveman/cli.py +64 -0
- forgecli/optimizer/caveman/decorator.py +67 -0
- forgecli/optimizer/caveman/factory.py +51 -0
- forgecli/optimizer/caveman/ruleset.py +156 -0
- forgecli/optimizer/caveman/state.py +52 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +183 -0
- forgecli/optimizer/ponytail/cli.py +168 -0
- forgecli/optimizer/ponytail/decorator.py +70 -0
- forgecli/optimizer/ponytail/factory.py +51 -0
- forgecli/optimizer/ponytail/ruleset.py +168 -0
- forgecli/optimizer/ponytail/state.py +53 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +707 -0
- forgecli/planner/__init__.py +56 -0
- forgecli/planner/agent.py +61 -0
- forgecli/planner/plan.py +65 -0
- forgecli/planner/planner.py +17 -0
- forgecli/planner/render.py +271 -0
- forgecli/planner/serialize.py +106 -0
- forgecli/planner/software.py +834 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +354 -0
- forgecli/platform/paths.py +236 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +252 -0
- forgecli/plugins/__init__.py +251 -0
- forgecli/prompts/__init__.py +11 -0
- forgecli/prompts/loader.py +28 -0
- forgecli/prompts/registry.py +32 -0
- forgecli/prompts/renderer.py +23 -0
- forgecli/providers/__init__.py +39 -0
- forgecli/providers/anthropic.py +206 -0
- forgecli/providers/base.py +206 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +78 -0
- forgecli/providers/google.py +295 -0
- forgecli/providers/http_base.py +211 -0
- forgecli/providers/mock.py +113 -0
- forgecli/providers/openai.py +202 -0
- forgecli/providers/openai_compatible.py +728 -0
- forgecli/providers/router.py +345 -0
- forgecli/providers/router_state.py +89 -0
- forgecli/review/__init__.py +45 -0
- forgecli/review/analyzer.py +124 -0
- forgecli/review/analyzers/__init__.py +1 -0
- forgecli/review/analyzers/architecture.py +258 -0
- forgecli/review/analyzers/complexity.py +167 -0
- forgecli/review/analyzers/dead_code.py +262 -0
- forgecli/review/analyzers/duplicates.py +163 -0
- forgecli/review/analyzers/performance.py +165 -0
- forgecli/review/analyzers/security.py +251 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +311 -0
- forgecli/review/repository.py +131 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +77 -0
- forgecli/runtime/prepare.py +199 -0
- forgecli/runtime/wrappers.py +152 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +243 -0
- forgecli/sdk/manager.py +693 -0
- forgecli/sdk/manifest.py +397 -0
- forgecli/sdk/sandbox.py +197 -0
- forgecli/sdk/version.py +316 -0
- forgecli/templates/__init__.py +9 -0
- forgecli/templates/engine.py +37 -0
- forgecli/templates/registry.py +32 -0
- forgecli/utils/__init__.py +19 -0
- forgecli/utils/fs.py +91 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +70 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
- forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
- forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
- forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Async HTTP base class shared by real providers.
|
|
2
|
+
|
|
3
|
+
A minimal :class:`HTTPChatProvider` that:
|
|
4
|
+
|
|
5
|
+
* lazily resolves an API key from configuration;
|
|
6
|
+
* exposes a typed :class:`httpx.AsyncClient` with sane timeouts;
|
|
7
|
+
* logs request/response lifecycle through the standard :mod:`logging`.
|
|
8
|
+
|
|
9
|
+
Subclasses implement :meth:`_format_request` and :meth:`_parse_response`
|
|
10
|
+
to translate between the provider's wire format and ForgeCLI's
|
|
11
|
+
provider-agnostic dataclasses.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from collections.abc import Iterable
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from forgecli.core.errors import ProviderError
|
|
23
|
+
from forgecli.providers.base import (
|
|
24
|
+
ChatMessage,
|
|
25
|
+
ChatRequest,
|
|
26
|
+
ChatResponse,
|
|
27
|
+
EmbeddingRequest,
|
|
28
|
+
EmbeddingResponse,
|
|
29
|
+
ModelInfo,
|
|
30
|
+
Provider,
|
|
31
|
+
Role,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class HTTPChatProvider(Provider[Any]):
|
|
36
|
+
"""Base class for providers that talk JSON over HTTP."""
|
|
37
|
+
|
|
38
|
+
name = "abstract-http"
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
config: Any,
|
|
43
|
+
*,
|
|
44
|
+
api_key: str | None = None,
|
|
45
|
+
base_url: str | None = None,
|
|
46
|
+
timeout: float = 60.0,
|
|
47
|
+
client: httpx.AsyncClient | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
super().__init__(config)
|
|
50
|
+
self._api_key = api_key or self._resolve_api_key()
|
|
51
|
+
self._base_url = (
|
|
52
|
+
base_url
|
|
53
|
+
or getattr(config, "base_url", None)
|
|
54
|
+
or self._default_base_url()
|
|
55
|
+
).rstrip("/")
|
|
56
|
+
self._timeout = timeout
|
|
57
|
+
self._client = client or httpx.AsyncClient(timeout=timeout)
|
|
58
|
+
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
# Subclass extension points
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def _default_base_url(self) -> str:
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
|
|
66
|
+
def _resolve_api_key(self) -> str | None:
|
|
67
|
+
env_var = getattr(self.config, "api_key_env", None)
|
|
68
|
+
if env_var:
|
|
69
|
+
env_val = os.environ.get(env_var)
|
|
70
|
+
if env_val:
|
|
71
|
+
return env_val
|
|
72
|
+
from forgecli.core.credentials import get_api_key
|
|
73
|
+
# Check secure storage using provider name
|
|
74
|
+
val = get_api_key(self.name)
|
|
75
|
+
if val:
|
|
76
|
+
return val
|
|
77
|
+
if self.name == "google":
|
|
78
|
+
val = get_api_key("google") or get_api_key("gemini")
|
|
79
|
+
if val:
|
|
80
|
+
return val
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
def _format_request(self, request: ChatRequest) -> dict[str, Any]:
|
|
84
|
+
raise NotImplementedError
|
|
85
|
+
|
|
86
|
+
def _parse_response(self, payload: dict[str, Any]) -> ChatResponse:
|
|
87
|
+
raise NotImplementedError
|
|
88
|
+
|
|
89
|
+
def _embeddings_url(self) -> str: # pragma: no cover - optional
|
|
90
|
+
raise NotImplementedError
|
|
91
|
+
|
|
92
|
+
def _format_embeddings(self, request: EmbeddingRequest) -> dict[str, Any]:
|
|
93
|
+
raise NotImplementedError
|
|
94
|
+
|
|
95
|
+
def _parse_embeddings(self, payload: dict[str, Any]) -> EmbeddingResponse:
|
|
96
|
+
raise NotImplementedError
|
|
97
|
+
|
|
98
|
+
def _default_embedding_model(self) -> str: # pragma: no cover - optional
|
|
99
|
+
raise NotImplementedError
|
|
100
|
+
|
|
101
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
102
|
+
if not self._api_key:
|
|
103
|
+
return {}
|
|
104
|
+
return {"Authorization": f"Bearer {self._api_key}"}
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
# Lifecycle
|
|
108
|
+
# ------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
async def aclose(self) -> None:
|
|
111
|
+
if self._client is not None:
|
|
112
|
+
await self._client.aclose()
|
|
113
|
+
|
|
114
|
+
def validate(self) -> None:
|
|
115
|
+
if not self._api_key:
|
|
116
|
+
env_var = getattr(self.config, "api_key_env", "API_KEY")
|
|
117
|
+
raise ProviderError(
|
|
118
|
+
f"{self.name}: missing API key. Set the {env_var} environment variable."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
# Provider surface
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
async def chat(self, request: ChatRequest) -> ChatResponse:
|
|
126
|
+
if not self._api_key:
|
|
127
|
+
self.validate()
|
|
128
|
+
body = self._format_request(request)
|
|
129
|
+
response = await self._client.post(
|
|
130
|
+
self._chat_url_for(request), json=body, headers=self._auth_headers()
|
|
131
|
+
)
|
|
132
|
+
if response.status_code >= 400:
|
|
133
|
+
raise ProviderError(
|
|
134
|
+
f"{self.name} chat failed ({response.status_code}): "
|
|
135
|
+
f"{response.text[:500]}"
|
|
136
|
+
)
|
|
137
|
+
return self._parse_response(response.json())
|
|
138
|
+
|
|
139
|
+
async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse:
|
|
140
|
+
if not self._api_key:
|
|
141
|
+
self.validate()
|
|
142
|
+
body = self._format_embeddings(request)
|
|
143
|
+
response = await self._client.post(
|
|
144
|
+
self._embeddings_url(), json=body, headers=self._auth_headers()
|
|
145
|
+
)
|
|
146
|
+
if response.status_code >= 400:
|
|
147
|
+
raise ProviderError(
|
|
148
|
+
f"{self.name} embed failed ({response.status_code}): "
|
|
149
|
+
f"{response.text[:500]}"
|
|
150
|
+
)
|
|
151
|
+
return self._parse_embeddings(response.json())
|
|
152
|
+
|
|
153
|
+
async def list_models(self) -> list[ModelInfo]:
|
|
154
|
+
try:
|
|
155
|
+
# Skip for mock provider
|
|
156
|
+
if self.name == "mock":
|
|
157
|
+
return self._known_models()
|
|
158
|
+
response = await self._client.get(
|
|
159
|
+
f"{self._base_url}/models",
|
|
160
|
+
headers=self._auth_headers(),
|
|
161
|
+
timeout=5.0
|
|
162
|
+
)
|
|
163
|
+
if response.status_code == 200:
|
|
164
|
+
data = response.json()
|
|
165
|
+
models_list = data.get("data", [])
|
|
166
|
+
if models_list:
|
|
167
|
+
return [
|
|
168
|
+
ModelInfo(
|
|
169
|
+
id=str(m["id"]),
|
|
170
|
+
name=str(m.get("id")),
|
|
171
|
+
context_window=128_000,
|
|
172
|
+
supports_tools=True,
|
|
173
|
+
supports_vision=True
|
|
174
|
+
)
|
|
175
|
+
for m in models_list if isinstance(m, dict) and m.get("id") is not None
|
|
176
|
+
]
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
return self._known_models()
|
|
180
|
+
|
|
181
|
+
def _known_models(self) -> list[ModelInfo]:
|
|
182
|
+
return []
|
|
183
|
+
|
|
184
|
+
def _chat_url(self) -> str:
|
|
185
|
+
"""Default chat URL; providers may override :meth:`_chat_url_for`."""
|
|
186
|
+
raise NotImplementedError
|
|
187
|
+
|
|
188
|
+
def _chat_url_for(self, request: ChatRequest) -> str:
|
|
189
|
+
"""Return the chat URL for ``request``; default is the static URL."""
|
|
190
|
+
return self._chat_url()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
# Wire-format helpers
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def messages_to_openai(messages: Iterable[ChatMessage]) -> list[dict[str, Any]]:
|
|
199
|
+
"""Translate :class:`ChatMessage` objects into the OpenAI schema."""
|
|
200
|
+
out: list[dict[str, Any]] = []
|
|
201
|
+
for message in messages:
|
|
202
|
+
entry: dict[str, Any] = {"role": message.role.value, "content": message.content}
|
|
203
|
+
if message.name:
|
|
204
|
+
entry["name"] = message.name
|
|
205
|
+
if message.role is Role.TOOL and message.tool_call_id:
|
|
206
|
+
entry["tool_call_id"] = message.tool_call_id
|
|
207
|
+
out.append(entry)
|
|
208
|
+
return out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
__all__ = ["HTTPChatProvider", "messages_to_openai"]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""A deterministic provider used in tests and offline development."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from forgecli.providers.base import (
|
|
9
|
+
ChatMessage,
|
|
10
|
+
ChatRequest,
|
|
11
|
+
ChatResponse,
|
|
12
|
+
EmbeddingRequest,
|
|
13
|
+
EmbeddingResponse,
|
|
14
|
+
ModelInfo,
|
|
15
|
+
Provider,
|
|
16
|
+
Role,
|
|
17
|
+
StreamChunk,
|
|
18
|
+
)
|
|
19
|
+
from forgecli.providers.conversation import (
|
|
20
|
+
greeting_reply,
|
|
21
|
+
is_greeting,
|
|
22
|
+
offline_build_notice,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MockProviderConfig:
|
|
27
|
+
"""Configuration for :class:`MockProvider`."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, default_model: str = "mock-model", max_tokens: int = 1024) -> None:
|
|
30
|
+
self.default_model = default_model
|
|
31
|
+
self.max_tokens = max_tokens
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MockProvider(Provider[MockProviderConfig]):
|
|
35
|
+
"""Offline provider for tests and explicit ``--mock`` mode."""
|
|
36
|
+
|
|
37
|
+
name: ClassVar[str] = "mock"
|
|
38
|
+
_MODELS: ClassVar[list[ModelInfo]] = [
|
|
39
|
+
ModelInfo(id="mock-model", name="Mock Model", context_window=8192),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
def __init__(self, config: MockProviderConfig | None = None) -> None:
|
|
43
|
+
super().__init__(config or MockProviderConfig())
|
|
44
|
+
|
|
45
|
+
async def chat(self, request: ChatRequest) -> ChatResponse:
|
|
46
|
+
last_user = next(
|
|
47
|
+
(m for m in reversed(request.messages) if m.role is Role.USER),
|
|
48
|
+
None,
|
|
49
|
+
)
|
|
50
|
+
text = last_user.content if last_user else ""
|
|
51
|
+
|
|
52
|
+
has_diff_request = any(
|
|
53
|
+
m.role is Role.SYSTEM and "unified diff" in m.content.lower()
|
|
54
|
+
for m in request.messages
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
has_commit_request = any(
|
|
58
|
+
"commit" in m.content.lower()
|
|
59
|
+
for m in request.messages
|
|
60
|
+
) or "commit" in text.lower()
|
|
61
|
+
|
|
62
|
+
if has_commit_request:
|
|
63
|
+
content = (
|
|
64
|
+
"feat(auth): add secure provider authentication\n\n"
|
|
65
|
+
"- add OS keychain credential storage\n"
|
|
66
|
+
"- support provider selection\n"
|
|
67
|
+
"- improve onboarding flow\n"
|
|
68
|
+
"- add regression tests"
|
|
69
|
+
)
|
|
70
|
+
elif has_diff_request:
|
|
71
|
+
content = offline_build_notice()
|
|
72
|
+
elif is_greeting(text):
|
|
73
|
+
content = greeting_reply(text)
|
|
74
|
+
else:
|
|
75
|
+
content = (
|
|
76
|
+
"I'm running in offline mode without a configured AI provider. "
|
|
77
|
+
"Ask a question about your codebase after configuring an API key, "
|
|
78
|
+
"or pass `--mock` explicitly to stay offline."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return ChatResponse(
|
|
82
|
+
model=request.model or self.config.default_model,
|
|
83
|
+
message=ChatMessage(role=Role.ASSISTANT, content=content),
|
|
84
|
+
finish_reason="stop",
|
|
85
|
+
prompt_tokens=len(text),
|
|
86
|
+
completion_tokens=len(content),
|
|
87
|
+
total_tokens=len(text) + len(content),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
async def stream(self, request: ChatRequest):
|
|
91
|
+
response = await self.chat(request)
|
|
92
|
+
if response.message.content:
|
|
93
|
+
yield StreamChunk(delta=response.message.content, raw=response.raw)
|
|
94
|
+
yield StreamChunk(delta="", finish_reason="stop")
|
|
95
|
+
|
|
96
|
+
async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse:
|
|
97
|
+
vectors: list[list[float]] = []
|
|
98
|
+
for text in request.inputs:
|
|
99
|
+
digest = hashlib.sha256(text.encode("utf-8")).digest()
|
|
100
|
+
vec = [b / 255.0 for b in digest[:16]]
|
|
101
|
+
vectors.append(vec)
|
|
102
|
+
return EmbeddingResponse(
|
|
103
|
+
model=request.model or self.config.default_model,
|
|
104
|
+
vectors=vectors,
|
|
105
|
+
prompt_tokens=sum(len(t) for t in request.inputs),
|
|
106
|
+
total_tokens=sum(len(t) for t in request.inputs),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
async def list_models(self) -> list[ModelInfo]:
|
|
110
|
+
return list(self._MODELS)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
__all__ = ["MockProvider", "MockProviderConfig"]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""OpenAI Chat Completions provider.
|
|
2
|
+
|
|
3
|
+
Targets the public ``https://api.openai.com/v1`` endpoint, with sane
|
|
4
|
+
defaults for the most common models (gpt-4o, gpt-4o-mini, o1, …). The
|
|
5
|
+
provider is configured via the standard ``OPENAI_API_KEY`` environment
|
|
6
|
+
variable; the base URL can be overridden in the ForgeCLI config to
|
|
7
|
+
point at OpenAI-compatible servers (vLLM, LM Studio, llama.cpp).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import AsyncIterator
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from forgecli.providers.base import (
|
|
19
|
+
ChatMessage,
|
|
20
|
+
ChatRequest,
|
|
21
|
+
ChatResponse,
|
|
22
|
+
EmbeddingRequest,
|
|
23
|
+
EmbeddingResponse,
|
|
24
|
+
ModelInfo,
|
|
25
|
+
Role,
|
|
26
|
+
StreamChunk,
|
|
27
|
+
)
|
|
28
|
+
from forgecli.providers.http_base import HTTPChatProvider, messages_to_openai
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class OpenAIConfig:
|
|
33
|
+
"""Configuration for the OpenAI provider."""
|
|
34
|
+
|
|
35
|
+
api_key_env: str = "OPENAI_API_KEY"
|
|
36
|
+
base_url: str = "https://api.openai.com/v1"
|
|
37
|
+
default_model: str = "gpt-4o-mini"
|
|
38
|
+
max_tokens: int = 4096
|
|
39
|
+
temperature: float = 0.2
|
|
40
|
+
embeddings_model: str = "text-embedding-3-small"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OpenAIProvider(HTTPChatProvider):
|
|
44
|
+
"""OpenAI Chat Completions + Embeddings."""
|
|
45
|
+
|
|
46
|
+
name = "openai"
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
config: OpenAIConfig | None = None,
|
|
51
|
+
*,
|
|
52
|
+
api_key: str | None = None,
|
|
53
|
+
base_url: str | None = None,
|
|
54
|
+
client: httpx.AsyncClient | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
super().__init__(
|
|
57
|
+
config or OpenAIConfig(),
|
|
58
|
+
api_key=api_key,
|
|
59
|
+
base_url=base_url,
|
|
60
|
+
client=client,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def _default_base_url(self) -> str:
|
|
64
|
+
return "https://api.openai.com/v1"
|
|
65
|
+
|
|
66
|
+
def _chat_url(self) -> str:
|
|
67
|
+
return f"{self._base_url}/chat/completions"
|
|
68
|
+
|
|
69
|
+
def _embeddings_url(self) -> str:
|
|
70
|
+
return f"{self._base_url}/embeddings"
|
|
71
|
+
|
|
72
|
+
def _default_embedding_model(self) -> str:
|
|
73
|
+
return self.config.embeddings_model
|
|
74
|
+
|
|
75
|
+
def _format_request(self, request: ChatRequest) -> dict[str, Any]:
|
|
76
|
+
body: dict[str, Any] = {
|
|
77
|
+
"model": request.model or self.config.default_model,
|
|
78
|
+
"messages": messages_to_openai(request.messages),
|
|
79
|
+
}
|
|
80
|
+
model_lower = (request.model or self.config.default_model).lower()
|
|
81
|
+
is_reasoning_or_gpt5 = (
|
|
82
|
+
model_lower.startswith("o1")
|
|
83
|
+
or model_lower.startswith("o3")
|
|
84
|
+
or "gpt-5" in model_lower
|
|
85
|
+
)
|
|
86
|
+
is_pure_reasoning = (
|
|
87
|
+
model_lower.startswith("o1")
|
|
88
|
+
or model_lower.startswith("o3")
|
|
89
|
+
or "gpt-5" in model_lower
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if not is_pure_reasoning:
|
|
93
|
+
if request.temperature is not None:
|
|
94
|
+
body["temperature"] = request.temperature
|
|
95
|
+
else:
|
|
96
|
+
body["temperature"] = self.config.temperature
|
|
97
|
+
|
|
98
|
+
max_tokens_val = request.max_tokens if request.max_tokens is not None else self.config.max_tokens
|
|
99
|
+
if is_reasoning_or_gpt5:
|
|
100
|
+
body["max_completion_tokens"] = max_tokens_val
|
|
101
|
+
else:
|
|
102
|
+
body["max_tokens"] = max_tokens_val
|
|
103
|
+
|
|
104
|
+
if request.top_p is not None:
|
|
105
|
+
body["top_p"] = request.top_p
|
|
106
|
+
if request.stop:
|
|
107
|
+
body["stop"] = list(request.stop)
|
|
108
|
+
if request.tools:
|
|
109
|
+
body["tools"] = list(request.tools)
|
|
110
|
+
return body
|
|
111
|
+
|
|
112
|
+
def _parse_response(self, payload: dict[str, Any]) -> ChatResponse:
|
|
113
|
+
choices = payload.get("choices") or []
|
|
114
|
+
first = choices[0] if choices else {}
|
|
115
|
+
message = first.get("message") or {}
|
|
116
|
+
usage = payload.get("usage") or {}
|
|
117
|
+
return ChatResponse(
|
|
118
|
+
model=str(payload.get("model", self.config.default_model)),
|
|
119
|
+
message=ChatMessage(
|
|
120
|
+
role=Role(message.get("role", "assistant")),
|
|
121
|
+
content=str(message.get("content", "")),
|
|
122
|
+
),
|
|
123
|
+
finish_reason=first.get("finish_reason"),
|
|
124
|
+
prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
|
|
125
|
+
completion_tokens=int(usage.get("completion_tokens", 0) or 0),
|
|
126
|
+
total_tokens=int(usage.get("total_tokens", 0) or 0),
|
|
127
|
+
raw=payload,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def stream(self, request: ChatRequest) -> AsyncIterator[StreamChunk]:
|
|
131
|
+
from forgecli.core.errors import ProviderError
|
|
132
|
+
if not self._api_key:
|
|
133
|
+
self.validate()
|
|
134
|
+
body = self._format_request(request)
|
|
135
|
+
body["stream"] = True
|
|
136
|
+
|
|
137
|
+
req = self._client.build_request(
|
|
138
|
+
"POST", self._chat_url_for(request), json=body, headers=self._auth_headers()
|
|
139
|
+
)
|
|
140
|
+
response = await self._client.send(req, stream=True)
|
|
141
|
+
if response.status_code >= 400:
|
|
142
|
+
await response.aread()
|
|
143
|
+
raise ProviderError(
|
|
144
|
+
f"{self.name} stream failed ({response.status_code}): {response.text[:500]}"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
import json
|
|
148
|
+
try:
|
|
149
|
+
async for line in response.aiter_lines():
|
|
150
|
+
line = line.strip()
|
|
151
|
+
if not line:
|
|
152
|
+
continue
|
|
153
|
+
if line.startswith("data: "):
|
|
154
|
+
data_str = line[6:]
|
|
155
|
+
if data_str == "[DONE]":
|
|
156
|
+
break
|
|
157
|
+
try:
|
|
158
|
+
payload = json.loads(data_str)
|
|
159
|
+
choices = payload.get("choices", [])
|
|
160
|
+
if choices:
|
|
161
|
+
delta = choices[0].get("delta", {})
|
|
162
|
+
content = delta.get("content", "")
|
|
163
|
+
finish_reason = choices[0].get("finish_reason")
|
|
164
|
+
if content or finish_reason:
|
|
165
|
+
yield StreamChunk(delta=content, finish_reason=finish_reason, raw=payload)
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
finally:
|
|
169
|
+
await response.aclose()
|
|
170
|
+
|
|
171
|
+
def _format_embeddings(self, request: EmbeddingRequest) -> dict[str, Any]:
|
|
172
|
+
return {
|
|
173
|
+
"model": request.model or self.config.embeddings_model,
|
|
174
|
+
"input": list(request.inputs),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
def _parse_embeddings(self, payload: dict[str, Any]) -> EmbeddingResponse:
|
|
178
|
+
data = payload.get("data") or []
|
|
179
|
+
vectors = [list(item.get("embedding", [])) for item in data]
|
|
180
|
+
usage = payload.get("usage") or {}
|
|
181
|
+
return EmbeddingResponse(
|
|
182
|
+
model=str(payload.get("model", self.config.embeddings_model)),
|
|
183
|
+
vectors=vectors,
|
|
184
|
+
prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
|
|
185
|
+
total_tokens=int(usage.get("total_tokens", 0) or 0),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _known_models(self) -> list[ModelInfo]:
|
|
189
|
+
from forgecli.core.models import MODEL_CATALOG
|
|
190
|
+
return [
|
|
191
|
+
ModelInfo(
|
|
192
|
+
id=m.id,
|
|
193
|
+
name=m.display_name,
|
|
194
|
+
context_window=128_000,
|
|
195
|
+
supports_tools=not m.id.startswith("o1"),
|
|
196
|
+
supports_vision=True
|
|
197
|
+
)
|
|
198
|
+
for m in MODEL_CATALOG if m.provider == "openai"
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
__all__ = ["OpenAIConfig", "OpenAIProvider"]
|