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.
Files changed (64) hide show
  1. crabcode-0.1.0.dist-info/METADATA +23 -0
  2. crabcode-0.1.0.dist-info/RECORD +64 -0
  3. crabcode-0.1.0.dist-info/WHEEL +4 -0
  4. crabcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +3 -0
  6. crabcode_cli/app.py +142 -0
  7. crabcode_cli/banner.py +42 -0
  8. crabcode_cli/pipe.py +58 -0
  9. crabcode_cli/render/__init__.py +0 -0
  10. crabcode_cli/render/messages.py +19 -0
  11. crabcode_cli/repl.py +1263 -0
  12. crabcode_core/__init__.py +3 -0
  13. crabcode_core/api/__init__.py +6 -0
  14. crabcode_core/api/anthropic_adapter.py +250 -0
  15. crabcode_core/api/base.py +66 -0
  16. crabcode_core/api/codex_adapter.py +390 -0
  17. crabcode_core/api/openai_adapter.py +221 -0
  18. crabcode_core/api/registry.py +40 -0
  19. crabcode_core/compact/__init__.py +7 -0
  20. crabcode_core/compact/compact.py +150 -0
  21. crabcode_core/config/__init__.py +3 -0
  22. crabcode_core/config/claudemd.py +53 -0
  23. crabcode_core/config/manager.py +152 -0
  24. crabcode_core/events.py +392 -0
  25. crabcode_core/mcp/__init__.py +4 -0
  26. crabcode_core/mcp/client.py +163 -0
  27. crabcode_core/mcp/config.py +52 -0
  28. crabcode_core/permissions/__init__.py +3 -0
  29. crabcode_core/permissions/manager.py +106 -0
  30. crabcode_core/prompts/__init__.py +0 -0
  31. crabcode_core/prompts/context.py +209 -0
  32. crabcode_core/prompts/profile.py +66 -0
  33. crabcode_core/prompts/system.py +405 -0
  34. crabcode_core/prompts/templates.py +60 -0
  35. crabcode_core/query/__init__.py +0 -0
  36. crabcode_core/query/loop.py +442 -0
  37. crabcode_core/session/__init__.py +3 -0
  38. crabcode_core/session/storage.py +153 -0
  39. crabcode_core/skills/__init__.py +5 -0
  40. crabcode_core/skills/loader.py +147 -0
  41. crabcode_core/tools/__init__.py +29 -0
  42. crabcode_core/tools/agent.py +160 -0
  43. crabcode_core/tools/bash.py +127 -0
  44. crabcode_core/tools/diff_utils.py +120 -0
  45. crabcode_core/tools/file_edit.py +145 -0
  46. crabcode_core/tools/file_read.py +123 -0
  47. crabcode_core/tools/file_write.py +102 -0
  48. crabcode_core/tools/glob.py +109 -0
  49. crabcode_core/tools/grep.py +130 -0
  50. crabcode_core/tools/lint.py +368 -0
  51. crabcode_core/tools/memory.py +236 -0
  52. crabcode_core/tools/skill.py +112 -0
  53. crabcode_core/types/__init__.py +73 -0
  54. crabcode_core/types/config.py +103 -0
  55. crabcode_core/types/event.py +105 -0
  56. crabcode_core/types/message.py +177 -0
  57. crabcode_core/types/tool.py +138 -0
  58. crabcode_search/__init__.py +11 -0
  59. crabcode_search/background.py +371 -0
  60. crabcode_search/chunker.py +517 -0
  61. crabcode_search/embedder.py +447 -0
  62. crabcode_search/indexer.py +212 -0
  63. crabcode_search/store.py +222 -0
  64. 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}")
@@ -0,0 +1,7 @@
1
+ from crabcode_core.compact.compact import (
2
+ compact_conversation,
3
+ estimate_token_count,
4
+ should_auto_compact,
5
+ )
6
+
7
+ __all__ = ["compact_conversation", "estimate_token_count", "should_auto_compact"]