Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions skillclaw/api_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,13 @@
"session_done",
"turn_type",
"_skillclaw_protocol",
# Hermes may send this for reasoning-capable providers. SkillClaw exposes a
# generic OpenAI-compatible proxy alias, and non-reasoning upstream chat
# models such as gpt-4o reject this extension with HTTP 400. Strip it at
# the proxy boundary instead of forwarding it blindly.
"reasoning_effort",
}
_OPENAI_COMPAT_MAX_COMPLETION_TOKENS = 8192
_PROTOCOL_ANTHROPIC_MESSAGES = "anthropic_messages"
_PROTOCOL_RESPONSES_COMPAT = "responses_compat"

Expand All @@ -66,6 +72,43 @@ def _flatten_message_content(content) -> str:
return str(content) if content is not None else ""


def _cap_completion_token_fields(body: dict[str, Any]) -> None:
"""Clamp oversized completion-token requests before forwarding upstream.

Hermes can ask custom OpenAI-compatible routes for very large completions
(for example 65536 tokens). Several upstream chat-completions models reject
those requests immediately, and SkillClaw's retry loop makes that look like
a hung smoke test. Keep the proxy request within a conservative
OpenAI-compatible ceiling.
"""
for key in ("max_tokens", "max_completion_tokens"):
value = body.get(key)
if value is None:
continue
try:
parsed = int(value)
except (TypeError, ValueError):
continue
if parsed > _OPENAI_COMPAT_MAX_COMPLETION_TOKENS:
body[key] = _OPENAI_COMPAT_MAX_COMPLETION_TOKENS


def _openai_chat_requires_max_completion_tokens(model: str) -> bool:
"""Return True for OpenAI chat models that reject legacy max_tokens."""
model = str(model or "").lower()
return model.startswith(("gpt-5", "o1", "o3", "o4"))


def _normalize_openai_chat_token_fields(body: dict[str, Any]) -> None:
"""Translate legacy max_tokens for newer OpenAI chat-completions models."""
if not _openai_chat_requires_max_completion_tokens(str(body.get("model") or "")):
return
if "max_tokens" in body and "max_completion_tokens" not in body:
body["max_completion_tokens"] = body.pop("max_tokens")
else:
body.pop("max_tokens", None)


def _normalize_assistant_content_parts(content: list[dict]) -> tuple[str, list[dict]]:
"""Extract plain text and OpenAI-style tool_calls from assistant content parts."""
text_parts: list[str] = []
Expand Down Expand Up @@ -2396,6 +2439,7 @@ def _prompt_len(msgs):
messages = self._truncate_messages(messages, tools, max_prompt)

forward_body = {k: v for k, v in body.items() if k not in _NON_STANDARD_BODY_KEYS}
_cap_completion_token_fields(forward_body)
forward_body["stream"] = False
forward_body.pop("stream_options", None)
if "model" not in forward_body:
Expand Down Expand Up @@ -2579,6 +2623,7 @@ def _prepare_responses_forward(
)

send_body = {k: v for k, v in body.items() if k not in _NON_STANDARD_BODY_KEYS}
_cap_completion_token_fields(send_body)
send_body["model"] = self.config.llm_model_id or body.get("model", "")
send_body["stream"] = stream

Expand Down Expand Up @@ -2883,6 +2928,7 @@ async def _forward_to_llm_openai(self, body: dict[str, Any]) -> dict[str, Any]:
# Strip Tinker-specific fields not supported by standard OpenAI APIs
send_body = {k: v for k, v in body.items() if k not in {"logprobs", "top_logprobs", "stream_options"}}
send_body["model"] = self.config.llm_model_id or body.get("model", "")
_normalize_openai_chat_token_fields(send_body)
send_body["stream"] = False

headers: dict[str, str] = {}
Expand Down
104 changes: 104 additions & 0 deletions tests/test_hermes_proxy_compat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import pytest

from skillclaw.api_server import SkillClawAPIServer, _normalize_openai_chat_token_fields
from skillclaw.config import SkillClawConfig


@pytest.mark.asyncio
async def test_hermes_reasoning_effort_is_not_forwarded_upstream(tmp_path):
server = SkillClawAPIServer(
SkillClawConfig(
proxy_api_key="skillclaw",
record_enabled=False,
record_dir=str(tmp_path),
use_skills=False,
llm_model_id="gpt-4o",
llm_api_base="https://api.openai.com/v1",
)
)
seen = {}

async def fake_forward(body):
seen["body"] = dict(body)
return {
"id": "chatcmpl_test",
"object": "chat.completion",
"model": "gpt-4o",
"choices": [
{"index": 0, "message": {"role": "assistant", "content": "OK"}, "finish_reason": "stop"}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}

server._forward_to_llm = fake_forward
result = await server._handle_request(
{
"model": "skillclaw-model",
"messages": [{"role": "user", "content": "hi"}],
"reasoning_effort": "medium",
"session_id": "hermes-smoke",
"turn_type": "main",
},
session_id="hermes-smoke",
turn_type="main",
session_done=False,
)

assert result["response"]["choices"][0]["message"]["content"] == "OK"
assert "reasoning_effort" not in seen["body"]
assert "session_id" not in seen["body"]
assert "turn_type" not in seen["body"]


@pytest.mark.asyncio
async def test_excessive_hermes_max_tokens_is_capped_before_upstream(tmp_path):
server = SkillClawAPIServer(
SkillClawConfig(
proxy_api_key="skillclaw",
record_enabled=False,
record_dir=str(tmp_path),
use_skills=False,
llm_model_id="gpt-4o",
llm_api_base="https://api.openai.com/v1",
)
)
seen = {}

async def fake_forward(body):
seen["body"] = dict(body)
return {
"id": "chatcmpl_test",
"object": "chat.completion",
"model": "gpt-4o",
"choices": [
{"index": 0, "message": {"role": "assistant", "content": "OK"}, "finish_reason": "stop"}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}

server._forward_to_llm = fake_forward
await server._handle_request(
{
"model": "skillclaw-model",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 65536,
},
session_id="hermes-smoke",
turn_type="main",
session_done=False,
)

assert seen["body"]["max_tokens"] == 8192


def test_gpt5_chat_requests_use_max_completion_tokens():
body = {
"model": "gpt-5.5",
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 20,
}

_normalize_openai_chat_token_fields(body)

assert "max_tokens" not in body
assert body["max_completion_tokens"] == 20