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 <noreply@anthropic.com>
This commit is contained in:
GitHub Actions
2026-04-22 10:56:21 +08:00
parent e3d3a63492
commit d081743924
6 changed files with 55 additions and 9 deletions

View File

@@ -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`.

View File

@@ -1,6 +1,6 @@
## Handoff: team-verify → complete ## 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`. - **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 protocol changes, no patch-point changes, and no extra cleanup beyond removing the stale Responses imports left behind by the extraction. - **Rejected**: No `app/main.py` behavior change, no Anthropic streaming fallback synthesis, and no extra refactor beyond test/schema/docs alignment.
- **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. - **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**: `app/main.py`, `app/http/openai_responses.py`, `.omc/handoffs/team-verify.md` - **Files**: `tests/test_tool_call_bridge.py`, `app/anthropic_schema.py`, `DESIGN.md`, `README.md`
- **Remaining**: Create the phase checkpoint commit, push it to Gitea, then close out the team session. - **Remaining**: Run git scope check, create the phase checkpoint commit, push to Gitea, and keep local `main` synced with `origin/main`.

View File

@@ -49,7 +49,7 @@
- **多租户 / 水平扩缩**:单容器即可;真要大规模部署 → 套层反代 + N 个网关副本就够,不在进程内解决。 - **多租户 / 水平扩缩**:单容器即可;真要大规模部署 → 套层反代 + N 个网关副本就够,不在进程内解决。
- **请求侧完整 function calling / tools 语义**:仍不是当前目标;现阶段仅支持 `tools`/`tool_choice``TOOL_FORWARD_ENABLED` 开关下灰度透传(默认关闭)。 - **请求侧完整 function calling / tools 语义**:仍不是当前目标;现阶段仅支持 `tools`/`tool_choice``TOOL_FORWARD_ENABLED` 开关下灰度透传(默认关闭)。
- **响应侧工具事件桥接**:若 Lingma 上游产出 tool 事件,网关会向 OpenAI 输出 `tool_calls`,向 Anthropic 输出 `tool_use` / `tool_result`stream + non-stream - **响应侧工具事件桥接**:若 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 仍保持原始文本流
--- ---

View File

@@ -5,6 +5,7 @@
- OpenAI`/v1/models``/v1/chat/completions`(含 stream - OpenAI`/v1/models``/v1/chat/completions`(含 stream
- Anthropic`/v1/messages``/v1/messages/count_tokens`(含 stream - Anthropic`/v1/messages``/v1/messages/count_tokens`(含 stream
- 内置多实例池、会话复用、Prometheus 指标、登录态 bundle 注入 - 内置多实例池、会话复用、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]` - 多模态降级OpenAI `image_url` / `input_image``[image]``input_audio``[audio]`Anthropic `image``[image]`
> 架构设计与二开细节请看 [`DESIGN.md`](./DESIGN.md)。 > 架构设计与二开细节请看 [`DESIGN.md`](./DESIGN.md)。
@@ -200,7 +201,7 @@ curl -s "http://127.0.0.1:${PORT}/healthz"
| `healthz` 正常但请求失败 | 用错端口 | 以 `.env``PORT` 为准,`docker compose ps` 再确认 | | `healthz` 正常但请求失败 | 用错端口 | 以 `.env``PORT` 为准,`docker compose ps` 再确认 |
| `git pull` 提示 not on a branch | 处于 detached HEAD | 执行 `git checkout -B main origin/main` | | `git pull` 提示 not on a branch | 处于 detached HEAD | 执行 `git checkout -B main origin/main` |
| 自动登录不稳定 | 浏览器流程波动 | 优先使用 `LINGMA_SESSION_BUNDLE(_FILE)` | | 自动登录不稳定 | 浏览器流程波动 | 优先使用 `LINGMA_SESSION_BUNDLE(_FILE)` |
| 工具调用未触发 | 模型未选择工具 | 使用 `tool_choice` 强制,必要时约束输出 JSON | | 工具调用未触发 | 模型未选择工具或当前协议路径不支持合成回退 | OpenAI 可配合 `tool_choice` 强制约束输出 JSONAnthropic 当前仅 non-stream 支持合成 `tool_use` / `tool_result` 回退 |
--- ---

View File

@@ -53,8 +53,10 @@ class AnthropicMessagesRequest(BaseModel):
# metadata.user_id is the official hint for per-user routing / abuse tracking. # metadata.user_id is the official hint for per-user routing / abuse tracking.
metadata: dict[str, Any] | None = None metadata: dict[str, Any] | None = None
# Tools / tool_choice are accepted for compatibility and, when forwarding is # Tools / tool_choice are accepted for compatibility and, when forwarding is
# enabled, are passed upstream as tool_config; tool_use / tool_result blocks # enabled, are passed upstream as tool_config. Response-side tool bridging is
# are still flattened into text so the assistant can see prior tool context. # 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 tools: list[dict[str, Any]] | None = None
tool_choice: dict[str, Any] | None = None tool_choice: dict[str, Any] | None = None

View File

@@ -662,6 +662,43 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase):
self.assertIn('"type": "tool_use"', body) self.assertIn('"type": "tool_use"', body)
self.assertIn('"stop_reason": "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: async def test_anthropic_stream_bridges_tool_and_text_events(self) -> None:
fake_client = _FakeClient( fake_client = _FakeClient(
stream_events=[ stream_events=[