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,207 @@
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 or getattr(config, "base_url", None) or self._default_base_url()
53
+ ).rstrip("/")
54
+ self._timeout = timeout
55
+ self._client = client or httpx.AsyncClient(timeout=timeout)
56
+
57
+ # ------------------------------------------------------------------
58
+ # Subclass extension points
59
+ # ------------------------------------------------------------------
60
+
61
+ def _default_base_url(self) -> str:
62
+ raise NotImplementedError
63
+
64
+ def _resolve_api_key(self) -> str | None:
65
+ env_var = getattr(self.config, "api_key_env", None)
66
+ if env_var:
67
+ env_val = os.environ.get(env_var)
68
+ if env_val:
69
+ return env_val
70
+ from forgecli.core.credentials import get_api_key
71
+
72
+ # Check secure storage using provider name
73
+ val = get_api_key(self.name)
74
+ if val:
75
+ return val
76
+ if self.name == "google":
77
+ val = get_api_key("google") or get_api_key("gemini")
78
+ if val:
79
+ return val
80
+ return None
81
+
82
+ def _format_request(self, request: ChatRequest) -> dict[str, Any]:
83
+ raise NotImplementedError
84
+
85
+ def _parse_response(self, payload: dict[str, Any]) -> ChatResponse:
86
+ raise NotImplementedError
87
+
88
+ def _embeddings_url(self) -> str: # pragma: no cover - optional
89
+ raise NotImplementedError
90
+
91
+ def _format_embeddings(self, request: EmbeddingRequest) -> dict[str, Any]:
92
+ raise NotImplementedError
93
+
94
+ def _parse_embeddings(self, payload: dict[str, Any]) -> EmbeddingResponse:
95
+ raise NotImplementedError
96
+
97
+ def _default_embedding_model(self) -> str: # pragma: no cover - optional
98
+ raise NotImplementedError
99
+
100
+ def _auth_headers(self) -> dict[str, str]:
101
+ if not self._api_key:
102
+ return {}
103
+ return {"Authorization": f"Bearer {self._api_key}"}
104
+
105
+ # ------------------------------------------------------------------
106
+ # Lifecycle
107
+ # ------------------------------------------------------------------
108
+
109
+ async def aclose(self) -> None:
110
+ if self._client is not None:
111
+ await self._client.aclose()
112
+
113
+ def validate(self) -> None:
114
+ if not self._api_key:
115
+ env_var = getattr(self.config, "api_key_env", "API_KEY")
116
+ raise ProviderError(
117
+ f"{self.name}: missing API key. Set the {env_var} environment variable."
118
+ )
119
+
120
+ # ------------------------------------------------------------------
121
+ # Provider surface
122
+ # ------------------------------------------------------------------
123
+
124
+ async def chat(self, request: ChatRequest) -> ChatResponse:
125
+ if not self._api_key:
126
+ self.validate()
127
+ body = self._format_request(request)
128
+ response = await self._client.post(
129
+ self._chat_url_for(request), json=body, headers=self._auth_headers()
130
+ )
131
+ if response.status_code >= 400:
132
+ raise ProviderError(
133
+ f"{self.name} chat failed ({response.status_code}): {response.text[:500]}"
134
+ )
135
+ return self._parse_response(response.json())
136
+
137
+ async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse:
138
+ if not self._api_key:
139
+ self.validate()
140
+ body = self._format_embeddings(request)
141
+ response = await self._client.post(
142
+ self._embeddings_url(), json=body, headers=self._auth_headers()
143
+ )
144
+ if response.status_code >= 400:
145
+ raise ProviderError(
146
+ f"{self.name} embed failed ({response.status_code}): {response.text[:500]}"
147
+ )
148
+ return self._parse_embeddings(response.json())
149
+
150
+ async def list_models(self) -> list[ModelInfo]:
151
+ try:
152
+ # Skip for mock provider
153
+ if self.name == "mock":
154
+ return self._known_models()
155
+ response = await self._client.get(
156
+ f"{self._base_url}/models", headers=self._auth_headers(), timeout=5.0
157
+ )
158
+ if response.status_code == 200:
159
+ data = response.json()
160
+ models_list = data.get("data", [])
161
+ if models_list:
162
+ return [
163
+ ModelInfo(
164
+ id=str(m["id"]),
165
+ name=str(m.get("id")),
166
+ context_window=128_000,
167
+ supports_tools=True,
168
+ supports_vision=True,
169
+ )
170
+ for m in models_list
171
+ if isinstance(m, dict) and m.get("id") is not None
172
+ ]
173
+ except Exception:
174
+ pass
175
+ return self._known_models()
176
+
177
+ def _known_models(self) -> list[ModelInfo]:
178
+ return []
179
+
180
+ def _chat_url(self) -> str:
181
+ """Default chat URL; providers may override :meth:`_chat_url_for`."""
182
+ raise NotImplementedError
183
+
184
+ def _chat_url_for(self, request: ChatRequest) -> str:
185
+ """Return the chat URL for ``request``; default is the static URL."""
186
+ return self._chat_url()
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Wire-format helpers
191
+ # ---------------------------------------------------------------------------
192
+
193
+
194
+ def messages_to_openai(messages: Iterable[ChatMessage]) -> list[dict[str, Any]]:
195
+ """Translate :class:`ChatMessage` objects into the OpenAI schema."""
196
+ out: list[dict[str, Any]] = []
197
+ for message in messages:
198
+ entry: dict[str, Any] = {"role": message.role.value, "content": message.content}
199
+ if message.name:
200
+ entry["name"] = message.name
201
+ if message.role is Role.TOOL and message.tool_call_id:
202
+ entry["tool_call_id"] = message.tool_call_id
203
+ out.append(entry)
204
+ return out
205
+
206
+
207
+ __all__ = ["HTTPChatProvider", "messages_to_openai"]
@@ -0,0 +1,111 @@
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() for m in request.messages
54
+ )
55
+
56
+ has_commit_request = (
57
+ any("commit" in m.content.lower() for m in request.messages) or "commit" in text.lower()
58
+ )
59
+
60
+ if has_commit_request:
61
+ content = (
62
+ "feat(auth): add secure provider authentication\n\n"
63
+ "- add OS keychain credential storage\n"
64
+ "- support provider selection\n"
65
+ "- improve onboarding flow\n"
66
+ "- add regression tests"
67
+ )
68
+ elif has_diff_request:
69
+ content = offline_build_notice()
70
+ elif is_greeting(text):
71
+ content = greeting_reply(text)
72
+ else:
73
+ content = (
74
+ "I'm running in offline mode without a configured AI provider. "
75
+ "Ask a question about your codebase after configuring an API key, "
76
+ "or pass `--mock` explicitly to stay offline."
77
+ )
78
+
79
+ return ChatResponse(
80
+ model=request.model or self.config.default_model,
81
+ message=ChatMessage(role=Role.ASSISTANT, content=content),
82
+ finish_reason="stop",
83
+ prompt_tokens=len(text),
84
+ completion_tokens=len(content),
85
+ total_tokens=len(text) + len(content),
86
+ )
87
+
88
+ async def stream(self, request: ChatRequest):
89
+ response = await self.chat(request)
90
+ if response.message.content:
91
+ yield StreamChunk(delta=response.message.content, raw=response.raw)
92
+ yield StreamChunk(delta="", finish_reason="stop")
93
+
94
+ async def embed(self, request: EmbeddingRequest) -> EmbeddingResponse:
95
+ vectors: list[list[float]] = []
96
+ for text in request.inputs:
97
+ digest = hashlib.sha256(text.encode("utf-8")).digest()
98
+ vec = [b / 255.0 for b in digest[:16]]
99
+ vectors.append(vec)
100
+ return EmbeddingResponse(
101
+ model=request.model or self.config.default_model,
102
+ vectors=vectors,
103
+ prompt_tokens=sum(len(t) for t in request.inputs),
104
+ total_tokens=sum(len(t) for t in request.inputs),
105
+ )
106
+
107
+ async def list_models(self) -> list[ModelInfo]:
108
+ return list(self._MODELS)
109
+
110
+
111
+ __all__ = ["MockProvider", "MockProviderConfig"]
@@ -0,0 +1,206 @@
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") or model_lower.startswith("o3") or "gpt-5" in model_lower
83
+ )
84
+ is_pure_reasoning = (
85
+ model_lower.startswith("o1") or model_lower.startswith("o3") or "gpt-5" in model_lower
86
+ )
87
+
88
+ if not is_pure_reasoning:
89
+ if request.temperature is not None:
90
+ body["temperature"] = request.temperature
91
+ else:
92
+ body["temperature"] = self.config.temperature
93
+
94
+ max_tokens_val = (
95
+ request.max_tokens if request.max_tokens is not None else self.config.max_tokens
96
+ )
97
+ if is_reasoning_or_gpt5:
98
+ body["max_completion_tokens"] = max_tokens_val
99
+ else:
100
+ body["max_tokens"] = max_tokens_val
101
+
102
+ if request.top_p is not None:
103
+ body["top_p"] = request.top_p
104
+ if request.stop:
105
+ body["stop"] = list(request.stop)
106
+ if request.tools:
107
+ body["tools"] = list(request.tools)
108
+ return body
109
+
110
+ def _parse_response(self, payload: dict[str, Any]) -> ChatResponse:
111
+ choices = payload.get("choices") or []
112
+ first = choices[0] if choices else {}
113
+ message = first.get("message") or {}
114
+ usage = payload.get("usage") or {}
115
+ return ChatResponse(
116
+ model=str(payload.get("model", self.config.default_model)),
117
+ message=ChatMessage(
118
+ role=Role(message.get("role", "assistant")),
119
+ content=str(message.get("content", "")),
120
+ ),
121
+ finish_reason=first.get("finish_reason"),
122
+ prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
123
+ completion_tokens=int(usage.get("completion_tokens", 0) or 0),
124
+ total_tokens=int(usage.get("total_tokens", 0) or 0),
125
+ raw=payload,
126
+ )
127
+
128
+ async def stream(self, request: ChatRequest) -> AsyncIterator[StreamChunk]:
129
+ from forgecli.core.errors import ProviderError
130
+
131
+ if not self._api_key:
132
+ self.validate()
133
+ body = self._format_request(request)
134
+ body["stream"] = True
135
+
136
+ req = self._client.build_request(
137
+ "POST", self._chat_url_for(request), json=body, headers=self._auth_headers()
138
+ )
139
+ response = await self._client.send(req, stream=True)
140
+ if response.status_code >= 400:
141
+ await response.aread()
142
+ raise ProviderError(
143
+ f"{self.name} stream failed ({response.status_code}): {response.text[:500]}"
144
+ )
145
+
146
+ import json
147
+
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(
166
+ delta=content, finish_reason=finish_reason, raw=payload
167
+ )
168
+ except Exception:
169
+ pass
170
+ finally:
171
+ await response.aclose()
172
+
173
+ def _format_embeddings(self, request: EmbeddingRequest) -> dict[str, Any]:
174
+ return {
175
+ "model": request.model or self.config.embeddings_model,
176
+ "input": list(request.inputs),
177
+ }
178
+
179
+ def _parse_embeddings(self, payload: dict[str, Any]) -> EmbeddingResponse:
180
+ data = payload.get("data") or []
181
+ vectors = [list(item.get("embedding", [])) for item in data]
182
+ usage = payload.get("usage") or {}
183
+ return EmbeddingResponse(
184
+ model=str(payload.get("model", self.config.embeddings_model)),
185
+ vectors=vectors,
186
+ prompt_tokens=int(usage.get("prompt_tokens", 0) or 0),
187
+ total_tokens=int(usage.get("total_tokens", 0) or 0),
188
+ )
189
+
190
+ def _known_models(self) -> list[ModelInfo]:
191
+ from forgecli.core.models import MODEL_CATALOG
192
+
193
+ return [
194
+ ModelInfo(
195
+ id=m.id,
196
+ name=m.display_name,
197
+ context_window=128_000,
198
+ supports_tools=not m.id.startswith("o1"),
199
+ supports_vision=True,
200
+ )
201
+ for m in MODEL_CATALOG
202
+ if m.provider == "openai"
203
+ ]
204
+
205
+
206
+ __all__ = ["OpenAIConfig", "OpenAIProvider"]