feat: add emulated tool-calling bridge for Lingma
Add a proxy-side tool emulation layer so Lingma requests can surface stable OpenAI tool_calls and Anthropic tool_use blocks even when upstream tool events are missing or inconsistent. Constraint: Keep native Lingma tool event bridging as the first path and layer emulation as a fallback Rejected: Depend exclusively on Lingma native tool/invoke events | tool visibility remains inconsistent across models and transports Confidence: high Scope-risk: moderate
This commit is contained in:
@@ -42,6 +42,7 @@
|
||||
1. 定点执行新增测试文件。
|
||||
2. 全量执行 `tests/` 下 `test_*.py`。
|
||||
3. 汇总通过率与失败项(若失败,给出定位与修复建议)。
|
||||
4. Docker 运行态执行 `bash scripts/smoke_tool_calls.sh`,验证 OpenAI / Anthropic 的 stream / non-stream 工具调用。
|
||||
|
||||
## 6. 执行命令
|
||||
```bash
|
||||
@@ -50,4 +51,5 @@ python3 -m unittest tests/test_session_cache_tooling.py
|
||||
python3 -m unittest tests/test_schema_normalization.py
|
||||
python3 -m unittest tests/test_tool_call_bridge.py
|
||||
python3 -m unittest discover -s tests -p "test_*.py"
|
||||
bash scripts/smoke_tool_calls.sh
|
||||
```
|
||||
|
||||
@@ -3,10 +3,12 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import types
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
import zipfile
|
||||
|
||||
# app.lingma_pool imports auto_login; tests here don't execute Playwright paths.
|
||||
# Stub module import so test environments without playwright can import pool code.
|
||||
@@ -28,6 +30,7 @@ sys.modules.setdefault("playwright", _playwright)
|
||||
sys.modules.setdefault("playwright.async_api", _playwright_async)
|
||||
|
||||
from app.config import _parse_accounts, load_settings
|
||||
from app.bootstrap_lingma import bootstrap_from_vsix
|
||||
from app.lingma_pool import LingmaPool
|
||||
from app.stats import StatsCollector, estimate_tokens
|
||||
|
||||
@@ -212,5 +215,57 @@ class ConfigParsingTests(unittest.TestCase):
|
||||
self.assertEqual(settings.tool_allowlist, [])
|
||||
|
||||
|
||||
class BootstrapLingmaTests(unittest.TestCase):
|
||||
def _make_test_vsix(self, root: str) -> str:
|
||||
nested_zip_path = os.path.join(root, "nested.zip")
|
||||
with zipfile.ZipFile(nested_zip_path, "w") as nested:
|
||||
nested.writestr("2.5.20/x86_64_linux/Lingma", b"new-binary")
|
||||
nested.writestr("2.5.20/extension/main.js", b"console.log('ok')")
|
||||
|
||||
vsix_path = os.path.join(root, "test.vsix")
|
||||
with zipfile.ZipFile(vsix_path, "w") as vsix:
|
||||
with open(nested_zip_path, "rb") as nested_file:
|
||||
vsix.writestr(
|
||||
"extension/dist/bin/lingma-2.5.20.zip",
|
||||
nested_file.read(),
|
||||
)
|
||||
return vsix_path
|
||||
|
||||
def test_bootstrap_refreshes_when_extension_assets_missing(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
bin_dir = os.path.join(tmpdir, "data", "bin")
|
||||
release_dir = os.path.join(bin_dir, "2.5.20")
|
||||
os.makedirs(release_dir, exist_ok=True)
|
||||
|
||||
lingma_bin = os.path.join(bin_dir, "Lingma")
|
||||
with open(lingma_bin, "wb") as f:
|
||||
f.write(b"old-binary")
|
||||
|
||||
marker = {
|
||||
"version": "2.5.20",
|
||||
"release_root": "2.5.20",
|
||||
}
|
||||
with open(os.path.join(bin_dir, ".lingma-bootstrap.json"), "w", encoding="utf-8") as f:
|
||||
json.dump(marker, f)
|
||||
|
||||
vsix_path = self._make_test_vsix(tmpdir)
|
||||
|
||||
env = {
|
||||
"LINGMA_BIN": lingma_bin,
|
||||
"LINGMA_SOURCE_TYPE": "vsix",
|
||||
"LINGMA_VSIX_URL": f"file://{vsix_path}",
|
||||
"LINGMA_BOOTSTRAP_ALWAYS": "false",
|
||||
"LINGMA_FORCE_REFRESH": "false",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
bootstrap_from_vsix()
|
||||
|
||||
with open(lingma_bin, "rb") as f:
|
||||
self.assertEqual(f.read(), b"new-binary")
|
||||
self.assertTrue(
|
||||
os.path.exists(os.path.join(release_dir, "extension", "main.js"))
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -388,6 +388,169 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase):
|
||||
{"query": "gateway"},
|
||||
)
|
||||
|
||||
async def test_openai_non_stream_synthesizes_tool_call_from_hash_tool_call_block(
|
||||
self,
|
||||
) -> None:
|
||||
fake_client = _FakeClient(
|
||||
stream_events=[],
|
||||
complete_result={
|
||||
"text": '#Tool Call\n```fetch_weather\n{"city": "Hangzhou"}\n```\n',
|
||||
"toolEvents": [],
|
||||
"sessionId": "sess-fallback-hash-tool-call-openai",
|
||||
},
|
||||
)
|
||||
req = ChatCompletionsRequest(
|
||||
model="org_auto",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=False,
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "fetch_weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
tool_choice={"type": "function", "function": {"name": "fetch_weather"}},
|
||||
)
|
||||
|
||||
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)
|
||||
),
|
||||
):
|
||||
response = await main.v1_chat_completions(
|
||||
req, _make_request("/v1/chat/completions")
|
||||
)
|
||||
|
||||
payload = json.loads(response.body)
|
||||
message = payload["choices"][0]["message"]
|
||||
self.assertEqual(payload["choices"][0]["finish_reason"], "tool_calls")
|
||||
self.assertEqual(message["content"], "")
|
||||
self.assertEqual(message["tool_calls"][0]["function"]["name"], "fetch_weather")
|
||||
self.assertEqual(
|
||||
json.loads(message["tool_calls"][0]["function"]["arguments"]),
|
||||
{"city": "Hangzhou"},
|
||||
)
|
||||
|
||||
async def test_openai_non_stream_synthesizes_tool_call_from_hash_tool_call_block_without_tool_choice(
|
||||
self,
|
||||
) -> None:
|
||||
fake_client = _FakeClient(
|
||||
stream_events=[],
|
||||
complete_result={
|
||||
"text": '#Tool Call\n```fetch_weather\n{"city": "Hangzhou"}\n```\n',
|
||||
"toolEvents": [],
|
||||
"sessionId": "sess-fallback-hash-tool-call-openai-no-choice",
|
||||
},
|
||||
)
|
||||
req = ChatCompletionsRequest(
|
||||
model="org_auto",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=False,
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "fetch_weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
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)
|
||||
),
|
||||
):
|
||||
response = await main.v1_chat_completions(
|
||||
req, _make_request("/v1/chat/completions")
|
||||
)
|
||||
|
||||
payload = json.loads(response.body)
|
||||
message = payload["choices"][0]["message"]
|
||||
self.assertEqual(payload["choices"][0]["finish_reason"], "tool_calls")
|
||||
self.assertEqual(message["content"], "")
|
||||
self.assertEqual(message["tool_calls"][0]["function"]["name"], "fetch_weather")
|
||||
self.assertEqual(
|
||||
json.loads(message["tool_calls"][0]["function"]["arguments"]),
|
||||
{"city": "Hangzhou"},
|
||||
)
|
||||
|
||||
async def test_openai_non_stream_synthesizes_tool_call_from_json_action_block(
|
||||
self,
|
||||
) -> None:
|
||||
fake_client = _FakeClient(
|
||||
stream_events=[],
|
||||
complete_result={
|
||||
"text": '```json action\n{"tool":"fetch_weather","parameters":{"city":"Hangzhou"}}\n```',
|
||||
"toolEvents": [],
|
||||
"sessionId": "sess-action-block-openai",
|
||||
},
|
||||
)
|
||||
req = ChatCompletionsRequest(
|
||||
model="org_auto",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=False,
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "fetch_weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
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)
|
||||
),
|
||||
):
|
||||
response = await main.v1_chat_completions(
|
||||
req, _make_request("/v1/chat/completions")
|
||||
)
|
||||
|
||||
payload = json.loads(response.body)
|
||||
message = payload["choices"][0]["message"]
|
||||
self.assertEqual(payload["choices"][0]["finish_reason"], "tool_calls")
|
||||
self.assertEqual(message["content"], "")
|
||||
self.assertEqual(message["tool_calls"][0]["function"]["name"], "fetch_weather")
|
||||
self.assertEqual(
|
||||
json.loads(message["tool_calls"][0]["function"]["arguments"]),
|
||||
{"city": "Hangzhou"},
|
||||
)
|
||||
|
||||
async def test_openai_stream_synthesizes_tool_call_from_tool_code(
|
||||
self,
|
||||
) -> None:
|
||||
@@ -439,6 +602,55 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertIn('"finish_reason": "tool_calls"', body)
|
||||
self.assertIn("data: [DONE]", body)
|
||||
|
||||
async def test_openai_stream_synthesizes_tool_call_from_hash_tool_call_block_without_tool_choice(
|
||||
self,
|
||||
) -> None:
|
||||
fake_client = _FakeClient(
|
||||
stream_events=[
|
||||
{"type": "text", "text": "#Tool Call\n```fetch_weather\n"},
|
||||
{"type": "text", "text": '{"city": "Hangzhou"}\n'},
|
||||
{"type": "text", "text": "```\n"},
|
||||
],
|
||||
complete_result={},
|
||||
)
|
||||
req = ChatCompletionsRequest(
|
||||
model="org_auto",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=True,
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "fetch_weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
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)
|
||||
),
|
||||
):
|
||||
response = await main.v1_chat_completions(
|
||||
req, _make_request("/v1/chat/completions")
|
||||
)
|
||||
body = await _collect_stream(response)
|
||||
|
||||
self.assertIn('"tool_calls"', body)
|
||||
self.assertIn('"fetch_weather"', body)
|
||||
self.assertIn('"finish_reason": "tool_calls"', body)
|
||||
|
||||
async def test_openai_non_stream_synthesizes_tool_call_from_json_array(self) -> None:
|
||||
fake_client = _FakeClient(
|
||||
stream_events=[],
|
||||
@@ -1918,6 +2130,117 @@ class ToolCallBridgeTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(messages_dump[3]["role"], "user")
|
||||
self.assertEqual(messages_dump[3]["content"], "follow up")
|
||||
|
||||
async def test_openai_tool_result_is_emulated_into_followup_prompt(self) -> None:
|
||||
spy_client = _SpyClient(
|
||||
stream_events=[],
|
||||
complete_result={
|
||||
"text": "done",
|
||||
"toolEvents": [],
|
||||
"sessionId": "sess-emulated-tool-result",
|
||||
},
|
||||
)
|
||||
req = ChatCompletionsRequest(
|
||||
model="org_auto",
|
||||
messages=[
|
||||
{"role": "assistant", "content": None, "tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "fetch_weather", "arguments": '{"city":"Hangzhou"}'},
|
||||
}]},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": '{"temperature":"22C"}'},
|
||||
{"role": "user", "content": "continue"},
|
||||
],
|
||||
stream=False,
|
||||
tools=[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "fetch_weather",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
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)
|
||||
),
|
||||
):
|
||||
await main.v1_chat_completions(req, _make_request("/v1/chat/completions"))
|
||||
|
||||
prompt = spy_client.last_complete_args[0]
|
||||
self.assertIn("Tool result for call_1:", prompt)
|
||||
self.assertIn('{"temperature":"22C"}', prompt)
|
||||
self.assertIn("Assistant:", prompt)
|
||||
|
||||
async def test_anthropic_non_stream_synthesizes_tool_use_from_json_action_block(
|
||||
self,
|
||||
) -> None:
|
||||
fake_client = _FakeClient(
|
||||
stream_events=[],
|
||||
complete_result={
|
||||
"text": '```json action\n{"tool":"fetch_weather","parameters":{"city":"Hangzhou"}}\n```',
|
||||
"toolEvents": [],
|
||||
"sessionId": "sess-anthropic-action-block",
|
||||
},
|
||||
)
|
||||
req = AnthropicMessagesRequest(
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
max_tokens=64,
|
||||
messages=[{"role": "user", "content": "weather"}],
|
||||
stream=False,
|
||||
tools=[
|
||||
{
|
||||
"name": "fetch_weather",
|
||||
"description": "Get weather for a city",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
}
|
||||
],
|
||||
tool_choice={"type": "tool", "name": "fetch_weather"},
|
||||
)
|
||||
|
||||
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",
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
payload = json.loads(response.body)
|
||||
tool_blocks = [item for item in payload["content"] if item["type"] == "tool_use"]
|
||||
self.assertEqual(payload["stop_reason"], "tool_use")
|
||||
self.assertEqual(tool_blocks[0]["name"], "fetch_weather")
|
||||
self.assertEqual(tool_blocks[0]["input"], {"city": "Hangzhou"})
|
||||
|
||||
async def test_responses_stream_bridges_text_tool_and_completed_events(
|
||||
self,
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user