Skip to content

Commit 4a6b444

Browse files
StoneHanaMoriUpper9527
authored andcommitted
feat: improve codex and claude protocol compatibility
1 parent 908d058 commit 4a6b444

12 files changed

Lines changed: 1687 additions & 465 deletions

skillclaw/api_server.py

Lines changed: 123 additions & 465 deletions
Large diffs are not rendered by default.

skillclaw/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ class SkillClawConfig:
6262
llm_api_base: str = ""
6363
llm_api_key: str = ""
6464
llm_model_id: str = ""
65+
# Upstream API surface: "chat" keeps the legacy chat-completions bridge;
66+
# "responses" forwards Codex /v1/responses payloads to an upstream Responses API.
67+
llm_api_mode: str = "chat"
6568

6669
# ------------------------------------------------------------------ #
6770
# OpenRouter-specific (ignored for other providers) #

skillclaw/config_store.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ def to_skillclaw_config(self) -> SkillClawConfig:
251251
llm_api_base = llm.get("api_base", "")
252252
llm_api_key = llm.get("api_key", "")
253253
llm_model_id = llm.get("model_id", "")
254+
llm_api_mode = str(llm.get("api_mode", "chat") or "chat")
254255
proxy = data.get("proxy", {})
255256
skills = data.get("skills", {})
256257
orouter = data.get("openrouter", {})
@@ -288,6 +289,7 @@ def to_skillclaw_config(self) -> SkillClawConfig:
288289
llm_api_base=llm_api_base,
289290
llm_api_key=llm_api_key,
290291
llm_model_id=llm_model_id,
292+
llm_api_mode=llm_api_mode,
291293
bedrock_region=llm.get("bedrock_region") or data.get("bedrock_region", "us-east-1"),
292294
# OpenRouter
293295
openrouter_app_name=orouter.get("app_name", "SkillClaw"),

skillclaw/protocols/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Protocol adapters for SkillClaw API compatibility layers."""
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
"""Anthropic Messages compatibility for Claude Code style clients."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from typing import Any, AsyncIterator
7+
8+
from .common import json_dumps_tool_args, json_loads_tool_input
9+
10+
_STOP_REASON_MAP = {
11+
"stop": "end_turn",
12+
"length": "max_tokens",
13+
"tool_calls": "tool_use",
14+
"content_filter": "stop_sequence",
15+
}
16+
17+
18+
def _flatten_tool_result_content(content: Any) -> str:
19+
if isinstance(content, str):
20+
return content
21+
if isinstance(content, list):
22+
parts: list[str] = []
23+
for item in content:
24+
if isinstance(item, dict):
25+
if item.get("type") in {"text", "input_text", "output_text"}:
26+
text = item.get("text")
27+
if isinstance(text, str):
28+
parts.append(text)
29+
elif "content" in item:
30+
parts.append(_flatten_tool_result_content(item.get("content")))
31+
elif item is not None:
32+
parts.append(str(item))
33+
return " ".join(part for part in parts if part)
34+
return str(content) if content is not None else ""
35+
36+
37+
def _image_block_to_openai_part(block: dict[str, Any]) -> dict[str, Any] | None:
38+
source = block.get("source") if isinstance(block.get("source"), dict) else {}
39+
if source.get("type") == "base64":
40+
media_type = str(source.get("media_type") or "image/png")
41+
data = str(source.get("data") or "")
42+
if data:
43+
return {"type": "image_url", "image_url": {"url": f"data:{media_type};base64,{data}"}}
44+
url = source.get("url") or block.get("url") or block.get("image_url")
45+
if isinstance(url, str) and url:
46+
return {"type": "image_url", "image_url": {"url": url}}
47+
return None
48+
49+
50+
def _tools_to_openai_tools(tools: Any) -> list[dict[str, Any]]:
51+
converted: list[dict[str, Any]] = []
52+
if not isinstance(tools, list):
53+
return converted
54+
for item in tools:
55+
if not isinstance(item, dict):
56+
continue
57+
item_type = str(item.get("type") or "").strip()
58+
# Anthropic server tools are not client function tools; a chat upstream
59+
# cannot execute them unless they are handled by a native protocol path.
60+
if item_type.startswith("web_search") or item_type in {"server_tool_use", "web_search_tool_result"}:
61+
continue
62+
name = str(item.get("name") or "").strip()
63+
if not name:
64+
continue
65+
converted.append(
66+
{
67+
"type": "function",
68+
"function": {
69+
"name": name,
70+
"description": str(item.get("description") or ""),
71+
"parameters": item.get("input_schema") or {"type": "object", "properties": {}},
72+
},
73+
}
74+
)
75+
return converted
76+
77+
78+
def _tool_choice_to_openai(tool_choice: Any) -> Any:
79+
if isinstance(tool_choice, str):
80+
return "required" if tool_choice == "any" else tool_choice
81+
if not isinstance(tool_choice, dict):
82+
return tool_choice
83+
choice_type = tool_choice.get("type")
84+
if choice_type == "auto":
85+
return "auto"
86+
if choice_type == "any":
87+
return "required"
88+
if choice_type == "tool":
89+
name = str(tool_choice.get("name") or "").strip()
90+
if name:
91+
return {"type": "function", "function": {"name": name}}
92+
return tool_choice
93+
94+
95+
def to_openai_body(body: dict[str, Any]) -> dict[str, Any]:
96+
"""Convert an Anthropic /v1/messages request body to OpenAI chat format."""
97+
messages: list[dict[str, Any]] = list(body.get("messages", []))
98+
99+
system = body.get("system")
100+
if system:
101+
if isinstance(system, str):
102+
system_text = system
103+
elif isinstance(system, list):
104+
system_text = " ".join(
105+
blk.get("text", "") for blk in system if isinstance(blk, dict) and blk.get("type") == "text"
106+
)
107+
else:
108+
system_text = str(system)
109+
messages = [{"role": "system", "content": system_text}] + messages
110+
111+
normalized: list[dict[str, Any]] = []
112+
for msg in messages:
113+
role = msg.get("role")
114+
content = msg.get("content")
115+
if not isinstance(content, list):
116+
normalized.append(msg)
117+
continue
118+
119+
text_parts: list[str] = []
120+
content_parts: list[dict[str, Any]] = []
121+
tool_calls: list[dict[str, Any]] = []
122+
tool_results: list[dict[str, Any]] = []
123+
for idx, block in enumerate(content):
124+
if not isinstance(block, dict):
125+
continue
126+
block_type = block.get("type")
127+
if block_type == "text":
128+
text = block.get("text")
129+
if isinstance(text, str) and text:
130+
text_parts.append(text)
131+
content_parts.append({"type": "text", "text": text})
132+
elif block_type == "image":
133+
image_part = _image_block_to_openai_part(block)
134+
if image_part:
135+
content_parts.append(image_part)
136+
elif block_type == "tool_use":
137+
tool_calls.append(
138+
{
139+
"id": str(block.get("id") or f"toolu_{idx}"),
140+
"type": "function",
141+
"function": {
142+
"name": str(block.get("name") or "unknown_tool"),
143+
"arguments": json_dumps_tool_args(block.get("input")),
144+
},
145+
}
146+
)
147+
elif block_type == "tool_result":
148+
tool_results.append(
149+
{
150+
"role": "tool",
151+
"tool_call_id": str(block.get("tool_use_id") or ""),
152+
"content": _flatten_tool_result_content(block.get("content")),
153+
}
154+
)
155+
156+
text = " ".join(text_parts).strip()
157+
has_image = any(part.get("type") == "image_url" for part in content_parts)
158+
openai_content: str | list[dict[str, Any]] = content_parts if has_image else text
159+
if role == "assistant":
160+
assistant_msg = {**msg, "content": text}
161+
if tool_calls:
162+
assistant_msg["tool_calls"] = tool_calls
163+
normalized.append(assistant_msg)
164+
continue
165+
if tool_results:
166+
normalized.extend(tool_results)
167+
if text:
168+
normalized.append({**msg, "content": text})
169+
continue
170+
normalized.append({**msg, "content": openai_content})
171+
172+
openai_body: dict[str, Any] = {
173+
"model": body.get("model", ""),
174+
"messages": normalized,
175+
"max_tokens": body.get("max_tokens", 2048),
176+
}
177+
tools = _tools_to_openai_tools(body.get("tools"))
178+
if tools:
179+
openai_body["tools"] = tools
180+
if "tool_choice" in body:
181+
openai_body["tool_choice"] = _tool_choice_to_openai(body.get("tool_choice"))
182+
for opt in ("temperature", "top_p", "stop_sequences", "stream"):
183+
if opt in body:
184+
key = "stop" if opt == "stop_sequences" else opt
185+
openai_body[key] = body[opt]
186+
return openai_body
187+
188+
189+
def from_openai_response(openai_resp: dict[str, Any], model: str) -> dict[str, Any]:
190+
"""Convert an OpenAI chat completion response to Anthropic /v1/messages format."""
191+
choice = openai_resp.get("choices", [{}])[0]
192+
message = choice.get("message", {}) if isinstance(choice.get("message"), dict) else {}
193+
content_text = message.get("content") or ""
194+
raw_tool_calls = message.get("tool_calls")
195+
tool_calls = raw_tool_calls if isinstance(raw_tool_calls, list) else []
196+
finish_reason = choice.get("finish_reason", "stop")
197+
stop_reason = "tool_use" if tool_calls else _STOP_REASON_MAP.get(finish_reason, "end_turn")
198+
199+
content_blocks: list[dict[str, Any]] = []
200+
if content_text:
201+
content_blocks.append({"type": "text", "text": content_text})
202+
for idx, tool_call in enumerate(tool_calls):
203+
if not isinstance(tool_call, dict):
204+
continue
205+
function = tool_call.get("function") if isinstance(tool_call.get("function"), dict) else {}
206+
content_blocks.append(
207+
{
208+
"type": "tool_use",
209+
"id": str(tool_call.get("id") or f"call_{idx}"),
210+
"name": str(function.get("name") or "unknown_tool"),
211+
"input": json_loads_tool_input(function.get("arguments")),
212+
}
213+
)
214+
if not content_blocks:
215+
content_blocks.append({"type": "text", "text": ""})
216+
217+
usage = openai_resp.get("usage", {})
218+
return {
219+
"id": openai_resp.get("id", "msg_skillclaw"),
220+
"type": "message",
221+
"role": "assistant",
222+
"model": model,
223+
"content": content_blocks,
224+
"stop_reason": stop_reason,
225+
"stop_sequence": None,
226+
"usage": {
227+
"input_tokens": usage.get("prompt_tokens", 0),
228+
"output_tokens": usage.get("completion_tokens", 0),
229+
},
230+
}
231+
232+
233+
async def stream_from_openai_result(result: dict[str, Any], model: str) -> AsyncIterator[str]:
234+
"""Yield Anthropic-format SSE events from an internal OpenAI chat result."""
235+
payload = result["response"]
236+
choice = payload.get("choices", [{}])[0]
237+
anthropic_payload = from_openai_response(payload, model)
238+
content_blocks = anthropic_payload.get("content", [])
239+
stop_reason = anthropic_payload.get("stop_reason") or "end_turn"
240+
usage = payload.get("usage", {})
241+
msg_id = payload.get("id", "msg_skillclaw")
242+
243+
def sse(event: str, data: dict[str, Any]) -> str:
244+
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
245+
246+
yield sse(
247+
"message_start",
248+
{
249+
"type": "message_start",
250+
"message": {
251+
"id": msg_id,
252+
"type": "message",
253+
"role": "assistant",
254+
"content": [],
255+
"model": model,
256+
"stop_reason": None,
257+
"stop_sequence": None,
258+
"usage": {"input_tokens": usage.get("prompt_tokens", 0), "output_tokens": 0},
259+
},
260+
},
261+
)
262+
yield sse("ping", {"type": "ping"})
263+
264+
for index, block in enumerate(content_blocks):
265+
block_type = block.get("type") if isinstance(block, dict) else None
266+
if block_type == "tool_use":
267+
input_obj = block.get("input") if isinstance(block.get("input"), dict) else {}
268+
partial_json = json.dumps(input_obj, ensure_ascii=False, separators=(",", ":"))
269+
yield sse(
270+
"content_block_start",
271+
{
272+
"type": "content_block_start",
273+
"index": index,
274+
"content_block": {
275+
"type": "tool_use",
276+
"id": block.get("id", f"call_{index}"),
277+
"name": block.get("name", "unknown_tool"),
278+
"input": {},
279+
},
280+
},
281+
)
282+
if partial_json and partial_json != "{}":
283+
yield sse(
284+
"content_block_delta",
285+
{
286+
"type": "content_block_delta",
287+
"index": index,
288+
"delta": {"type": "input_json_delta", "partial_json": partial_json},
289+
},
290+
)
291+
yield sse("content_block_stop", {"type": "content_block_stop", "index": index})
292+
continue
293+
294+
text = str(block.get("text") or "") if isinstance(block, dict) else ""
295+
yield sse(
296+
"content_block_start",
297+
{
298+
"type": "content_block_start",
299+
"index": index,
300+
"content_block": {"type": "text", "text": ""},
301+
},
302+
)
303+
if text:
304+
yield sse(
305+
"content_block_delta",
306+
{
307+
"type": "content_block_delta",
308+
"index": index,
309+
"delta": {"type": "text_delta", "text": text},
310+
},
311+
)
312+
yield sse("content_block_stop", {"type": "content_block_stop", "index": index})
313+
314+
yield sse(
315+
"message_delta",
316+
{
317+
"type": "message_delta",
318+
"delta": {"stop_reason": stop_reason, "stop_sequence": None},
319+
"usage": {"output_tokens": usage.get("completion_tokens", 0)},
320+
},
321+
)
322+
yield sse("message_stop", {"type": "message_stop"})

skillclaw/protocols/common.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Shared helpers for protocol adapters."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from typing import Any
7+
8+
9+
def json_dumps_tool_args(value: Any) -> str:
10+
"""Return a JSON object string suitable for OpenAI function arguments."""
11+
if isinstance(value, str):
12+
stripped = value.strip()
13+
return stripped or "{}"
14+
try:
15+
return json.dumps(value if value is not None else {}, ensure_ascii=False)
16+
except Exception:
17+
return "{}"
18+
19+
20+
def json_loads_tool_input(value: Any) -> dict[str, Any]:
21+
"""Parse OpenAI function arguments into a tool input object."""
22+
if isinstance(value, str):
23+
try:
24+
parsed = json.loads(value or "{}")
25+
except Exception:
26+
return {}
27+
return parsed if isinstance(parsed, dict) else {}
28+
return value if isinstance(value, dict) else {}

0 commit comments

Comments
 (0)