From 3498b81fa2fbdee49731182d1ff882fc2313195b Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Sun, 19 Apr 2026 20:15:14 +0800 Subject: [PATCH] fix: enable anthropic agent mode for tooling requests Use agent ask_mode for Anthropic messages with tooling context so tool/write flows are executed, and add regression coverage plus docs/env updates for TOOL_FORWARD_ENABLED. Co-Authored-By: Claude Opus 4.7 --- .env.example | 3 +++ README.md | 2 +- app/main.py | 7 ++++--- tests/test_tool_call_bridge.py | 38 ++++++++++++++++++++++++++++++++++ 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 6882604..bf063c3 100644 --- a/.env.example +++ b/.env.example @@ -46,6 +46,9 @@ DEFAULT_MODEL=org_auto # 默认模式:chat 或 agent DEFAULT_ASK_MODE=chat +# 请求侧 tools/tool_choice 透传到 Lingma(默认关闭,开启后可支持工具写文件等场景) +TOOL_FORWARD_ENABLED=false + # 专属域(可选) DEDICATED_DOMAIN_URL= diff --git a/README.md b/README.md index 16c6f8d..e836e1c 100644 --- a/README.md +++ b/README.md @@ -222,7 +222,7 @@ curl -N http://127.0.0.1:8317/v1/messages \ - **模型名兼容**:客户端可以继续传 `claude-3-*` 等名字;未识别的 model 会回退到 `DEFAULT_MODEL` 对应的 Lingma key,后端实际仍由 Lingma 提供(Qwen 系列)。如需显式选模型,直接传 Lingma key(`dashscope_qmodel` 等)。 - **会话复用共享**:Anthropic 与 OpenAI 两个端点共用同一 `SessionCache`,只要 API key 相同、对话前缀相同,就会命中同一上游 `sessionId`。 - **多模态**:`image` 块会被降级为 `[image]` 占位符(Lingma 不支持 vision)。 -- **工具事件桥接**:当 Lingma 上游返回 `tool` 事件时,网关会输出为 OpenAI `tool_calls`(含 stream/non-stream)和 Anthropic `tool_use`/`tool_result` blocks(含 stream/non-stream);但请求侧 `tools`/`tool_choice` 仍不会透传到 Lingma。 +- **工具事件桥接**:当 Lingma 上游返回 `tool` 事件时,网关会输出为 OpenAI `tool_calls`(含 stream/non-stream)和 Anthropic `tool_use`/`tool_result` blocks(含 stream/non-stream);请求侧 `tools`/`tool_choice` 在 `TOOL_FORWARD_ENABLED=true` 时会透传到 Lingma(默认关闭)。 - **鉴权**:优先 `x-api-key` 头(Anthropic 官方 SDK 默认),回退 `Authorization: Bearer`(方便 curl / OpenAI 风格客户端)。 ### 3.2 观测(`METRICS_TOKEN` 或 `API_KEYS`) diff --git a/app/main.py b/app/main.py index 716256d..4721929 100644 --- a/app/main.py +++ b/app/main.py @@ -912,12 +912,13 @@ async def v1_messages(req: AnthropicMessagesRequest, request: Request): ) # ------------------------------------------------------------- session reuse - # Anthropic clients don't expose an ask_mode, so we always run in "chat". - ask_mode = "chat" - tool_config = _anthropic_tool_config(req) has_tooling_context = _anthropic_has_tooling_context(req) + ask_mode = settings.default_ask_mode + if req.model.lower() in {"lingma-agent", "agent"} or has_tooling_context: + ask_mode = "agent" + reuse_eligible = ( session_cache.enabled and ask_mode == "chat" and len(messages_dump) >= 2 and not has_tooling_context ) diff --git a/tests/test_tool_call_bridge.py b/tests/test_tool_call_bridge.py index 8667d13..3b02791 100644 --- a/tests/test_tool_call_bridge.py +++ b/tests/test_tool_call_bridge.py @@ -147,14 +147,18 @@ async def _collect_stream(response) -> str: class _SpyClient(_FakeClient): def __init__(self, *, stream_events: list[dict], complete_result: dict) -> None: super().__init__(stream_events=stream_events, complete_result=complete_result) + self.last_complete_args: tuple = () + self.last_stream_args: tuple = () self.last_complete_kwargs: dict = {} self.last_stream_kwargs: dict = {} async def chat_complete(self, *args, **kwargs) -> dict: + self.last_complete_args = tuple(args) self.last_complete_kwargs = dict(kwargs) return await super().chat_complete(*args, **kwargs) async def chat_stream(self, *args, **kwargs): + self.last_stream_args = tuple(args) self.last_stream_kwargs = dict(kwargs) async for event in super().chat_stream(*args, **kwargs): yield event @@ -551,6 +555,40 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(fake_cache.get_calls, []) self.assertEqual(fake_cache.put_calls, []) + + async def test_anthropic_non_stream_with_tools_uses_agent_mode(self) -> None: + spy_client = _SpyClient(stream_events=[], complete_result={"text": "ok", "toolEvents": []}) + req = AnthropicMessagesRequest( + model="claude-3-5-sonnet-20241022", + max_tokens=128, + messages=[{"role": "user", "content": "hi"}], + stream=False, + tools=[{"name": "write_file", "input_schema": {"type": "object", "properties": {}}}], + tool_choice={"type": "auto"}, + ) + + with ( + patch.object(main, "pool", _FakePool(_FakeInstance(spy_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"]), + _SettingsPatch(tool_forward_enabled=True, default_ask_mode="chat"), + ): + await main.v1_messages( + req, + _make_request( + "/v1/messages", + headers={"x-api-key": "test-key", "anthropic-version": "2023-06-01"}, + ), + ) + + self.assertIn("tool_config", spy_client.last_complete_kwargs) + cfg = spy_client.last_complete_kwargs["tool_config"] + self.assertEqual(cfg["provider"], "anthropic") + self.assertEqual(len(cfg["tools"]), 1) + self.assertEqual(spy_client.last_complete_args[2], "agent") + async def test_anthropic_tooling_context_disables_session_reuse_cache(self) -> None: fake_cache = _FakeSessionCache() fake_client = _FakeClient(