From 332d1f8ba39350963f24a5e88b5f217b7af87196 Mon Sep 17 00:00:00 2001 From: fitzabombr Date: Sun, 26 Jul 2026 16:30:03 -0400 Subject: [PATCH] fix: support gpt-5 chat through SkillClaw proxy Translate legacy max_tokens to max_completion_tokens for gpt-5/o-series chat-completions requests while preserving legacy chat model behavior. Also keep Hermes proxy compatibility guards for unsupported reasoning_effort fields and oversized completion-token requests. Tests: uv run pytest tests/test_hermes_proxy_compat.py tests/test_responses_native.py -q --- skillclaw/api_server.py | 46 +++++++++++++ tests/test_hermes_proxy_compat.py | 104 ++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 tests/test_hermes_proxy_compat.py diff --git a/skillclaw/api_server.py b/skillclaw/api_server.py index 961e0fa..8d27854 100644 --- a/skillclaw/api_server.py +++ b/skillclaw/api_server.py @@ -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" @@ -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] = [] @@ -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: @@ -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 @@ -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] = {} diff --git a/tests/test_hermes_proxy_compat.py b/tests/test_hermes_proxy_compat.py new file mode 100644 index 0000000..f6dde8e --- /dev/null +++ b/tests/test_hermes_proxy_compat.py @@ -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