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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user