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.
Files changed (156) 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 +137 -0
  19. forgecli/cli/commands_wrappers.py +63 -0
  20. forgecli/cli/main.py +122 -0
  21. forgecli/cli/ui.py +56 -0
  22. forgecli/config/__init__.py +28 -0
  23. forgecli/config/loader.py +85 -0
  24. forgecli/config/settings.py +204 -0
  25. forgecli/config/writer.py +90 -0
  26. forgecli/core/__init__.py +35 -0
  27. forgecli/core/container.py +68 -0
  28. forgecli/core/context.py +54 -0
  29. forgecli/core/credentials.py +114 -0
  30. forgecli/core/errors.py +27 -0
  31. forgecli/core/events.py +67 -0
  32. forgecli/core/logging.py +47 -0
  33. forgecli/core/models.py +159 -0
  34. forgecli/core/plugins.py +56 -0
  35. forgecli/core/service.py +22 -0
  36. forgecli/docs/__init__.py +5 -0
  37. forgecli/docs/generator.py +119 -0
  38. forgecli/engine/__init__.py +105 -0
  39. forgecli/engine/context.py +171 -0
  40. forgecli/engine/defaults.py +89 -0
  41. forgecli/engine/events.py +185 -0
  42. forgecli/engine/execution.py +526 -0
  43. forgecli/engine/plugins.py +146 -0
  44. forgecli/engine/runner.py +155 -0
  45. forgecli/engine/stages/__init__.py +33 -0
  46. forgecli/engine/stages/caveman_optimizer.py +47 -0
  47. forgecli/engine/stages/context_optimizer.py +44 -0
  48. forgecli/engine/stages/execution_engine_stage.py +108 -0
  49. forgecli/engine/stages/git_engine.py +30 -0
  50. forgecli/engine/stages/intent_analyzer.py +38 -0
  51. forgecli/engine/stages/model_router.py +66 -0
  52. forgecli/engine/stages/planning_engine.py +45 -0
  53. forgecli/engine/stages/repository_analyzer.py +54 -0
  54. forgecli/engine/stages/validation_engine.py +89 -0
  55. forgecli/git/__init__.py +9 -0
  56. forgecli/git/repo.py +55 -0
  57. forgecli/git/service.py +45 -0
  58. forgecli/graph/__init__.py +56 -0
  59. forgecli/graph/backend_graphify.py +453 -0
  60. forgecli/graph/edge.py +19 -0
  61. forgecli/graph/graph.py +85 -0
  62. forgecli/graph/graphify.py +412 -0
  63. forgecli/graph/indexer.py +82 -0
  64. forgecli/graph/node.py +47 -0
  65. forgecli/graph/repository.py +164 -0
  66. forgecli/memory/__init__.py +12 -0
  67. forgecli/memory/cache.py +61 -0
  68. forgecli/memory/history.py +138 -0
  69. forgecli/memory/store.py +87 -0
  70. forgecli/optimizer/__init__.py +14 -0
  71. forgecli/optimizer/caveman/__init__.py +173 -0
  72. forgecli/optimizer/caveman/cli.py +64 -0
  73. forgecli/optimizer/caveman/decorator.py +67 -0
  74. forgecli/optimizer/caveman/factory.py +51 -0
  75. forgecli/optimizer/caveman/ruleset.py +156 -0
  76. forgecli/optimizer/caveman/state.py +52 -0
  77. forgecli/optimizer/chunker.py +85 -0
  78. forgecli/optimizer/optimizer.py +81 -0
  79. forgecli/optimizer/ponytail/__init__.py +183 -0
  80. forgecli/optimizer/ponytail/cli.py +168 -0
  81. forgecli/optimizer/ponytail/decorator.py +70 -0
  82. forgecli/optimizer/ponytail/factory.py +51 -0
  83. forgecli/optimizer/ponytail/ruleset.py +168 -0
  84. forgecli/optimizer/ponytail/state.py +53 -0
  85. forgecli/optimizer/ranker.py +37 -0
  86. forgecli/optimizer/summarizer.py +44 -0
  87. forgecli/orchestrator/__init__.py +707 -0
  88. forgecli/planner/__init__.py +56 -0
  89. forgecli/planner/agent.py +61 -0
  90. forgecli/planner/plan.py +65 -0
  91. forgecli/planner/planner.py +17 -0
  92. forgecli/planner/render.py +271 -0
  93. forgecli/planner/serialize.py +106 -0
  94. forgecli/planner/software.py +834 -0
  95. forgecli/platform/__init__.py +91 -0
  96. forgecli/platform/core.py +201 -0
  97. forgecli/platform/deps.py +354 -0
  98. forgecli/platform/paths.py +236 -0
  99. forgecli/platform/shell.py +176 -0
  100. forgecli/platform/update.py +252 -0
  101. forgecli/plugins/__init__.py +251 -0
  102. forgecli/prompts/__init__.py +11 -0
  103. forgecli/prompts/loader.py +28 -0
  104. forgecli/prompts/registry.py +32 -0
  105. forgecli/prompts/renderer.py +23 -0
  106. forgecli/providers/__init__.py +39 -0
  107. forgecli/providers/anthropic.py +206 -0
  108. forgecli/providers/base.py +206 -0
  109. forgecli/providers/builtin.py +26 -0
  110. forgecli/providers/conversation.py +78 -0
  111. forgecli/providers/google.py +295 -0
  112. forgecli/providers/http_base.py +211 -0
  113. forgecli/providers/mock.py +113 -0
  114. forgecli/providers/openai.py +202 -0
  115. forgecli/providers/openai_compatible.py +728 -0
  116. forgecli/providers/router.py +345 -0
  117. forgecli/providers/router_state.py +89 -0
  118. forgecli/review/__init__.py +45 -0
  119. forgecli/review/analyzer.py +124 -0
  120. forgecli/review/analyzers/__init__.py +1 -0
  121. forgecli/review/analyzers/architecture.py +258 -0
  122. forgecli/review/analyzers/complexity.py +167 -0
  123. forgecli/review/analyzers/dead_code.py +262 -0
  124. forgecli/review/analyzers/duplicates.py +163 -0
  125. forgecli/review/analyzers/performance.py +165 -0
  126. forgecli/review/analyzers/security.py +251 -0
  127. forgecli/review/finding.py +68 -0
  128. forgecli/review/report.py +311 -0
  129. forgecli/review/repository.py +131 -0
  130. forgecli/review/suggestions.py +98 -0
  131. forgecli/runtime/__init__.py +6 -0
  132. forgecli/runtime/cache_store.py +77 -0
  133. forgecli/runtime/prepare.py +199 -0
  134. forgecli/runtime/wrappers.py +152 -0
  135. forgecli/sdk/__init__.py +132 -0
  136. forgecli/sdk/events.py +206 -0
  137. forgecli/sdk/interfaces.py +282 -0
  138. forgecli/sdk/loader.py +243 -0
  139. forgecli/sdk/manager.py +693 -0
  140. forgecli/sdk/manifest.py +397 -0
  141. forgecli/sdk/sandbox.py +197 -0
  142. forgecli/sdk/version.py +316 -0
  143. forgecli/templates/__init__.py +9 -0
  144. forgecli/templates/engine.py +37 -0
  145. forgecli/templates/registry.py +32 -0
  146. forgecli/utils/__init__.py +19 -0
  147. forgecli/utils/fs.py +91 -0
  148. forgecli/utils/ids.py +11 -0
  149. forgecli/utils/io.py +27 -0
  150. forgecli/utils/paths.py +70 -0
  151. forgecli/utils/timing.py +25 -0
  152. forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
  153. forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
  154. forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
  155. forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
  156. 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"]