From d0817439248ee63edceb82d625d13cf513364d6b Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 22 Apr 2026 10:56:21 +0800 Subject: [PATCH] test: freeze tool-call contract semantics Lock the current Anthropic streaming asymmetry so future refactors do not silently synthesize tool blocks. Align schema and docs with the actual support level to avoid over-promising forced-tool fallback. Co-Authored-By: Claude Opus 4.7 --- .omc/handoffs/team-plan.md | 6 ++++++ .omc/handoffs/team-verify.md | 10 ++++----- DESIGN.md | 2 +- README.md | 3 ++- app/anthropic_schema.py | 6 ++++-- tests/test_tool_call_bridge.py | 37 ++++++++++++++++++++++++++++++++++ 6 files changed, 55 insertions(+), 9 deletions(-) create mode 100644 .omc/handoffs/team-plan.md diff --git a/.omc/handoffs/team-plan.md b/.omc/handoffs/team-plan.md new file mode 100644 index 0000000..5020e64 --- /dev/null +++ b/.omc/handoffs/team-plan.md @@ -0,0 +1,6 @@ +## Handoff: team-plan → team-exec +- **Decided**: The next compatibility-first phase is contract freeze/alignment, not another runtime extraction: tighten tests around the actual tool-call support level, then align schema/docs wording to match. +- **Rejected**: No new `app/main.py` refactor in this slice, and no Anthropic streaming fallback implementation; that would turn the phase into a behavior change instead of a compatibility sync-up. +- **Risks**: Current docs can over-promise forced-tool fallback on Anthropic streaming; tests need to lock the current asymmetry explicitly so future refactors do not accidentally change it. +- **Files**: `tests/test_tool_call_bridge.py`, `app/anthropic_schema.py`, `DESIGN.md`, `README.md` +- **Remaining**: Add/adjust regression coverage, align wording in schema/docs, run focused + full unittest, then do the phase checkpoint commit/push while keeping local `main` synced with `origin/main`. diff --git a/.omc/handoffs/team-verify.md b/.omc/handoffs/team-verify.md index 55430a3..d0e20ee 100644 --- a/.omc/handoffs/team-verify.md +++ b/.omc/handoffs/team-verify.md @@ -1,6 +1,6 @@ ## Handoff: team-verify → complete -- **Decided**: Kept `app.main.v1_responses` as the compatibility route entry while moving the OpenAI Responses wrapper implementation into `app/http/openai_responses.py`. -- **Rejected**: No protocol changes, no patch-point changes, and no extra cleanup beyond removing the stale Responses imports left behind by the extraction. -- **Risks**: `app/http/openai_responses.py` now owns the Responses SSE bridge, so future protocol edits should be validated against the existing Responses regression coverage before touching it. -- **Files**: `app/main.py`, `app/http/openai_responses.py`, `.omc/handoffs/team-verify.md` -- **Remaining**: Create the phase checkpoint commit, push it to Gitea, then close out the team session. +- **Decided**: This phase freezes the current tool-call contract instead of expanding runtime behavior: Anthropic forced-tool fallback remains non-stream-only, and streaming keeps raw text when no upstream tool event exists. +- **Rejected**: No `app/main.py` behavior change, no Anthropic streaming fallback synthesis, and no extra refactor beyond test/schema/docs alignment. +- **Risks**: README / schema wording must stay aligned with runtime if tool-call support expands later; any future Anthropic streaming fallback work should update both docs and the new regression test together. +- **Files**: `tests/test_tool_call_bridge.py`, `app/anthropic_schema.py`, `DESIGN.md`, `README.md` +- **Remaining**: Run git scope check, create the phase checkpoint commit, push to Gitea, and keep local `main` synced with `origin/main`. diff --git a/DESIGN.md b/DESIGN.md index 873fe54..a600d0c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -49,7 +49,7 @@ - **多租户 / 水平扩缩**:单容器即可;真要大规模部署 → 套层反代 + N 个网关副本就够,不在进程内解决。 - **请求侧完整 function calling / tools 语义**:仍不是当前目标;现阶段仅支持 `tools`/`tool_choice` 在 `TOOL_FORWARD_ENABLED` 开关下灰度透传(默认关闭)。 - **响应侧工具事件桥接**:若 Lingma 上游产出 tool 事件,网关会向 OpenAI 输出 `tool_calls`,向 Anthropic 输出 `tool_use` / `tool_result`(stream + non-stream)。 -- **强制工具回退闭环(non-stream)**:当上游未返回 tool 事件且请求为强制 `tool_choice` 时,网关会从文本里解析严格 JSON,合成 OpenAI `tool_calls` 与 Anthropic `tool_use` / `tool_result`。 +- **强制工具回退闭环**:OpenAI 在 stream + non-stream 下都支持从文本里解析严格 JSON / `tool_code` 并合成 `tool_calls`;Anthropic 当前只在 non-stream 下合成 `tool_use` / `tool_result`,stream 仍保持原始文本流。 --- diff --git a/README.md b/README.md index bb3e43e..336bb89 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ - OpenAI:`/v1/models`、`/v1/chat/completions`(含 stream) - Anthropic:`/v1/messages`、`/v1/messages/count_tokens`(含 stream) - 内置:多实例池、会话复用、Prometheus 指标、登录态 bundle 注入 +- 工具事件桥接:Lingma 上游返回 `tool` 事件时,网关会输出为 OpenAI `tool_calls`(stream/non-stream)和 Anthropic `tool_use` / `tool_result`(stream/non-stream);请求侧 `tools` / `tool_choice` 仅在 `TOOL_FORWARD_ENABLED=true` 时透传(默认关闭) - 多模态降级:OpenAI `image_url` / `input_image` 转 `[image]`,`input_audio` 转 `[audio]`;Anthropic `image` 转 `[image]` > 架构设计与二开细节请看 [`DESIGN.md`](./DESIGN.md)。 @@ -200,7 +201,7 @@ curl -s "http://127.0.0.1:${PORT}/healthz" | `healthz` 正常但请求失败 | 用错端口 | 以 `.env` 的 `PORT` 为准,`docker compose ps` 再确认 | | `git pull` 提示 not on a branch | 处于 detached HEAD | 执行 `git checkout -B main origin/main` | | 自动登录不稳定 | 浏览器流程波动 | 优先使用 `LINGMA_SESSION_BUNDLE(_FILE)` | -| 工具调用未触发 | 模型未选择工具 | 使用 `tool_choice` 强制,必要时约束输出 JSON | +| 工具调用未触发 | 模型未选择工具或当前协议路径不支持合成回退 | OpenAI 可配合 `tool_choice` 强制并约束输出 JSON;Anthropic 当前仅 non-stream 支持合成 `tool_use` / `tool_result` 回退 | --- diff --git a/app/anthropic_schema.py b/app/anthropic_schema.py index b239081..21fdde6 100644 --- a/app/anthropic_schema.py +++ b/app/anthropic_schema.py @@ -53,8 +53,10 @@ class AnthropicMessagesRequest(BaseModel): # metadata.user_id is the official hint for per-user routing / abuse tracking. metadata: dict[str, Any] | None = None # Tools / tool_choice are accepted for compatibility and, when forwarding is - # enabled, are passed upstream as tool_config; tool_use / tool_result blocks - # are still flattened into text so the assistant can see prior tool context. + # enabled, are passed upstream as tool_config. Response-side tool bridging is + # the primary supported surface today; forced-tool synthesis is only covered + # for non-stream Anthropic responses. tool_use / tool_result blocks in prior + # messages are still flattened into text so the assistant can see that context. tools: list[dict[str, Any]] | None = None tool_choice: dict[str, Any] | None = None diff --git a/tests/test_tool_call_bridge.py b/tests/test_tool_call_bridge.py index e875776..6efdd34 100644 --- a/tests/test_tool_call_bridge.py +++ b/tests/test_tool_call_bridge.py @@ -662,6 +662,43 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase): self.assertIn('"type": "tool_use"', body) self.assertIn('"stop_reason": "tool_use"', body) + async def test_anthropic_stream_does_not_fallback_to_synthetic_tool_blocks_for_forced_tool(self) -> None: + fake_client = _FakeClient( + stream_events=[ + {"type": "text", "text": '```json\n{"input": {"k": "v"}, "result": {"value": 1}}\n```'} + ], + complete_result={}, + ) + req = AnthropicMessagesRequest( + model="claude-3-5-sonnet-20241022", + max_tokens=256, + messages=[{"role": "user", "content": "hi"}], + stream=True, + tools=[{"name": "lookup", "input_schema": {"type": "object", "properties": {}}}], + tool_choice={"type": "tool", "name": "lookup"}, + ) + + with ( + patch.object(main, "pool", _FakePool(_FakeInstance(fake_client))), + patch.object(main, "chat_guard", _FakeGuard()), + patch.object(main, "_ensure_instance_logged_in", AsyncMock(return_value={"id": "u"})), + patch.object(main.stats_collector, "record_chat", AsyncMock(return_value=None)), + patch.object(main.settings, "api_keys", ["test-key"]), + ): + response = await main.v1_messages( + req, + _make_request( + "/v1/messages", + headers={"x-api-key": "test-key", "anthropic-version": "2023-06-01"}, + ), + ) + body = await _collect_stream(response) + + self.assertNotIn('"type": "tool_use"', body) + self.assertNotIn('"type": "tool_result"', body) + self.assertIn('"type": "text_delta"', body) + self.assertIn('"stop_reason": "end_turn"', body) + async def test_anthropic_stream_bridges_tool_and_text_events(self) -> None: fake_client = _FakeClient( stream_events=[