diff --git a/.github/workflows/protocol-tests.yml b/.github/workflows/protocol-tests.yml new file mode 100644 index 0000000..57dbabd --- /dev/null +++ b/.github/workflows/protocol-tests.yml @@ -0,0 +1,29 @@ +name: Protocol regression tests + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + protocol-regressions: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: python -m pip install -e ".[dev]" + - name: Verify protocol adapters and safe tool pairing + run: | + python -m pytest -q \ + test_anthropic_tool_pairing.py \ + test_responses_tool_pairing.py \ + test_desensitize_claude_harness.py diff --git a/README.md b/README.md index 205b109..c4000cc 100644 --- a/README.md +++ b/README.md @@ -796,6 +796,24 @@ uv run --with workbuddy2api python -m codebuddy_proxy --desensitize --optimize-c --log-file "$HOME/.workbuddy2api/codebuddy-proxy.jsonl" ``` +### Upstream error 11148 "tool calls and tool results do not match" + +Some agent clients produce nonconforming Anthropic / Responses tool histories. +The CodeBuddy backend requires Chat tool results to directly follow the +declaring assistant `tool_calls`. The proxy now repairs recoverable converted +histories in `/v1/messages` and `/v1/responses`: interleaved non-tool messages +are deferred, and orphan or identical duplicate results are dropped with +diagnostic warnings (no tool output is logged). + +Ambiguous histories are **not silently truncated**. If a call has no result, a +tool-call ID is reused, or duplicate results disagree, the proxy returns HTTP +**422**, with `detail.code=invalid_tool_history` and a specific `reason`. +No request is sent upstream in that case. Verify the side effects of any +unfinished tools before retrying or starting a fresh conversation. + +This repair does not apply to direct `/v1/chat/completions` requests and cannot +guarantee the absence of unrelated upstream 11148 errors. + ### Port already in use ```bash diff --git a/README_zh.md b/README_zh.md index 350d83f..9965978 100644 --- a/README_zh.md +++ b/README_zh.md @@ -859,6 +859,22 @@ uv run --with workbuddy2api python -m codebuddy_proxy --desensitize --optimize-c --log-file "$HOME/.workbuddy2api/codebuddy-proxy.jsonl" ``` +### 上游错误 11148 "tool calls and tool results do not match" + +部分 Agent 客户端会生成不符合标准格式的 Anthropic / Responses 工具调用历史。 +CodeBuddy 后端要求 Chat `tool` 结果必须紧跟声明对应 `tool_calls` 的 assistant。 +代理在 `/v1/messages` 与 `/v1/responses` 转换后修复能够安全恢复的历史: +延后夹杂的非工具消息;对孤儿结果及内容相同的重复结果进行丢弃并记录诊断日志 +(不会记录工具输出正文)。 + +**不再静默删除无法配对的工具调用。** 如果调用缺少结果、工具调用 ID 重用、 +或重复结果内容冲突,代理返回 HTTP **422**,错误详情包含 +`detail.code=invalid_tool_history` 和对应的 `reason`,不会向上游发起请求。 +再次尝试或新建会话前,应先核查未完成工具是否已经产生副作用,避免重复执行。 + +此修复不适用于直接访问 `/v1/chat/completions` 的请求,也不能保证完全消除其他 +原因产生的上游 11148 错误。 + ### 端口被占用 ```bash diff --git a/src/codebuddy_proxy/__main__.py b/src/codebuddy_proxy/__main__.py index 20d9701..4a9e607 100755 --- a/src/codebuddy_proxy/__main__.py +++ b/src/codebuddy_proxy/__main__.py @@ -34,7 +34,8 @@ from typing import Any, Optional import httpx -from fastapi import FastAPI, Request, HTTPException +from fastapi import FastAPI, Request, HTTPException +from codebuddy_proxy.tool_pairing import ToolPairingError from fastapi.responses import JSONResponse, StreamingResponse import uvicorn from codebuddy_proxy.dsml_parser import DSMLStreamBuffer, parse_all_tool_calls, remove_all_tool_call_markers @@ -905,7 +906,12 @@ async def create_response(request: Request): log_client_request("POST", "/v1/responses", body) # 转换 Responses → Chat - chat_body = responses_request_to_chat(body) + try: + chat_body = responses_request_to_chat(body) + except ToolPairingError as exc: + diagnostic("tool_pairing_rejected", protocol="responses", reason=exc.reason, + call_count=len(exc.call_ids)) + raise HTTPException(status_code=422, detail=exc.as_detail()) from exc # 消息压缩优化(如果启用) if state.enable_optimize_context and HAS_PROJECTION: @@ -963,7 +969,12 @@ async def create_message(request: Request): log_client_request("POST", "/v1/messages", body) # 转换 Anthropic → Chat - chat_body = anthropic_to_chat(body) + try: + chat_body = anthropic_to_chat(body) + except ToolPairingError as exc: + diagnostic("tool_pairing_rejected", protocol="anthropic", reason=exc.reason, + call_count=len(exc.call_ids)) + raise HTTPException(status_code=422, detail=exc.as_detail()) from exc diagnostic("request", protocol="anthropic", **body_summary(chat_body)) return await forward_chat(chat_body, "anthropic", original=body) diff --git a/src/codebuddy_proxy/anthropic_adapter.py b/src/codebuddy_proxy/anthropic_adapter.py index 996d03f..469621b 100644 --- a/src/codebuddy_proxy/anthropic_adapter.py +++ b/src/codebuddy_proxy/anthropic_adapter.py @@ -12,6 +12,8 @@ import json import os import time +from codebuddy_proxy.tool_pairing import sanitize_tool_pairing as _sanitize_tool_pairing + from typing import Any @@ -169,6 +171,9 @@ def anthropic_request_to_chat(body: dict) -> dict: # messages转换 for msg in body.get("messages", []): messages.extend(_convert_anthropic_message(msg)) + + # 修复跨消息的工具调用配对,避免上游 11148 + messages = _sanitize_tool_pairing(messages, protocol="anthropic") # 构造Chat body chat: dict[str, Any] = { diff --git a/src/codebuddy_proxy/responses_adapter.py b/src/codebuddy_proxy/responses_adapter.py index df97b3d..62a60b4 100644 --- a/src/codebuddy_proxy/responses_adapter.py +++ b/src/codebuddy_proxy/responses_adapter.py @@ -13,6 +13,8 @@ import os import time import uuid +from codebuddy_proxy.tool_pairing import sanitize_tool_pairing as _sanitize_tool_pairing + from typing import Any, Iterator @@ -88,94 +90,6 @@ def _convert_tools_for_chat(tools: list[dict]) -> list[dict]: return result -def _sanitize_tool_pairing(messages: list[dict]) -> list[dict]: - """Keep each Chat tool result adjacent to its declaring assistant tool_calls. - - Responses histories can contain messages between outputs belonging to a - single assistant turn. Chat Completions requires the declared call IDs to - be answered together, so postpone intervening messages until that group - has finished. Never forward an orphan/duplicate result or an unanswered - tool call to the upstream service. - """ - out: list[dict] = [] - deferred: list[dict] = [] - pending: set[str] = set() - answered: set[str] = set() - assistant_index: int | None = None - - def _close_group() -> None: - nonlocal assistant_index - if assistant_index is not None: - assistant = out[assistant_index] - matched = [ - call for call in assistant["tool_calls"] - if call["id"] in answered - ] - if matched: - assistant["tool_calls"] = matched - else: - assistant.pop("tool_calls", None) - if not str(assistant.get("content") or "").strip(): - out.pop(assistant_index) - - assistant_index = None - pending.clear() - answered.clear() - out.extend(deferred) - deferred.clear() - - for message in messages: - if not isinstance(message, dict): - continue - - role = message.get("role") - calls = message.get("tool_calls") or [] - if role == "assistant" and calls: - _close_group() - # A duplicate/missing call ID would make matching ambiguous. - unique_calls: list[dict] = [] - seen_ids: set[str] = set() - for call in calls: - if not isinstance(call, dict): - continue - call_id = call.get("id") - if not isinstance(call_id, str) or not call_id or call_id in seen_ids: - continue - seen_ids.add(call_id) - unique_calls.append(call) - - if unique_calls: - out.append({**message, "tool_calls": unique_calls}) - assistant_index = len(out) - 1 - pending.update(seen_ids) - elif str(message.get("content") or "").strip(): - out.append({key: value for key, value in message.items() if key != "tool_calls"}) - continue - - if role == "tool": - call_id = message.get("tool_call_id") - if isinstance(call_id, str) and assistant_index is not None and call_id in pending: - out.append(message) - pending.remove(call_id) - answered.add(call_id) - if not pending: - _close_group() - # Missing, repeated, or unmatched results are not valid Chat messages. - continue - - # Blank assistant messages are also invalid/no-ops in this context. - if role == "assistant" and not str(message.get("content") or "").strip(): - continue - - if assistant_index is not None: - deferred.append(message) - else: - out.append(message) - - _close_group() - return out - - def _convert_input_items(items: list) -> list[dict]: """将Responses API的input数组转换为Chat messages @@ -273,7 +187,7 @@ def _flush_assistant(): # 刷新最后一条assistant消息,并修复跨消息的工具调用配对 _flush_assistant() - return _sanitize_tool_pairing(messages) + return _sanitize_tool_pairing(messages, protocol="responses") def responses_request_to_chat(body: dict) -> dict: diff --git a/src/codebuddy_proxy/tool_pairing.py b/src/codebuddy_proxy/tool_pairing.py new file mode 100644 index 0000000..7ee5d4f --- /dev/null +++ b/src/codebuddy_proxy/tool_pairing.py @@ -0,0 +1,136 @@ +"""Repair tool-call/result grouping after protocol adapters create Chat messages. + +The proxy may reorder interleaved *text* to satisfy Chat tool-call adjacency. +It must never silently erase an unanswered call: execution may already have +had side effects. For ambiguous histories fail closed before contacting upstream. +""" +from __future__ import annotations + +import hashlib +import logging +from typing import Any + +_LOG = logging.getLogger("codebuddy_proxy.tool_pairing") + + +class ToolPairingError(ValueError): + """An ambiguous tool history cannot be forwarded without losing meaning.""" + + def __init__(self, reason: str, call_ids: tuple[str, ...] = ()) -> None: + self.reason = reason + self.call_ids = call_ids + super().__init__(f"Invalid tool history ({reason}); calls={len(call_ids)}") + + def as_detail(self) -> dict[str, Any]: + return { + "code": "invalid_tool_history", + "reason": self.reason, + "tool_call_ids": list(self.call_ids), + "message": "Tool execution history is ambiguous; no upstream request was sent.", + } + + +def _result_digest(content: Any) -> bytes: + """Store a small comparison token rather than retaining tool output twice.""" + return hashlib.sha256(repr(content).encode("utf-8", "backslashreplace")).digest() + + +def sanitize_tool_pairing( + messages: list[dict], *, protocol: str = "unknown", +) -> list[dict]: + """Return a valid Chat tool history or raise ToolPairingError. + + Non-tool messages interleaved with an *open* group are deferred in their + original relative order. Orphan results and identical duplicates cannot + be paired and are dropped with a warning. Missing calls, ambiguous IDs or + contradictory duplicate results are errors rather than silent data loss. + """ + out: list[dict] = [] + deferred: list[dict] = [] + pending: set[str] = set() + seen_call_ids: set[str] = set() + completed_results: dict[str, bytes] = {} + active = False + + def warn(action: str, count: int = 1) -> None: + # Do not log tool outputs, arguments or conversation contents. + _LOG.warning( + "tool_pairing protocol=%s action=%s count=%d", protocol, action, count + ) + + def fail(reason: str, ids: set[str] | tuple[str, ...] = ()) -> None: + ordered_ids = tuple(sorted(ids)) + warn(reason, len(ordered_ids)) + raise ToolPairingError(reason, ordered_ids) + + def close_group() -> None: + nonlocal active + if pending: + fail("unanswered_tool_calls", pending) + active = False + if deferred: + _LOG.info( + "tool_pairing protocol=%s action=deferred_messages count=%d", + protocol, + len(deferred), + ) + out.extend(deferred) + deferred.clear() + + for message in messages: + if not isinstance(message, dict): + fail("invalid_message") + + role = message.get("role") + calls = message.get("tool_calls") if role == "assistant" else None + if calls: + close_group() + if not isinstance(calls, list): + fail("invalid_tool_calls") + call_ids: set[str] = set() + for call in calls: + if not isinstance(call, dict): + fail("invalid_tool_calls") + call_id = call.get("id") + if not isinstance(call_id, str) or not call_id: + fail("invalid_tool_call_id") + if call_id in call_ids or call_id in seen_call_ids: + fail("duplicate_tool_call_id", (call_id,)) + call_ids.add(call_id) + seen_call_ids.update(call_ids) + pending.update(call_ids) + active = True + out.append(message) + continue + + if role == "tool": + call_id = message.get("tool_call_id") + if not isinstance(call_id, str) or not call_id: + warn("invalid_result_id_dropped") + continue + digest = _result_digest(message.get("content")) + if active and call_id in pending: + out.append(message) + completed_results[call_id] = digest + pending.remove(call_id) + if not pending: + close_group() + elif call_id in completed_results: + if completed_results[call_id] != digest: + fail("conflicting_duplicate_result", (call_id,)) + warn("duplicate_result_dropped") + else: + warn("orphan_result_dropped") + continue + + # Empty assistant messages have no meaning in Chat histories. + if role == "assistant" and not str(message.get("content") or "").strip(): + continue + + if active: + deferred.append(message) + else: + out.append(message) + + close_group() + return out diff --git a/test_anthropic_tool_pairing.py b/test_anthropic_tool_pairing.py new file mode 100644 index 0000000..01b79b5 --- /dev/null +++ b/test_anthropic_tool_pairing.py @@ -0,0 +1,308 @@ +"""Regression tests for Anthropic to Chat tool-call/result pairing. + +The CodeBuddy backend rejects Chat histories whose tool results are not +adjacent to the assistant message declaring the call IDs with +``code 11148: tool calls and tool results do not match``. Legal Anthropic +histories routinely produce such shapes (tool_use blocks answered in later +turns, user text between a tool_use and its tool_result, duplicated +tool_result blocks), so conversion must repair them before forwarding. +""" + +import asyncio +import json +from types import SimpleNamespace + +import pytest +from fastapi.responses import JSONResponse +from httpx import ASGITransport, AsyncClient + +import codebuddy_proxy.__main__ as proxy +from codebuddy_proxy.tool_pairing import ToolPairingError +from codebuddy_proxy.anthropic_adapter import anthropic_request_to_chat + + +def _tool_use(call_id, name="Bash"): + return {"type": "tool_use", "id": call_id, "name": name, "input": {"cmd": "ls"}} + + +def _tool_result(call_id, output="ok"): + return {"type": "tool_result", "tool_use_id": call_id, "content": output} + + +def _assert_well_formed_tool_groups(messages): + """Every tool result belongs to the immediately preceding assistant call group.""" + expected = set() + for message in messages: + if message["role"] == "assistant": + assert not expected + calls = message.get("tool_calls") or [] + ids = [call["id"] for call in calls] + assert len(ids) == len(set(ids)) + expected = set(ids) + elif message["role"] == "tool": + assert message["tool_call_id"] in expected + expected.remove(message["tool_call_id"]) + else: + assert not expected + assert not expected + + +def _convert(messages): + """Convert an Anthropic history through the real request path.""" + return anthropic_request_to_chat({"model": "m", "messages": messages})["messages"] + + +def test_user_text_between_tool_use_and_result_is_deferred(): + """The D1 shape: text interleaved inside a tool call group.""" + messages = _convert([ + {"role": "user", "content": [{"type": "text", "text": "run it"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "sure"}, _tool_use("c1")]}, + {"role": "user", "content": [{"type": "text", "text": "ctx"}, _tool_result("c1")]}, + ]) + _assert_well_formed_tool_groups(messages) + assert [m["role"] for m in messages] == ["user", "assistant", "tool", "user"] + assert messages[2]["tool_call_id"] == "c1" + assert messages[3]["content"] == "ctx" + + chat = anthropic_request_to_chat({"model": "m", "messages": [ + {"role": "user", "content": [{"type": "text", "text": "run it"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "sure"}, _tool_use("c1")]}, + {"role": "user", "content": [{"type": "text", "text": "ctx"}, _tool_result("c1")]}, + ]}) + _assert_well_formed_tool_groups(chat["messages"]) + + +def test_parallel_tool_uses_answered_in_separate_turns(): + """Parallel calls whose results arrive in later turns stay adjacent.""" + messages = _convert([ + {"role": "assistant", "content": [_tool_use("c1"), _tool_use("c2", "Read")]}, + {"role": "user", "content": [_tool_result("c1", "r1")]}, + {"role": "user", "content": [_tool_result("c2", "r2")]}, + ]) + _assert_well_formed_tool_groups(messages) + assert [m["role"] for m in messages] == ["assistant", "tool", "tool"] + assert [m["tool_call_id"] for m in messages if m["role"] == "tool"] == ["c1", "c2"] + + +def test_duplicate_result_for_answered_call_is_dropped(): + """A repeated tool_result must not reach the upstream service.""" + messages = _convert([ + {"role": "assistant", "content": [_tool_use("c1")]}, + {"role": "user", "content": [_tool_result("c1", "r1")]}, + {"role": "assistant", "content": [_tool_use("c2", "Read")]}, + {"role": "user", "content": [_tool_result("c2", "r2"), _tool_result("c1", "r1")]}, + ]) + _assert_well_formed_tool_groups(messages) + assert [m["tool_call_id"] for m in messages if m["role"] == "tool"] == ["c1", "c2"] + + +def test_unanswered_tool_use_is_rejected_instead_of_silently_removed(): + """Missing output must not erase a tool invocation that may have side effects.""" + with pytest.raises(ToolPairingError, match="unanswered_tool_calls"): + _convert([ + {"role": "assistant", "content": [ + {"type": "text", "text": "I'll run it"}, _tool_use("c1"), + ]}, + {"role": "user", "content": [{"type": "text", "text": "continue"}]}, + ]) + + +def test_orphan_result_without_declaring_assistant_is_dropped(): + messages = _convert([ + {"role": "assistant", "content": [{"type": "text", "text": "plain"}]}, + {"role": "user", "content": [_tool_result("c9", "orphan")]}, + ]) + assert messages == [{"role": "assistant", "content": "plain"}] + + +def test_missing_tool_use_id_is_not_silently_rewritten(): + """An unknown tool call ID cannot be reconstructed from unrelated output.""" + with pytest.raises(ToolPairingError, match="unanswered_tool_calls"): + _convert([ + {"role": "assistant", "content": [ + {"type": "tool_use", "name": "Bash", "input": {}}, + ]}, + {"role": "user", "content": [_tool_result("call_generated", "r")]}, + ]) + + +def test_long_agentic_history_stays_well_formed(): + """59 raw messages with mixed text/results and a repeated identical result.""" + items = [{"role": "user", "content": [{"type": "text", "text": "start"}]}] + for idx in range(29): + items.append({ + "role": "assistant", + "content": [{"type": "text", "text": f"step {idx}"}, _tool_use(f"call_{idx}")], + }) + results = [_tool_result(f"call_{idx}", f"out {idx}")] + if idx == 14: + results.insert(0, {"type": "text", "text": "interleaved context"}) + if idx == 20: + results.append(_tool_result(f"call_{idx}", f"out {idx}")) + items.append({"role": "user", "content": results}) + + assert len(items) == 59 + messages = _convert(items) + _assert_well_formed_tool_groups(messages) + assert len(messages) == 60 # one deferred user text message + assert messages[0]["content"] == "start" + assert sum(m["role"] == "tool" for m in messages) == 29 + + chat = anthropic_request_to_chat({"model": "deepseek-v4.1-flash", "messages": items}) + assert chat["messages"] == messages + + +def test_interleaved_messages_are_deferred_until_group_completes(): + """Legal Anthropic history must not split a Chat tool result group.""" + messages = _convert([ + {"role": "user", "content": [{"type": "text", "text": "run two"}]}, + {"role": "assistant", "content": [_tool_use("a1"), _tool_use("a2", "Read")]}, + {"role": "user", "content": [_tool_result("a1", "out-1")]}, + {"role": "user", "content": [{"type": "text", "text": "ctx"}]}, + {"role": "user", "content": [_tool_result("a2", "out-2")]}, + ]) + _assert_well_formed_tool_groups(messages) + assert [m["role"] for m in messages] == [ + "user", "assistant", "tool", "tool", "user", + ] + assert [m["tool_call_id"] for m in messages if m["role"] == "tool"] == ["a1", "a2"] + assert messages[-1]["content"] == "ctx" + + +def test_deferred_messages_keep_their_relative_order(): + messages = _convert([ + {"role": "assistant", "content": [_tool_use("a"), _tool_use("b", "Read")]}, + {"role": "user", "content": [_tool_result("a")]}, + {"role": "user", "content": [{"type": "text", "text": "guidance"}]}, + {"role": "user", "content": [{"type": "text", "text": "later"}]}, + {"role": "assistant", "content": [{"type": "text", "text": "ack"}]}, + {"role": "user", "content": [_tool_result("b")]}, + ]) + _assert_well_formed_tool_groups(messages) + assert [m["role"] for m in messages] == [ + "assistant", "tool", "tool", "user", "user", "assistant", + ] + assert [m["content"] for m in messages[-3:]] == ["guidance", "later", "ack"] + + +def test_plain_conversation_is_untouched(): + body = { + "system": "be helpful", + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ], + } + chat = anthropic_request_to_chat(body) + assert chat["messages"] == [ + {"role": "system", "content": "be helpful"}, + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ] + + +def test_tool_call_arguments_survive_sanitization(): + """Repair must not drop or alter tool call arguments.""" + arguments = json.dumps({"cmd": "echo hi"}) + messages = _convert([ + {"role": "assistant", "content": [{ + "type": "tool_use", "id": "c1", "name": "Bash", "input": {"cmd": "echo hi"}, + }]}, + {"role": "user", "content": [_tool_result("c1", "hi")]}, + ]) + _assert_well_formed_tool_groups(messages) + assert messages[0]["tool_calls"][0]["function"]["arguments"] == arguments + + +def test_incomplete_parallel_group_is_rejected(): + with pytest.raises(ToolPairingError, match="unanswered_tool_calls") as error: + _convert([ + {"role": "assistant", "content": [_tool_use("a"), _tool_use("b")]}, + {"role": "user", "content": [_tool_result("a")]}, + {"role": "user", "content": [{"type": "text", "text": "next"}]}, + ]) + assert error.value.call_ids == ("b",) + + +def test_new_assistant_call_cannot_discard_previous_pending_call(): + with pytest.raises(ToolPairingError, match="unanswered_tool_calls"): + _convert([ + {"role": "assistant", "content": [_tool_use("a"), _tool_use("b")]}, + {"role": "user", "content": [_tool_result("a")]}, + {"role": "assistant", "content": [_tool_use("c")]}, + {"role": "user", "content": [_tool_result("b"), _tool_result("c")]}, + ]) + + +def test_conflicting_duplicate_result_is_not_silently_dropped(): + with pytest.raises(ToolPairingError, match="conflicting_duplicate_result"): + _convert([ + {"role": "assistant", "content": [_tool_use("a")]}, + {"role": "user", "content": [_tool_result("a", "success")]}, + {"role": "user", "content": [_tool_result("a", "failure")]}, + ]) + + +def test_duplicate_call_id_is_not_arbitrarily_deduplicated(): + with pytest.raises(ToolPairingError, match="duplicate_tool_call_id"): + _convert([ + {"role": "assistant", "content": [_tool_use("a"), _tool_use("a")]}, + {"role": "user", "content": [_tool_result("a")]}, + ]) + + +def test_orphan_result_is_dropped_with_diagnostic(caplog): + with caplog.at_level("WARNING", logger="codebuddy_proxy.tool_pairing"): + messages = _convert([ + {"role": "user", "content": [_tool_result("unknown", "payload")]}, + {"role": "user", "content": [{"type": "text", "text": "hello"}]}, + ]) + assert messages == [{"role": "user", "content": "hello"}] + assert "orphan_result_dropped" in caplog.text + assert "payload" not in caplog.text + + +@pytest.mark.parametrize("invalid", [False, True]) +def test_anthropic_http_route_validates_tool_history(monkeypatch, invalid): + state = SimpleNamespace( + enable_optimize_context=False, + verbose_llm=False, + write_log=lambda *args, **kwargs: None, + json_logger=None, + logger=None, + ) + monkeypatch.setattr(proxy, "proxy_state", state) + captured = [] + + async def fake_forward_chat(body, protocol, original=None): + captured.append(body) + assert protocol == "anthropic" + return JSONResponse({"ok": True}) + + monkeypatch.setattr(proxy, "forward_chat", fake_forward_chat) + history = [ + {"role": "user", "content": "run"}, + {"role": "assistant", "content": [_tool_use("a"), _tool_use("b")]}, + {"role": "user", "content": [_tool_result("a", "ok")]}, + ] + if not invalid: + history.append({"role": "user", "content": [_tool_result("b", "ok")]}) + + async def request(): + async with AsyncClient( + transport=ASGITransport(app=proxy.app), base_url="http://test", + ) as client: + return await client.post("/v1/messages", json={ + "model": "test", "messages": history, "stream": False, + }) + + response = asyncio.run(request()) + if invalid: + assert response.status_code == 422 + assert response.json()["detail"]["code"] == "invalid_tool_history" + assert response.json()["detail"]["tool_call_ids"] == ["b"] + assert not captured # bad history must never reach upstream + else: + assert response.status_code == 200 + assert len(captured) == 1 + _assert_well_formed_tool_groups(captured[0]["messages"]) diff --git a/test_responses_tool_pairing.py b/test_responses_tool_pairing.py index 9b0f4dc..29c6082 100644 --- a/test_responses_tool_pairing.py +++ b/test_responses_tool_pairing.py @@ -9,6 +9,7 @@ from httpx import ASGITransport, AsyncClient import codebuddy_proxy.__main__ as proxy +from codebuddy_proxy.tool_pairing import ToolPairingError from codebuddy_proxy.responses_adapter import ( ResponsesStreamConverter, _convert_input_items, responses_request_to_chat, ) @@ -95,32 +96,27 @@ def test_deferred_messages_keep_their_relative_order(): assert [m["content"] for m in messages[-3:]] == ["guidance", "later", "ack"] -def test_incomplete_group_keeps_only_answered_calls(): - messages = _convert_input_items([ - {"type": "message", "role": "user", "content": "go"}, - _call("a"), _call("b"), - _result("a"), - {"type": "message", "role": "user", "content": "next"}, - ]) - _assert_well_formed_tool_groups(messages) - assert [c["id"] for c in messages[1]["tool_calls"]] == ["a"] - assert messages[2]["tool_call_id"] == "a" - assert messages[3]["content"] == "next" +def test_incomplete_group_cannot_erase_unanswered_execution(): + with pytest.raises(ToolPairingError, match="unanswered_tool_calls") as error: + _convert_input_items([ + {"type": "message", "role": "user", "content": "go"}, + _call("a"), _call("b"), + _result("a"), + {"type": "message", "role": "user", "content": "next"}, + ]) + assert error.value.call_ids == ("b",) -def test_unanswered_group_removed_but_assistant_text_preserved(): - messages = _convert_input_items([ - {"type": "message", "role": "assistant", "content": "I'll run it"}, - _call("a"), - {"type": "message", "role": "user", "content": "continue"}, - ]) - assert messages == [ - {"role": "assistant", "content": "I'll run it"}, - {"role": "user", "content": "continue"}, - ] +def test_unanswered_group_fails_closed(): + with pytest.raises(ToolPairingError, match="unanswered_tool_calls"): + _convert_input_items([ + {"type": "message", "role": "assistant", "content": "I'll run it"}, + _call("a"), + {"type": "message", "role": "user", "content": "continue"}, + ]) - messages = _convert_input_items([_call("a")]) - assert messages == [] + with pytest.raises(ToolPairingError, match="unanswered_tool_calls"): + _convert_input_items([_call("a")]) def test_orphan_duplicate_missing_id_outputs_and_empty_assistants_are_dropped(): @@ -128,9 +124,8 @@ def test_orphan_duplicate_missing_id_outputs_and_empty_assistants_are_dropped(): _result("orphan"), {"type": "message", "role": "assistant", "content": ""}, _call("a"), - _call("a"), _result("a", "first"), - _result("a", "duplicate"), + _result("a", "first"), {"type": "function_call_output", "output": "missing id"}, {"type": "message", "role": "user", "content": "next"}, ]) @@ -267,3 +262,51 @@ async def send(): else: assert len(captured) < 4802 assert captured[-1]["content"] == "finish" + + +def test_responses_cross_group_pending_call_fails_closed(): + with pytest.raises(ToolPairingError, match="unanswered_tool_calls"): + _convert_input_items([ + _call("a"), _call("b"), _result("a"), + {"type": "message", "role": "assistant", "content": "new"}, + _call("c"), _result("b"), _result("c"), + ]) + + +def test_responses_conflicting_duplicate_result_fails_closed(): + with pytest.raises(ToolPairingError, match="conflicting_duplicate_result"): + _convert_input_items([ + _call("a"), _result("a", "success"), _result("a", "failure"), + ]) + + +def test_responses_http_route_rejects_incomplete_history(monkeypatch): + state = SimpleNamespace( + enable_optimize_context=False, + verbose_llm=False, + write_log=lambda *args, **kwargs: None, + json_logger=None, + logger=None, + ) + monkeypatch.setattr(proxy, "proxy_state", state) + forwarded = [] + + async def fake_forward_chat(body, protocol, original=None): + forwarded.append(body) + raise AssertionError("Invalid history must not be forwarded upstream") + + monkeypatch.setattr(proxy, "forward_chat", fake_forward_chat) + + async def send(): + transport = ASGITransport(app=proxy.app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + return await client.post("/v1/responses", json={ + "model": "test", + "input": [_call("a"), _call("b"), _result("a")], + }) + + response = asyncio.run(send()) + assert response.status_code == 422 + assert response.json()["detail"]["reason"] == "unanswered_tool_calls" + assert response.json()["detail"]["tool_call_ids"] == ["b"] + assert not forwarded