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:
6
.omc/handoffs/team-plan.md
Normal file
6
.omc/handoffs/team-plan.md
Normal 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`.
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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 仍保持原始文本流。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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` 强制并约束输出 JSON;Anthropic 当前仅 non-stream 支持合成 `tool_use` / `tool_result` 回退 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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=[
|
||||||
|
|||||||
Reference in New Issue
Block a user