feat: bridge Lingma tool events to OpenAI/Anthropic responses

Add structured tool event propagation from Lingma stream/finish metadata and map it to OpenAI tool_calls and Anthropic tool_use/tool_result in both streaming and non-streaming responses. Add focused bridge tests and update docs/design notes to match current behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
GitHub Actions
2026-04-18 22:34:43 +08:00
parent b3fd8800f7
commit 1c7b86e2c0
6 changed files with 668 additions and 35 deletions

View File

@@ -9,7 +9,7 @@ import subprocess
import time
import uuid
from pathlib import Path
from typing import AsyncIterator, Callable, Optional
from typing import Any, AsyncIterator, Callable, Optional
import websockets
@@ -103,6 +103,58 @@ class LspWsRpcClient:
self._on_disconnect = on_disconnect
self._closed = False
@staticmethod
def _extract_tool_event(params: dict[str, Any]) -> dict[str, Any] | None:
candidates: list[dict[str, Any]] = []
if isinstance(params.get("toolCall"), dict):
candidates.append(params["toolCall"])
if isinstance(params.get("tool_call"), dict):
candidates.append(params["tool_call"])
if isinstance(params.get("tool"), dict):
candidates.append(params["tool"])
data = params.get("data")
if isinstance(data, dict):
if isinstance(data.get("toolCall"), dict):
candidates.append(data["toolCall"])
if isinstance(data.get("tool_call"), dict):
candidates.append(data["tool_call"])
if isinstance(data.get("tool"), dict):
candidates.append(data["tool"])
if not candidates:
return None
raw = candidates[0]
tool_id = (
raw.get("toolCallId")
or raw.get("tool_call_id")
or raw.get("id")
or params.get("toolCallId")
or params.get("tool_call_id")
)
name = raw.get("name") or raw.get("toolName") or raw.get("tool_name")
call_input = raw.get("input")
if call_input is None:
call_input = raw.get("arguments")
if call_input is None:
call_input = raw.get("args")
result_payload = raw.get("result")
if result_payload is None:
result_payload = params.get("result")
if result_payload is None and isinstance(data, dict):
result_payload = data.get("result")
if not tool_id:
return None
return {
"id": str(tool_id),
"name": str(name or "tool"),
"input": call_input if call_input is not None else {},
"result": result_payload,
}
async def start(self):
self._reader_task = asyncio.create_task(self._reader_loop())
@@ -185,7 +237,16 @@ class LspWsRpcClient:
stream["parts"].append(text)
if stream["first_chunk_at"] is None:
stream["first_chunk_at"] = time.monotonic()
stream["chunks"].put_nowait(text)
stream["chunks"].put_nowait({"type": "text", "text": text})
if method in {"tool/call/sync", "tool/invoke", "tool/call/approve"}:
req_id = params.get("requestId")
stream = self._chat_streams.get(req_id)
if stream is not None:
tool_event = self._extract_tool_event(params)
if tool_event is not None:
stream["tool_events"].append(tool_event)
stream["chunks"].put_nowait({"type": "tool", "tool": tool_event})
if method == "chat/finish":
req_id = params.get("requestId")
@@ -224,6 +285,7 @@ class LspWsRpcClient:
"chunks": asyncio.Queue(),
"done": asyncio.Event(),
"finish": None,
"tool_events": [],
"started_at": time.monotonic(),
"first_chunk_at": None,
"finish_at": None,
@@ -239,7 +301,7 @@ class LspWsRpcClient:
with contextlib.suppress(Exception):
stream["chunks"].put_nowait(None)
async def consume_stream(self, request_id: str, timeout: float) -> AsyncIterator[str]:
async def consume_stream(self, request_id: str, timeout: float) -> AsyncIterator[dict[str, Any]]:
stream = self._chat_streams.get(request_id)
if stream is None:
return
@@ -266,6 +328,7 @@ class LspWsRpcClient:
"finish": stream.get("finish") or {},
"firstTokenLatencyMs": first_ms,
"totalLatencyMs": total_ms,
"toolEvents": stream.get("tool_events") or [],
}
@@ -722,8 +785,12 @@ class LingmaGatewayClient:
session_id: str | None = None,
is_reply: bool = False,
out_meta: dict | None = None,
) -> AsyncIterator[str]:
"""Stream `chat/answer` chunks.
) -> AsyncIterator[dict[str, Any]]:
"""Stream chat events.
Yields structured events:
* {"type": "text", "text": "..."}
* {"type": "tool", "tool": {...}}
If `out_meta` is provided, the final `chat/finish` payload's sessionId
(and the raw finish dict) is written into it when the stream ends or is
@@ -739,10 +806,10 @@ class LingmaGatewayClient:
self.rpc.create_stream(request_id)
try:
await self._kick_chat_ask(payload)
async for chunk in self.rpc.consume_stream(
async for event in self.rpc.consume_stream(
request_id, timeout=max(60.0, self.rpc_timeout + 60.0)
):
yield chunk
yield event
finally:
# Runs on normal completion, exception, or consumer GeneratorExit (client disconnect).
if out_meta is not None:
@@ -753,6 +820,7 @@ class LingmaGatewayClient:
out_meta["finish"] = finish
out_meta["request_id"] = request_id
out_meta["chars"] = len(stream_result.get("text") or "")
out_meta["tool_events"] = stream_result.get("toolEvents") or []
except Exception:
pass
self.rpc.pop_stream(request_id)