crabcode 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.
- crabcode-0.1.0.dist-info/METADATA +23 -0
- crabcode-0.1.0.dist-info/RECORD +64 -0
- crabcode-0.1.0.dist-info/WHEEL +4 -0
- crabcode-0.1.0.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +3 -0
- crabcode_cli/app.py +142 -0
- crabcode_cli/banner.py +42 -0
- crabcode_cli/pipe.py +58 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +1263 -0
- crabcode_core/__init__.py +3 -0
- crabcode_core/api/__init__.py +6 -0
- crabcode_core/api/anthropic_adapter.py +250 -0
- crabcode_core/api/base.py +66 -0
- crabcode_core/api/codex_adapter.py +390 -0
- crabcode_core/api/openai_adapter.py +221 -0
- crabcode_core/api/registry.py +40 -0
- crabcode_core/compact/__init__.py +7 -0
- crabcode_core/compact/compact.py +150 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +53 -0
- crabcode_core/config/manager.py +152 -0
- crabcode_core/events.py +392 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +163 -0
- crabcode_core/mcp/config.py +52 -0
- crabcode_core/permissions/__init__.py +3 -0
- crabcode_core/permissions/manager.py +106 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/context.py +209 -0
- crabcode_core/prompts/profile.py +66 -0
- crabcode_core/prompts/system.py +405 -0
- crabcode_core/prompts/templates.py +60 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/loop.py +442 -0
- crabcode_core/session/__init__.py +3 -0
- crabcode_core/session/storage.py +153 -0
- crabcode_core/skills/__init__.py +5 -0
- crabcode_core/skills/loader.py +147 -0
- crabcode_core/tools/__init__.py +29 -0
- crabcode_core/tools/agent.py +160 -0
- crabcode_core/tools/bash.py +127 -0
- crabcode_core/tools/diff_utils.py +120 -0
- crabcode_core/tools/file_edit.py +145 -0
- crabcode_core/tools/file_read.py +123 -0
- crabcode_core/tools/file_write.py +102 -0
- crabcode_core/tools/glob.py +109 -0
- crabcode_core/tools/grep.py +130 -0
- crabcode_core/tools/lint.py +368 -0
- crabcode_core/tools/memory.py +236 -0
- crabcode_core/tools/skill.py +112 -0
- crabcode_core/types/__init__.py +73 -0
- crabcode_core/types/config.py +103 -0
- crabcode_core/types/event.py +105 -0
- crabcode_core/types/message.py +177 -0
- crabcode_core/types/tool.py +138 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +371 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +447 -0
- crabcode_search/indexer.py +212 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +232 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""OpenAI Responses (Codex) API adapter — uses the newer Responses API endpoint.
|
|
2
|
+
|
|
3
|
+
Supports OpenAI's Responses API which is used by Codex and o-series models.
|
|
4
|
+
Falls back to Chat Completions API for models that don't support the Responses API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any, AsyncGenerator
|
|
12
|
+
|
|
13
|
+
from crabcode_core.api.base import APIAdapter, ModelConfig, StreamChunk
|
|
14
|
+
from crabcode_core.types.config import ApiConfig
|
|
15
|
+
from crabcode_core.types.message import (
|
|
16
|
+
Message,
|
|
17
|
+
MessageRole,
|
|
18
|
+
TextBlock,
|
|
19
|
+
ToolResultBlock,
|
|
20
|
+
ToolUseBlock,
|
|
21
|
+
ThinkingBlock,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _messages_to_responses_input(
|
|
26
|
+
messages: list[Message],
|
|
27
|
+
) -> list[dict[str, Any]]:
|
|
28
|
+
"""Convert internal messages to OpenAI Responses API input format.
|
|
29
|
+
|
|
30
|
+
The Responses API uses a flat list of input items rather than
|
|
31
|
+
the Chat Completions 'messages' array. Each item has a 'type' field.
|
|
32
|
+
"""
|
|
33
|
+
result: list[dict[str, Any]] = []
|
|
34
|
+
|
|
35
|
+
for msg in messages:
|
|
36
|
+
if msg.role == MessageRole.SYSTEM:
|
|
37
|
+
continue
|
|
38
|
+
|
|
39
|
+
if isinstance(msg.content, str):
|
|
40
|
+
result.append({
|
|
41
|
+
"type": "message",
|
|
42
|
+
"role": msg.role.value,
|
|
43
|
+
"content": msg.content,
|
|
44
|
+
})
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
text_parts: list[str] = []
|
|
48
|
+
tool_calls: list[dict[str, Any]] = []
|
|
49
|
+
tool_results: list[dict[str, Any]] = []
|
|
50
|
+
|
|
51
|
+
for block in msg.content:
|
|
52
|
+
if isinstance(block, TextBlock):
|
|
53
|
+
text_parts.append(block.text)
|
|
54
|
+
elif isinstance(block, ToolUseBlock):
|
|
55
|
+
# Responses API uses 'function_call' type
|
|
56
|
+
tool_calls.append({
|
|
57
|
+
"type": "function_call",
|
|
58
|
+
"call_id": block.id,
|
|
59
|
+
"name": block.name,
|
|
60
|
+
"arguments": json.dumps(block.input),
|
|
61
|
+
})
|
|
62
|
+
elif isinstance(block, ToolResultBlock):
|
|
63
|
+
# Responses API uses 'function_call_output' type
|
|
64
|
+
tool_results.append({
|
|
65
|
+
"type": "function_call_output",
|
|
66
|
+
"call_id": block.tool_use_id,
|
|
67
|
+
"output": block.content,
|
|
68
|
+
})
|
|
69
|
+
elif isinstance(block, ThinkingBlock):
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
if msg.role == MessageRole.ASSISTANT:
|
|
73
|
+
# Add assistant message with text content
|
|
74
|
+
if text_parts:
|
|
75
|
+
result.append({
|
|
76
|
+
"type": "message",
|
|
77
|
+
"role": "assistant",
|
|
78
|
+
"content": "".join(text_parts),
|
|
79
|
+
})
|
|
80
|
+
# Add function_call items (they are separate top-level items)
|
|
81
|
+
for tc in tool_calls:
|
|
82
|
+
result.append(tc)
|
|
83
|
+
elif msg.role == MessageRole.USER:
|
|
84
|
+
# Add function_call_output items (they are separate top-level items)
|
|
85
|
+
for tr in tool_results:
|
|
86
|
+
result.append(tr)
|
|
87
|
+
# Add user message if there's text content
|
|
88
|
+
if text_parts and not tool_results:
|
|
89
|
+
result.append({
|
|
90
|
+
"type": "message",
|
|
91
|
+
"role": "user",
|
|
92
|
+
"content": "".join(text_parts),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _tools_to_responses(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
99
|
+
"""Convert tool schemas to OpenAI Responses API function tool format."""
|
|
100
|
+
result = []
|
|
101
|
+
for tool in tools:
|
|
102
|
+
schema = tool.get("input_schema", {"type": "object", "properties": {}})
|
|
103
|
+
result.append({
|
|
104
|
+
"type": "function",
|
|
105
|
+
"name": tool["name"],
|
|
106
|
+
"description": tool.get("description", ""),
|
|
107
|
+
"parameters": schema,
|
|
108
|
+
"strict": False,
|
|
109
|
+
})
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _response_to_stream_chunks(response: Any) -> list[StreamChunk]:
|
|
114
|
+
"""Convert a non-stream Responses API object into stream chunks.
|
|
115
|
+
|
|
116
|
+
Some OpenAI-compatible proxies support the Responses endpoint but return
|
|
117
|
+
SSE frames that the official SDK cannot parse. In that case we retry
|
|
118
|
+
without streaming and translate the final response back into CrabCode's
|
|
119
|
+
streaming abstraction.
|
|
120
|
+
"""
|
|
121
|
+
chunks: list[StreamChunk] = []
|
|
122
|
+
|
|
123
|
+
output_items = getattr(response, "output", None) or []
|
|
124
|
+
for item in output_items:
|
|
125
|
+
item_type = getattr(item, "type", "")
|
|
126
|
+
|
|
127
|
+
if item_type == "message":
|
|
128
|
+
for part in getattr(item, "content", None) or []:
|
|
129
|
+
part_type = getattr(part, "type", "")
|
|
130
|
+
if part_type == "output_text" and getattr(part, "text", ""):
|
|
131
|
+
chunks.append(StreamChunk(type="text", text=part.text))
|
|
132
|
+
|
|
133
|
+
elif item_type == "function_call":
|
|
134
|
+
call_id = getattr(item, "call_id", "") or getattr(item, "id", "")
|
|
135
|
+
name = getattr(item, "name", "")
|
|
136
|
+
arguments = getattr(item, "arguments", "") or ""
|
|
137
|
+
chunks.append(
|
|
138
|
+
StreamChunk(
|
|
139
|
+
type="tool_use_start",
|
|
140
|
+
tool_use_id=call_id,
|
|
141
|
+
tool_name=name,
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
chunks.append(
|
|
145
|
+
StreamChunk(
|
|
146
|
+
type="tool_use_end",
|
|
147
|
+
tool_use_id=call_id,
|
|
148
|
+
tool_name=name,
|
|
149
|
+
tool_input_json=arguments,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
usage = {}
|
|
154
|
+
if hasattr(response, "usage") and response.usage:
|
|
155
|
+
u = response.usage
|
|
156
|
+
usage = {
|
|
157
|
+
"input_tokens": getattr(u, "input_tokens", 0),
|
|
158
|
+
"output_tokens": getattr(u, "output_tokens", 0),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if not chunks and getattr(response, "error", None):
|
|
162
|
+
err = response.error
|
|
163
|
+
error_msg = getattr(err, "message", str(err))
|
|
164
|
+
chunks.append(StreamChunk(type="error", error=error_msg or "Response failed"))
|
|
165
|
+
return chunks
|
|
166
|
+
|
|
167
|
+
chunks.append(
|
|
168
|
+
StreamChunk(
|
|
169
|
+
type="message_stop",
|
|
170
|
+
stop_reason="end_turn",
|
|
171
|
+
usage=usage,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
return chunks
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class CodexAdapter(APIAdapter):
|
|
178
|
+
"""Adapter for OpenAI's Responses API (Codex / o-series models).
|
|
179
|
+
|
|
180
|
+
Uses client.responses.create() with stream=True.
|
|
181
|
+
"""
|
|
182
|
+
|
|
183
|
+
def __init__(self, config: ApiConfig):
|
|
184
|
+
import openai
|
|
185
|
+
|
|
186
|
+
self.config = config
|
|
187
|
+
api_key = None
|
|
188
|
+
if config.api_key_env:
|
|
189
|
+
api_key = os.environ.get(config.api_key_env)
|
|
190
|
+
if not api_key:
|
|
191
|
+
api_key = os.environ.get("OPENAI_API_KEY")
|
|
192
|
+
|
|
193
|
+
kwargs: dict[str, Any] = {}
|
|
194
|
+
if api_key:
|
|
195
|
+
kwargs["api_key"] = api_key
|
|
196
|
+
if config.base_url:
|
|
197
|
+
kwargs["base_url"] = config.base_url
|
|
198
|
+
|
|
199
|
+
self.client = openai.AsyncOpenAI(**kwargs)
|
|
200
|
+
|
|
201
|
+
async def stream_message(
|
|
202
|
+
self,
|
|
203
|
+
messages: list[Message],
|
|
204
|
+
system: list[str],
|
|
205
|
+
tools: list[dict[str, Any]],
|
|
206
|
+
config: ModelConfig,
|
|
207
|
+
) -> AsyncGenerator[StreamChunk, None]:
|
|
208
|
+
model = config.model or self.config.model or "codex-mini-latest"
|
|
209
|
+
|
|
210
|
+
# Responses API uses 'instructions' for system prompt
|
|
211
|
+
instructions = "\n\n".join(s for s in system if s) or None
|
|
212
|
+
|
|
213
|
+
params: dict[str, Any] = {
|
|
214
|
+
"model": model,
|
|
215
|
+
"input": _messages_to_responses_input(messages),
|
|
216
|
+
"stream": True,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if instructions:
|
|
220
|
+
params["instructions"] = instructions
|
|
221
|
+
|
|
222
|
+
if config.max_tokens:
|
|
223
|
+
params["max_output_tokens"] = config.max_tokens
|
|
224
|
+
|
|
225
|
+
if config.temperature is not None:
|
|
226
|
+
params["temperature"] = config.temperature
|
|
227
|
+
|
|
228
|
+
api_tools = _tools_to_responses(tools)
|
|
229
|
+
if api_tools:
|
|
230
|
+
params["tools"] = api_tools
|
|
231
|
+
|
|
232
|
+
# For o-series models, configure reasoning
|
|
233
|
+
if config.thinking_enabled:
|
|
234
|
+
# Map thinking_budget to reasoning effort
|
|
235
|
+
budget = config.thinking_budget
|
|
236
|
+
if budget >= 20000:
|
|
237
|
+
effort = "high"
|
|
238
|
+
elif budget >= 8000:
|
|
239
|
+
effort = "medium"
|
|
240
|
+
else:
|
|
241
|
+
effort = "low"
|
|
242
|
+
params["reasoning"] = {"effort": effort, "summary": "auto"}
|
|
243
|
+
|
|
244
|
+
# Track active function calls by item_id
|
|
245
|
+
active_calls: dict[str, dict[str, str]] = {}
|
|
246
|
+
emitted_stream_event = False
|
|
247
|
+
try:
|
|
248
|
+
stream = await self.client.responses.create(**params)
|
|
249
|
+
async for event in stream:
|
|
250
|
+
emitted_stream_event = True
|
|
251
|
+
event_type = getattr(event, "type", "")
|
|
252
|
+
|
|
253
|
+
# Text delta
|
|
254
|
+
if event_type == "response.output_text.delta":
|
|
255
|
+
yield StreamChunk(type="text", text=event.delta)
|
|
256
|
+
|
|
257
|
+
# Function call arguments delta
|
|
258
|
+
elif event_type == "response.function_call_arguments.delta":
|
|
259
|
+
item_id = event.item_id
|
|
260
|
+
if item_id not in active_calls:
|
|
261
|
+
active_calls[item_id] = {
|
|
262
|
+
"call_id": "",
|
|
263
|
+
"name": "",
|
|
264
|
+
"arguments": "",
|
|
265
|
+
}
|
|
266
|
+
buf = active_calls[item_id]
|
|
267
|
+
buf["arguments"] += event.delta
|
|
268
|
+
call_id = buf.get("call_id", "") or item_id
|
|
269
|
+
yield StreamChunk(
|
|
270
|
+
type="tool_use_delta",
|
|
271
|
+
tool_use_id=call_id,
|
|
272
|
+
tool_input_json=event.delta,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# Output item added — detect function_call start
|
|
276
|
+
elif event_type == "response.output_item.added":
|
|
277
|
+
item = event.item
|
|
278
|
+
item_type = getattr(item, "type", "")
|
|
279
|
+
if item_type == "function_call":
|
|
280
|
+
item_id = getattr(item, "id", "")
|
|
281
|
+
call_id = getattr(item, "call_id", "") or item_id
|
|
282
|
+
name = getattr(item, "name", "")
|
|
283
|
+
if item_id:
|
|
284
|
+
active_calls[item_id] = {
|
|
285
|
+
"call_id": call_id,
|
|
286
|
+
"name": name,
|
|
287
|
+
"arguments": "",
|
|
288
|
+
}
|
|
289
|
+
yield StreamChunk(
|
|
290
|
+
type="tool_use_start",
|
|
291
|
+
tool_use_id=call_id,
|
|
292
|
+
tool_name=name,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# Function call arguments done
|
|
296
|
+
elif event_type == "response.function_call_arguments.done":
|
|
297
|
+
item_id = event.item_id
|
|
298
|
+
buf = active_calls.get(item_id, {})
|
|
299
|
+
call_id = buf.get("call_id", item_id)
|
|
300
|
+
name = buf.get("name", "")
|
|
301
|
+
arguments = event.arguments or buf.get("arguments", "")
|
|
302
|
+
yield StreamChunk(
|
|
303
|
+
type="tool_use_end",
|
|
304
|
+
tool_use_id=call_id,
|
|
305
|
+
tool_name=name,
|
|
306
|
+
tool_input_json=arguments,
|
|
307
|
+
)
|
|
308
|
+
active_calls.pop(item_id, None)
|
|
309
|
+
|
|
310
|
+
# Output item done — also finalize function calls if not already done
|
|
311
|
+
elif event_type == "response.output_item.done":
|
|
312
|
+
item = event.item
|
|
313
|
+
item_type = getattr(item, "type", "")
|
|
314
|
+
if item_type == "function_call":
|
|
315
|
+
item_id = getattr(item, "id", "")
|
|
316
|
+
# Only yield if not already yielded via arguments.done
|
|
317
|
+
if item_id and item_id in active_calls:
|
|
318
|
+
buf = active_calls.pop(item_id)
|
|
319
|
+
call_id = buf.get("call_id", item_id)
|
|
320
|
+
yield StreamChunk(
|
|
321
|
+
type="tool_use_end",
|
|
322
|
+
tool_use_id=call_id,
|
|
323
|
+
tool_name=buf.get("name", ""),
|
|
324
|
+
tool_input_json=buf.get("arguments", ""),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# Reasoning summary text delta — treat as thinking
|
|
328
|
+
elif event_type == "response.reasoning_summary_text.delta":
|
|
329
|
+
yield StreamChunk(type="thinking", text=event.delta)
|
|
330
|
+
|
|
331
|
+
# Response completed
|
|
332
|
+
elif event_type == "response.completed":
|
|
333
|
+
usage = {}
|
|
334
|
+
response = event.response
|
|
335
|
+
if hasattr(response, "usage") and response.usage:
|
|
336
|
+
u = response.usage
|
|
337
|
+
usage = {
|
|
338
|
+
"input_tokens": getattr(u, "input_tokens", 0),
|
|
339
|
+
"output_tokens": getattr(u, "output_tokens", 0),
|
|
340
|
+
}
|
|
341
|
+
yield StreamChunk(
|
|
342
|
+
type="message_stop",
|
|
343
|
+
stop_reason="end_turn",
|
|
344
|
+
usage=usage,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
# Response failed or incomplete
|
|
348
|
+
elif event_type == "response.failed":
|
|
349
|
+
error_msg = ""
|
|
350
|
+
if hasattr(event, "response") and hasattr(event.response, "error"):
|
|
351
|
+
err = event.response.error
|
|
352
|
+
if err:
|
|
353
|
+
error_msg = getattr(err, "message", str(err))
|
|
354
|
+
yield StreamChunk(type="error", error=error_msg or "Response failed")
|
|
355
|
+
|
|
356
|
+
elif event_type == "response.incomplete":
|
|
357
|
+
yield StreamChunk(type="error", error="Response incomplete (max output tokens or content filter)")
|
|
358
|
+
|
|
359
|
+
# Error event
|
|
360
|
+
elif event_type == "response.error":
|
|
361
|
+
error_msg = ""
|
|
362
|
+
if hasattr(event, "error"):
|
|
363
|
+
err = event.error
|
|
364
|
+
error_msg = getattr(err, "message", str(err)) if err else ""
|
|
365
|
+
yield StreamChunk(type="error", error=error_msg or "Unknown error")
|
|
366
|
+
except json.JSONDecodeError:
|
|
367
|
+
if emitted_stream_event:
|
|
368
|
+
raise
|
|
369
|
+
|
|
370
|
+
fallback_params = dict(params)
|
|
371
|
+
fallback_params.pop("stream", None)
|
|
372
|
+
response = await self.client.responses.create(**fallback_params)
|
|
373
|
+
for chunk in _response_to_stream_chunks(response):
|
|
374
|
+
yield chunk
|
|
375
|
+
|
|
376
|
+
async def count_tokens(
|
|
377
|
+
self,
|
|
378
|
+
messages: list[Message],
|
|
379
|
+
system: list[str],
|
|
380
|
+
) -> int:
|
|
381
|
+
# No token counting API for Responses; use character estimate
|
|
382
|
+
total = sum(len(s) for s in system)
|
|
383
|
+
for msg in messages:
|
|
384
|
+
if isinstance(msg.content, str):
|
|
385
|
+
total += len(msg.content)
|
|
386
|
+
else:
|
|
387
|
+
for block in msg.content:
|
|
388
|
+
if isinstance(block, TextBlock):
|
|
389
|
+
total += len(block.text)
|
|
390
|
+
return total // 4
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""OpenAI API adapter — translates between internal Anthropic-style messages and OpenAI format."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any, AsyncGenerator
|
|
8
|
+
|
|
9
|
+
from crabcode_core.api.base import APIAdapter, ModelConfig, StreamChunk
|
|
10
|
+
from crabcode_core.types.config import ApiConfig
|
|
11
|
+
from crabcode_core.types.message import (
|
|
12
|
+
Message,
|
|
13
|
+
MessageRole,
|
|
14
|
+
TextBlock,
|
|
15
|
+
ToolResultBlock,
|
|
16
|
+
ToolUseBlock,
|
|
17
|
+
ThinkingBlock,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _messages_to_openai(
|
|
22
|
+
messages: list[Message],
|
|
23
|
+
system: list[str],
|
|
24
|
+
) -> list[dict[str, Any]]:
|
|
25
|
+
"""Convert internal messages + system to OpenAI chat format."""
|
|
26
|
+
result: list[dict[str, Any]] = []
|
|
27
|
+
|
|
28
|
+
system_text = "\n\n".join(s for s in system if s)
|
|
29
|
+
if system_text:
|
|
30
|
+
result.append({"role": "system", "content": system_text})
|
|
31
|
+
|
|
32
|
+
for msg in messages:
|
|
33
|
+
if msg.role == MessageRole.SYSTEM:
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
if isinstance(msg.content, str):
|
|
37
|
+
result.append({"role": msg.role.value, "content": msg.content})
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
text_parts: list[str] = []
|
|
41
|
+
tool_calls: list[dict[str, Any]] = []
|
|
42
|
+
tool_results: list[dict[str, Any]] = []
|
|
43
|
+
|
|
44
|
+
for block in msg.content:
|
|
45
|
+
if isinstance(block, TextBlock):
|
|
46
|
+
text_parts.append(block.text)
|
|
47
|
+
elif isinstance(block, ToolUseBlock):
|
|
48
|
+
tool_calls.append({
|
|
49
|
+
"id": block.id,
|
|
50
|
+
"type": "function",
|
|
51
|
+
"function": {
|
|
52
|
+
"name": block.name,
|
|
53
|
+
"arguments": json.dumps(block.input),
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
elif isinstance(block, ToolResultBlock):
|
|
57
|
+
tool_results.append({
|
|
58
|
+
"role": "tool",
|
|
59
|
+
"tool_call_id": block.tool_use_id,
|
|
60
|
+
"content": block.content,
|
|
61
|
+
})
|
|
62
|
+
elif isinstance(block, ThinkingBlock):
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
if msg.role == MessageRole.ASSISTANT:
|
|
66
|
+
entry: dict[str, Any] = {"role": "assistant"}
|
|
67
|
+
if text_parts:
|
|
68
|
+
entry["content"] = "".join(text_parts)
|
|
69
|
+
elif tool_calls:
|
|
70
|
+
# OpenAI expects explicit null when the turn is tool-only.
|
|
71
|
+
entry["content"] = None
|
|
72
|
+
if tool_calls:
|
|
73
|
+
entry["tool_calls"] = tool_calls
|
|
74
|
+
result.append(entry)
|
|
75
|
+
elif msg.role == MessageRole.USER:
|
|
76
|
+
if tool_results:
|
|
77
|
+
result.extend(tool_results)
|
|
78
|
+
elif text_parts:
|
|
79
|
+
result.append({"role": "user", "content": "".join(text_parts)})
|
|
80
|
+
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _tools_to_openai(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
85
|
+
"""Convert tool schemas to OpenAI function format."""
|
|
86
|
+
result = []
|
|
87
|
+
for tool in tools:
|
|
88
|
+
schema = tool.get("input_schema", {"type": "object", "properties": {}})
|
|
89
|
+
result.append({
|
|
90
|
+
"type": "function",
|
|
91
|
+
"function": {
|
|
92
|
+
"name": tool["name"],
|
|
93
|
+
"description": tool.get("description", ""),
|
|
94
|
+
"parameters": schema,
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class OpenAIAdapter(APIAdapter):
|
|
101
|
+
"""Adapter for OpenAI-compatible APIs."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, config: ApiConfig):
|
|
104
|
+
import openai
|
|
105
|
+
|
|
106
|
+
self.config = config
|
|
107
|
+
api_key = None
|
|
108
|
+
if config.api_key_env:
|
|
109
|
+
api_key = os.environ.get(config.api_key_env)
|
|
110
|
+
if not api_key:
|
|
111
|
+
api_key = os.environ.get("OPENAI_API_KEY")
|
|
112
|
+
|
|
113
|
+
kwargs: dict[str, Any] = {}
|
|
114
|
+
if api_key:
|
|
115
|
+
kwargs["api_key"] = api_key
|
|
116
|
+
if config.base_url:
|
|
117
|
+
kwargs["base_url"] = config.base_url
|
|
118
|
+
|
|
119
|
+
self.client = openai.AsyncOpenAI(**kwargs)
|
|
120
|
+
|
|
121
|
+
async def stream_message(
|
|
122
|
+
self,
|
|
123
|
+
messages: list[Message],
|
|
124
|
+
system: list[str],
|
|
125
|
+
tools: list[dict[str, Any]],
|
|
126
|
+
config: ModelConfig,
|
|
127
|
+
) -> AsyncGenerator[StreamChunk, None]:
|
|
128
|
+
model = config.model or self.config.model or "gpt-4o"
|
|
129
|
+
|
|
130
|
+
params: dict[str, Any] = {
|
|
131
|
+
"model": model,
|
|
132
|
+
"max_tokens": config.max_tokens,
|
|
133
|
+
"messages": _messages_to_openai(messages, system),
|
|
134
|
+
"stream": True,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
api_tools = _tools_to_openai(tools)
|
|
138
|
+
if api_tools:
|
|
139
|
+
params["tools"] = api_tools
|
|
140
|
+
|
|
141
|
+
if config.temperature is not None:
|
|
142
|
+
params["temperature"] = config.temperature
|
|
143
|
+
|
|
144
|
+
tool_call_buffers: dict[int, dict[str, str]] = {}
|
|
145
|
+
|
|
146
|
+
stream = await self.client.chat.completions.create(**params)
|
|
147
|
+
async for chunk in stream:
|
|
148
|
+
if not chunk.choices:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
delta = chunk.choices[0].delta
|
|
152
|
+
finish_reason = chunk.choices[0].finish_reason
|
|
153
|
+
|
|
154
|
+
if delta.content:
|
|
155
|
+
yield StreamChunk(type="text", text=delta.content)
|
|
156
|
+
|
|
157
|
+
if delta.tool_calls:
|
|
158
|
+
for tc in delta.tool_calls:
|
|
159
|
+
idx = tc.index
|
|
160
|
+
if idx not in tool_call_buffers:
|
|
161
|
+
tool_call_buffers[idx] = {
|
|
162
|
+
"id": tc.id or "",
|
|
163
|
+
"name": "",
|
|
164
|
+
"arguments": "",
|
|
165
|
+
}
|
|
166
|
+
buf = tool_call_buffers[idx]
|
|
167
|
+
if tc.id:
|
|
168
|
+
buf["id"] = tc.id
|
|
169
|
+
if tc.function:
|
|
170
|
+
if tc.function.name:
|
|
171
|
+
buf["name"] = tc.function.name
|
|
172
|
+
yield StreamChunk(
|
|
173
|
+
type="tool_use_start",
|
|
174
|
+
tool_use_id=buf["id"],
|
|
175
|
+
tool_name=buf["name"],
|
|
176
|
+
)
|
|
177
|
+
if tc.function.arguments:
|
|
178
|
+
buf["arguments"] += tc.function.arguments
|
|
179
|
+
yield StreamChunk(
|
|
180
|
+
type="tool_use_delta",
|
|
181
|
+
tool_use_id=buf["id"],
|
|
182
|
+
tool_input_json=tc.function.arguments,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if finish_reason == "tool_calls":
|
|
186
|
+
for buf in tool_call_buffers.values():
|
|
187
|
+
yield StreamChunk(
|
|
188
|
+
type="tool_use_end",
|
|
189
|
+
tool_use_id=buf["id"],
|
|
190
|
+
tool_name=buf["name"],
|
|
191
|
+
tool_input_json=buf["arguments"],
|
|
192
|
+
)
|
|
193
|
+
tool_call_buffers.clear()
|
|
194
|
+
|
|
195
|
+
if finish_reason == "stop":
|
|
196
|
+
usage = {}
|
|
197
|
+
if chunk.usage:
|
|
198
|
+
usage = {
|
|
199
|
+
"input_tokens": chunk.usage.prompt_tokens,
|
|
200
|
+
"output_tokens": chunk.usage.completion_tokens,
|
|
201
|
+
}
|
|
202
|
+
yield StreamChunk(
|
|
203
|
+
type="message_stop",
|
|
204
|
+
stop_reason="end_turn",
|
|
205
|
+
usage=usage,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
async def count_tokens(
|
|
209
|
+
self,
|
|
210
|
+
messages: list[Message],
|
|
211
|
+
system: list[str],
|
|
212
|
+
) -> int:
|
|
213
|
+
total = sum(len(s) for s in system)
|
|
214
|
+
for msg in messages:
|
|
215
|
+
if isinstance(msg.content, str):
|
|
216
|
+
total += len(msg.content)
|
|
217
|
+
else:
|
|
218
|
+
for block in msg.content:
|
|
219
|
+
if isinstance(block, TextBlock):
|
|
220
|
+
total += len(block.text)
|
|
221
|
+
return total // 4
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""API adapter factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from crabcode_core.api.base import APIAdapter
|
|
6
|
+
from crabcode_core.types.config import ApiConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_adapter(config: ApiConfig) -> APIAdapter:
|
|
10
|
+
"""Create an API adapter based on configuration."""
|
|
11
|
+
provider = config.provider or "anthropic"
|
|
12
|
+
|
|
13
|
+
if provider == "anthropic":
|
|
14
|
+
from crabcode_core.api.anthropic_adapter import AnthropicAdapter
|
|
15
|
+
return AnthropicAdapter(config)
|
|
16
|
+
elif provider == "bedrock":
|
|
17
|
+
from crabcode_core.api.anthropic_adapter import BedrockAdapter
|
|
18
|
+
return BedrockAdapter(config)
|
|
19
|
+
elif provider == "vertex":
|
|
20
|
+
from crabcode_core.api.anthropic_adapter import VertexAdapter
|
|
21
|
+
return VertexAdapter(config)
|
|
22
|
+
elif provider == "openai":
|
|
23
|
+
from crabcode_core.api.openai_adapter import OpenAIAdapter
|
|
24
|
+
return OpenAIAdapter(config)
|
|
25
|
+
elif provider == "codex":
|
|
26
|
+
from crabcode_core.api.codex_adapter import CodexAdapter
|
|
27
|
+
return CodexAdapter(config)
|
|
28
|
+
elif provider == "router":
|
|
29
|
+
fmt = config.format or "openai"
|
|
30
|
+
if fmt == "anthropic":
|
|
31
|
+
from crabcode_core.api.anthropic_adapter import AnthropicAdapter
|
|
32
|
+
return AnthropicAdapter(config)
|
|
33
|
+
elif fmt == "codex":
|
|
34
|
+
from crabcode_core.api.codex_adapter import CodexAdapter
|
|
35
|
+
return CodexAdapter(config)
|
|
36
|
+
else:
|
|
37
|
+
from crabcode_core.api.openai_adapter import OpenAIAdapter
|
|
38
|
+
return OpenAIAdapter(config)
|
|
39
|
+
else:
|
|
40
|
+
raise ValueError(f"Unknown API provider: {provider}")
|