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:
mmc
2026-05-07 18:10:01 +08:00
parent 5911e4322e
commit 94a8025ae5
11 changed files with 1808 additions and 4 deletions

View File

@@ -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: