codeframe-ai 0.9.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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""OpenAI-compatible LLM provider implementation.
|
|
2
|
+
|
|
3
|
+
Provides access to OpenAI and any OpenAI-compatible endpoint
|
|
4
|
+
(Ollama, vLLM, LM Studio, Groq, Together, etc.) via the openai SDK.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
from typing import TYPE_CHECKING, AsyncIterator, Iterator, Optional
|
|
12
|
+
|
|
13
|
+
import openai
|
|
14
|
+
|
|
15
|
+
from codeframe.adapters.llm.base import (
|
|
16
|
+
LLMProvider,
|
|
17
|
+
LLMResponse,
|
|
18
|
+
ModelSelector,
|
|
19
|
+
Purpose,
|
|
20
|
+
StreamChunk,
|
|
21
|
+
Tool,
|
|
22
|
+
ToolCall,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from codeframe.core.credentials import CredentialManager
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
_STOP_REASON_MAP = {
|
|
31
|
+
"stop": "end_turn",
|
|
32
|
+
"tool_calls": "tool_use",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OpenAIProvider(LLMProvider):
|
|
37
|
+
"""OpenAI-compatible provider.
|
|
38
|
+
|
|
39
|
+
Uses the openai Python SDK to make API calls.
|
|
40
|
+
A configurable base_url covers the entire OpenAI-compatible ecosystem:
|
|
41
|
+
OpenAI, Ollama, vLLM, LM Studio, Groq, Together, etc.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
api_key: Optional[str] = None,
|
|
47
|
+
model: str = "gpt-4o",
|
|
48
|
+
base_url: Optional[str] = None,
|
|
49
|
+
model_selector: Optional[ModelSelector] = None,
|
|
50
|
+
credential_manager: Optional["CredentialManager"] = None,
|
|
51
|
+
):
|
|
52
|
+
"""Initialize the OpenAI provider.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
api_key: OpenAI API key (defaults to OPENAI_API_KEY env var)
|
|
56
|
+
model: Default model to use for all purposes
|
|
57
|
+
base_url: Custom endpoint URL for OpenAI-compatible APIs
|
|
58
|
+
model_selector: Optional model selector; when provided, defers to it for per-purpose routing
|
|
59
|
+
credential_manager: Optional credential manager for secure key retrieval
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ValueError: If no API key is available
|
|
63
|
+
"""
|
|
64
|
+
self._has_custom_selector = model_selector is not None
|
|
65
|
+
super().__init__(model_selector)
|
|
66
|
+
|
|
67
|
+
self.model = model
|
|
68
|
+
self.base_url = base_url
|
|
69
|
+
self.api_key = api_key
|
|
70
|
+
|
|
71
|
+
if not self.api_key and credential_manager:
|
|
72
|
+
from codeframe.core.credentials import CredentialProvider
|
|
73
|
+
self.api_key = credential_manager.get_credential(CredentialProvider.LLM_OPENAI)
|
|
74
|
+
|
|
75
|
+
if not self.api_key:
|
|
76
|
+
self.api_key = os.getenv("OPENAI_API_KEY")
|
|
77
|
+
|
|
78
|
+
if not self.api_key:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
"OPENAI_API_KEY not set. "
|
|
81
|
+
"Set the environment variable, pass api_key parameter, "
|
|
82
|
+
"or configure via 'codeframe auth setup --provider openai'."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
self._client = None
|
|
86
|
+
self._async_client = None
|
|
87
|
+
|
|
88
|
+
def get_model(self, purpose: Purpose) -> str:
|
|
89
|
+
"""Return the model for a given purpose.
|
|
90
|
+
|
|
91
|
+
When an explicit model_selector was provided, defers to it so callers
|
|
92
|
+
can route PLANNING/EXECUTION/GENERATION to different OpenAI models.
|
|
93
|
+
Otherwise returns self.model for all purposes (single-model mode).
|
|
94
|
+
"""
|
|
95
|
+
if self._has_custom_selector:
|
|
96
|
+
return self.model_selector.for_purpose(purpose)
|
|
97
|
+
return self.model
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def client(self):
|
|
101
|
+
"""Lazy-load the OpenAI client."""
|
|
102
|
+
if self._client is None:
|
|
103
|
+
self._client = openai.OpenAI(api_key=self.api_key, base_url=self.base_url)
|
|
104
|
+
return self._client
|
|
105
|
+
|
|
106
|
+
def complete(
|
|
107
|
+
self,
|
|
108
|
+
messages: list[dict],
|
|
109
|
+
purpose: Purpose = Purpose.EXECUTION,
|
|
110
|
+
tools: Optional[list[Tool]] = None,
|
|
111
|
+
max_tokens: int = 4096,
|
|
112
|
+
temperature: float = 0.0,
|
|
113
|
+
system: Optional[str] = None,
|
|
114
|
+
) -> LLMResponse:
|
|
115
|
+
"""Generate a completion using an OpenAI-compatible API.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
messages: Conversation messages
|
|
119
|
+
purpose: Purpose of call (for model selection — always returns self.model)
|
|
120
|
+
tools: Available tools for the model to use
|
|
121
|
+
max_tokens: Maximum tokens to generate
|
|
122
|
+
temperature: Sampling temperature
|
|
123
|
+
system: System prompt
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
LLMResponse with content and/or tool calls
|
|
127
|
+
"""
|
|
128
|
+
converted = self._convert_messages(messages)
|
|
129
|
+
|
|
130
|
+
if system:
|
|
131
|
+
converted = [{"role": "system", "content": system}] + converted
|
|
132
|
+
|
|
133
|
+
kwargs = {
|
|
134
|
+
"model": self.get_model(purpose),
|
|
135
|
+
"max_tokens": max_tokens,
|
|
136
|
+
"messages": converted,
|
|
137
|
+
"temperature": temperature,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if tools:
|
|
141
|
+
kwargs["tools"] = self._convert_tools(tools)
|
|
142
|
+
kwargs["tool_choice"] = "auto"
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
response = self.client.chat.completions.create(**kwargs)
|
|
146
|
+
except openai.AuthenticationError as exc:
|
|
147
|
+
raise ValueError(f"OpenAI authentication failed: {exc}") from exc
|
|
148
|
+
except openai.RateLimitError as exc:
|
|
149
|
+
raise ValueError(f"OpenAI rate limit exceeded: {exc}") from exc
|
|
150
|
+
except openai.NotFoundError as exc:
|
|
151
|
+
raise ValueError(f"OpenAI model not found: {exc}") from exc
|
|
152
|
+
|
|
153
|
+
return self._parse_response(response)
|
|
154
|
+
|
|
155
|
+
async def async_complete(
|
|
156
|
+
self,
|
|
157
|
+
messages: list[dict],
|
|
158
|
+
purpose: Purpose = Purpose.EXECUTION,
|
|
159
|
+
tools: Optional[list[Tool]] = None,
|
|
160
|
+
max_tokens: int = 4096,
|
|
161
|
+
temperature: float = 0.0,
|
|
162
|
+
system: Optional[str] = None,
|
|
163
|
+
) -> LLMResponse:
|
|
164
|
+
"""True async completion via openai.AsyncOpenAI.
|
|
165
|
+
|
|
166
|
+
Raises LLMAuthError / LLMRateLimitError / LLMConnectionError on failure.
|
|
167
|
+
"""
|
|
168
|
+
import openai as _openai
|
|
169
|
+
from codeframe.adapters.llm.base import (
|
|
170
|
+
LLMAuthError,
|
|
171
|
+
LLMRateLimitError,
|
|
172
|
+
LLMConnectionError,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if self._async_client is None:
|
|
176
|
+
self._async_client = _openai.AsyncOpenAI(
|
|
177
|
+
api_key=self.api_key, base_url=self.base_url
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
converted = self._convert_messages(messages)
|
|
181
|
+
if system:
|
|
182
|
+
converted = [{"role": "system", "content": system}] + converted
|
|
183
|
+
|
|
184
|
+
kwargs: dict = {
|
|
185
|
+
"model": self.get_model(purpose),
|
|
186
|
+
"max_tokens": max_tokens,
|
|
187
|
+
"messages": converted,
|
|
188
|
+
"temperature": temperature,
|
|
189
|
+
}
|
|
190
|
+
if tools:
|
|
191
|
+
kwargs["tools"] = self._convert_tools(tools)
|
|
192
|
+
kwargs["tool_choice"] = "auto"
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
response = await self._async_client.chat.completions.create(**kwargs)
|
|
196
|
+
return self._parse_response(response)
|
|
197
|
+
except _openai.AuthenticationError as exc:
|
|
198
|
+
raise LLMAuthError(str(exc)) from exc
|
|
199
|
+
except _openai.RateLimitError as exc:
|
|
200
|
+
raise LLMRateLimitError(str(exc)) from exc
|
|
201
|
+
except _openai.APIConnectionError as exc:
|
|
202
|
+
raise LLMConnectionError(str(exc)) from exc
|
|
203
|
+
|
|
204
|
+
async def async_stream(
|
|
205
|
+
self,
|
|
206
|
+
messages: list[dict],
|
|
207
|
+
system: str,
|
|
208
|
+
tools: list[dict],
|
|
209
|
+
model: str,
|
|
210
|
+
max_tokens: int,
|
|
211
|
+
interrupt_event: Optional[asyncio.Event] = None,
|
|
212
|
+
extended_thinking: bool = False,
|
|
213
|
+
) -> AsyncIterator[StreamChunk]:
|
|
214
|
+
"""Stream using OpenAI async client, yielding StreamChunk objects.
|
|
215
|
+
|
|
216
|
+
Translates OpenAI SSE chunks into the normalized StreamChunk format.
|
|
217
|
+
Tool calls are emitted as tool_use_start chunks (deferred until both
|
|
218
|
+
id and name are known); final inputs are collected and emitted in the
|
|
219
|
+
message_stop chunk via tool_inputs_by_id.
|
|
220
|
+
|
|
221
|
+
``extended_thinking`` is silently ignored — OpenAI-compatible endpoints
|
|
222
|
+
do not support Anthropic extended thinking.
|
|
223
|
+
"""
|
|
224
|
+
import openai as _openai
|
|
225
|
+
from codeframe.adapters.llm.base import (
|
|
226
|
+
LLMAuthError,
|
|
227
|
+
LLMConnectionError,
|
|
228
|
+
LLMRateLimitError,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if self._async_client is None:
|
|
232
|
+
self._async_client = _openai.AsyncOpenAI(
|
|
233
|
+
api_key=self.api_key, base_url=self.base_url
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
converted = self._convert_messages(messages)
|
|
237
|
+
if system:
|
|
238
|
+
converted = [{"role": "system", "content": system}] + converted
|
|
239
|
+
|
|
240
|
+
kwargs: dict = {
|
|
241
|
+
"model": model,
|
|
242
|
+
"max_tokens": max_tokens,
|
|
243
|
+
"messages": converted,
|
|
244
|
+
"stream": True,
|
|
245
|
+
"stream_options": {"include_usage": True},
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if tools:
|
|
249
|
+
kwargs["tools"] = self._convert_raw_tools(tools)
|
|
250
|
+
kwargs["tool_choice"] = "auto"
|
|
251
|
+
|
|
252
|
+
# Track partial tool calls across chunks (OpenAI streams them incrementally).
|
|
253
|
+
# key: index → {id, name, arguments_parts, emitted_start}
|
|
254
|
+
partial_tool_calls: dict[int, dict] = {}
|
|
255
|
+
usage_input: int = 0
|
|
256
|
+
usage_output: int = 0
|
|
257
|
+
stop_reason: str = "end_turn"
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
async for chunk in await self._async_client.chat.completions.create(**kwargs):
|
|
261
|
+
if interrupt_event and interrupt_event.is_set():
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
# Usage is in the final chunk when stream_options.include_usage is set
|
|
265
|
+
if chunk.usage is not None:
|
|
266
|
+
usage_input = chunk.usage.prompt_tokens or 0
|
|
267
|
+
usage_output = chunk.usage.completion_tokens or 0
|
|
268
|
+
|
|
269
|
+
if not chunk.choices:
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
choice = chunk.choices[0]
|
|
273
|
+
delta = choice.delta
|
|
274
|
+
|
|
275
|
+
if choice.finish_reason:
|
|
276
|
+
stop_reason = _STOP_REASON_MAP.get(choice.finish_reason, choice.finish_reason)
|
|
277
|
+
|
|
278
|
+
if delta.content:
|
|
279
|
+
yield StreamChunk(type="text_delta", text=delta.content)
|
|
280
|
+
|
|
281
|
+
if delta.tool_calls:
|
|
282
|
+
for tc_delta in delta.tool_calls:
|
|
283
|
+
idx = tc_delta.index
|
|
284
|
+
if idx not in partial_tool_calls:
|
|
285
|
+
partial_tool_calls[idx] = {
|
|
286
|
+
"id": tc_delta.id or "",
|
|
287
|
+
"name": (tc_delta.function.name if tc_delta.function else ""),
|
|
288
|
+
"arguments_parts": [],
|
|
289
|
+
"emitted_start": False,
|
|
290
|
+
}
|
|
291
|
+
else:
|
|
292
|
+
# Accumulate id/name as they arrive across deltas
|
|
293
|
+
if tc_delta.id:
|
|
294
|
+
partial_tool_calls[idx]["id"] = tc_delta.id
|
|
295
|
+
if tc_delta.function and tc_delta.function.name:
|
|
296
|
+
partial_tool_calls[idx]["name"] = tc_delta.function.name
|
|
297
|
+
|
|
298
|
+
if tc_delta.function and tc_delta.function.arguments:
|
|
299
|
+
partial_tool_calls[idx]["arguments_parts"].append(
|
|
300
|
+
tc_delta.function.arguments
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Defer tool_use_start until both id and name are known
|
|
304
|
+
tc_info = partial_tool_calls[idx]
|
|
305
|
+
if not tc_info["emitted_start"] and tc_info["id"] and tc_info["name"]:
|
|
306
|
+
yield StreamChunk(
|
|
307
|
+
type="tool_use_start",
|
|
308
|
+
tool_id=tc_info["id"],
|
|
309
|
+
tool_name=tc_info["name"],
|
|
310
|
+
tool_input={},
|
|
311
|
+
)
|
|
312
|
+
tc_info["emitted_start"] = True
|
|
313
|
+
|
|
314
|
+
except _openai.AuthenticationError as exc:
|
|
315
|
+
raise LLMAuthError(str(exc)) from exc
|
|
316
|
+
except _openai.RateLimitError as exc:
|
|
317
|
+
raise LLMRateLimitError(str(exc)) from exc
|
|
318
|
+
except _openai.APIConnectionError as exc:
|
|
319
|
+
raise LLMConnectionError(str(exc)) from exc
|
|
320
|
+
|
|
321
|
+
# Build tool_inputs_by_id from accumulated partial tool calls
|
|
322
|
+
tool_inputs_by_id: dict = {}
|
|
323
|
+
for tc in partial_tool_calls.values():
|
|
324
|
+
raw_args = "".join(tc["arguments_parts"]) or "{}"
|
|
325
|
+
try:
|
|
326
|
+
tool_inputs_by_id[tc["id"]] = json.loads(raw_args)
|
|
327
|
+
except json.JSONDecodeError:
|
|
328
|
+
logger.warning(
|
|
329
|
+
"Failed to parse tool arguments for tool '%s' (id=%s): %r",
|
|
330
|
+
tc["name"],
|
|
331
|
+
tc["id"],
|
|
332
|
+
raw_args,
|
|
333
|
+
)
|
|
334
|
+
tool_inputs_by_id[tc["id"]] = {}
|
|
335
|
+
# Emit tool_use_stop for each completed tool call
|
|
336
|
+
yield StreamChunk(type="tool_use_stop")
|
|
337
|
+
|
|
338
|
+
yield StreamChunk(
|
|
339
|
+
type="message_stop",
|
|
340
|
+
stop_reason=stop_reason,
|
|
341
|
+
input_tokens=usage_input,
|
|
342
|
+
output_tokens=usage_output,
|
|
343
|
+
tool_inputs_by_id=tool_inputs_by_id,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def stream(
|
|
347
|
+
self,
|
|
348
|
+
messages: list[dict],
|
|
349
|
+
purpose: Purpose = Purpose.EXECUTION,
|
|
350
|
+
max_tokens: int = 4096,
|
|
351
|
+
temperature: float = 0.0,
|
|
352
|
+
system: Optional[str] = None,
|
|
353
|
+
) -> Iterator[str]:
|
|
354
|
+
"""Stream a completion token by token.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
messages: Conversation messages
|
|
358
|
+
purpose: Purpose of call
|
|
359
|
+
max_tokens: Maximum tokens to generate
|
|
360
|
+
temperature: Sampling temperature
|
|
361
|
+
system: System prompt
|
|
362
|
+
|
|
363
|
+
Yields:
|
|
364
|
+
Text chunks as they are generated
|
|
365
|
+
"""
|
|
366
|
+
converted = self._convert_messages(messages)
|
|
367
|
+
|
|
368
|
+
if system:
|
|
369
|
+
converted = [{"role": "system", "content": system}] + converted
|
|
370
|
+
|
|
371
|
+
kwargs = {
|
|
372
|
+
"model": self.get_model(purpose),
|
|
373
|
+
"max_tokens": max_tokens,
|
|
374
|
+
"messages": converted,
|
|
375
|
+
"stream": True,
|
|
376
|
+
"temperature": temperature,
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
for chunk in self.client.chat.completions.create(**kwargs):
|
|
380
|
+
content = chunk.choices[0].delta.content
|
|
381
|
+
if content is not None:
|
|
382
|
+
yield content
|
|
383
|
+
|
|
384
|
+
def _convert_messages(self, messages: list[dict]) -> list[dict]:
|
|
385
|
+
"""Convert internal message format to OpenAI Chat Completions format.
|
|
386
|
+
|
|
387
|
+
OpenAI differences from internal format:
|
|
388
|
+
- Tool results must be separate messages with role='tool'
|
|
389
|
+
- Tool calls on assistant messages use a specific nested format
|
|
390
|
+
"""
|
|
391
|
+
converted = []
|
|
392
|
+
for msg in messages:
|
|
393
|
+
if msg.get("tool_results"):
|
|
394
|
+
# Each tool result becomes its own role='tool' message
|
|
395
|
+
for tr in msg["tool_results"]:
|
|
396
|
+
converted.append({
|
|
397
|
+
"role": "tool",
|
|
398
|
+
"tool_call_id": tr["tool_call_id"],
|
|
399
|
+
"content": tr["content"],
|
|
400
|
+
})
|
|
401
|
+
elif msg.get("tool_calls"):
|
|
402
|
+
# Assistant message with tool calls
|
|
403
|
+
converted.append({
|
|
404
|
+
"role": "assistant",
|
|
405
|
+
"content": msg.get("content", ""),
|
|
406
|
+
"tool_calls": [
|
|
407
|
+
{
|
|
408
|
+
"id": tc["id"],
|
|
409
|
+
"type": "function",
|
|
410
|
+
"function": {
|
|
411
|
+
"name": tc["name"],
|
|
412
|
+
"arguments": json.dumps(tc["input"]),
|
|
413
|
+
},
|
|
414
|
+
}
|
|
415
|
+
for tc in msg["tool_calls"]
|
|
416
|
+
],
|
|
417
|
+
})
|
|
418
|
+
else:
|
|
419
|
+
converted.append({"role": msg["role"], "content": msg["content"]})
|
|
420
|
+
return converted
|
|
421
|
+
|
|
422
|
+
def _convert_tools(self, tools: list[Tool]) -> list[dict]:
|
|
423
|
+
"""Convert Tool objects to OpenAI function-calling format."""
|
|
424
|
+
return [
|
|
425
|
+
{
|
|
426
|
+
"type": "function",
|
|
427
|
+
"function": {
|
|
428
|
+
"name": tool.name,
|
|
429
|
+
"description": tool.description,
|
|
430
|
+
"parameters": tool.input_schema,
|
|
431
|
+
},
|
|
432
|
+
}
|
|
433
|
+
for tool in tools
|
|
434
|
+
]
|
|
435
|
+
|
|
436
|
+
def _convert_raw_tools(self, tools: list[dict]) -> list[dict]:
|
|
437
|
+
"""Convert already-serialized tool dicts (Anthropic-style) to OpenAI format.
|
|
438
|
+
|
|
439
|
+
The ``async_stream()`` interface receives tools as ``list[dict]`` with an
|
|
440
|
+
``input_schema`` key (Anthropic API format). This helper converts them to
|
|
441
|
+
the OpenAI ``function`` calling format, mirroring :meth:`_convert_tools`
|
|
442
|
+
for raw dicts instead of :class:`Tool` objects.
|
|
443
|
+
"""
|
|
444
|
+
return [
|
|
445
|
+
{
|
|
446
|
+
"type": "function",
|
|
447
|
+
"function": {
|
|
448
|
+
"name": t["name"],
|
|
449
|
+
"description": t.get("description", ""),
|
|
450
|
+
"parameters": t.get("input_schema", {}),
|
|
451
|
+
},
|
|
452
|
+
}
|
|
453
|
+
for t in tools
|
|
454
|
+
]
|
|
455
|
+
|
|
456
|
+
def _parse_response(self, response) -> LLMResponse:
|
|
457
|
+
"""Parse OpenAI ChatCompletion into LLMResponse."""
|
|
458
|
+
choice = response.choices[0]
|
|
459
|
+
message = choice.message
|
|
460
|
+
|
|
461
|
+
content = message.content or ""
|
|
462
|
+
tool_calls = []
|
|
463
|
+
|
|
464
|
+
if message.tool_calls:
|
|
465
|
+
for tc in message.tool_calls:
|
|
466
|
+
tool_calls.append(
|
|
467
|
+
ToolCall(
|
|
468
|
+
id=tc.id,
|
|
469
|
+
name=tc.function.name,
|
|
470
|
+
input=json.loads(tc.function.arguments),
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
stop_reason = _STOP_REASON_MAP.get(choice.finish_reason, choice.finish_reason)
|
|
475
|
+
|
|
476
|
+
return LLMResponse(
|
|
477
|
+
content=content,
|
|
478
|
+
tool_calls=tool_calls,
|
|
479
|
+
stop_reason=stop_reason,
|
|
480
|
+
model=response.model,
|
|
481
|
+
input_tokens=response.usage.prompt_tokens,
|
|
482
|
+
output_tokens=response.usage.completion_tokens,
|
|
483
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Agent utilities for CodeFRAME.
|
|
2
|
+
|
|
3
|
+
The legacy multi-agent orchestration (LeadAgent/WorkerAgent/AgentFactory) was
|
|
4
|
+
removed during the v1 cleanup. Only the dependency resolver remains, used by the
|
|
5
|
+
live ``cf`` task-scheduling commands. Import it directly:
|
|
6
|
+
|
|
7
|
+
from codeframe.agents.dependency_resolver import ...
|
|
8
|
+
"""
|