semora-llm 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- semora_llm-0.1.0/.gitignore +28 -0
- semora_llm-0.1.0/PKG-INFO +17 -0
- semora_llm-0.1.0/pyproject.toml +28 -0
- semora_llm-0.1.0/src/semora_llm/__init__.py +14 -0
- semora_llm-0.1.0/src/semora_llm/chat.py +303 -0
- semora_llm-0.1.0/src/semora_llm/dsml.py +206 -0
- semora_llm-0.1.0/src/semora_llm/py.typed +0 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
.pytest_cache/
|
|
3
|
+
.mypy_cache/
|
|
4
|
+
.ruff_cache/
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
*.egg-info/
|
|
8
|
+
build/
|
|
9
|
+
dist/
|
|
10
|
+
.coverage
|
|
11
|
+
htmlcov/
|
|
12
|
+
.env
|
|
13
|
+
.env.*
|
|
14
|
+
!.env.example
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Local tool/editor state — machine-specific, never pushed.
|
|
18
|
+
.claude/
|
|
19
|
+
.codecanvas/
|
|
20
|
+
.vscode/
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Superpowers design/spec scratch — working notes, not project documentation.
|
|
24
|
+
docs/superpowers/
|
|
25
|
+
|
|
26
|
+
# 로컬 자격증명 — 절대 커밋 금지.
|
|
27
|
+
a.txt
|
|
28
|
+
*.token
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: semora-llm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenAI-compatible chat model for Semora: official openai SDK, LangChain chunks.
|
|
5
|
+
Project-URL: Homepage, https://github.com/donggyun112/semora
|
|
6
|
+
Project-URL: Source, https://github.com/donggyun112/semora
|
|
7
|
+
Project-URL: Changelog, https://github.com/donggyun112/semora/blob/main/CHANGELOG.md
|
|
8
|
+
Author: donggyun112
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.12
|
|
16
|
+
Requires-Dist: langchain-core<2,>=1
|
|
17
|
+
Requires-Dist: openai<4,>=3
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "semora-llm"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "OpenAI-compatible chat model for Semora: official openai SDK, LangChain chunks."
|
|
9
|
+
requires-python = ">=3.12"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
authors = [{ name = "donggyun112" }]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 4 - Beta",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
"Typing :: Typed",
|
|
18
|
+
]
|
|
19
|
+
urls = { Homepage = "https://github.com/donggyun112/semora", Source = "https://github.com/donggyun112/semora", Changelog = "https://github.com/donggyun112/semora/blob/main/CHANGELOG.md" }
|
|
20
|
+
dependencies = [
|
|
21
|
+
"langchain-core>=1,<2",
|
|
22
|
+
"openai>=3,<4",
|
|
23
|
+
]
|
|
24
|
+
# Own footprint: the OpenAI Chat Completions wire and the chunk adapter the loop
|
|
25
|
+
# already speaks. Not a provider catalog. Anthropic/Google native APIs stay extras.
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["src/semora_llm"]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""OpenAI-compatible chat model used by Semora's planner."""
|
|
2
|
+
|
|
3
|
+
from .chat import ChatModel, openrouter, xai
|
|
4
|
+
from .dsml import DsmlFilter, parse_dsml_tool_calls, recover_dsml_chunks, strip_dsml
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"ChatModel",
|
|
8
|
+
"DsmlFilter",
|
|
9
|
+
"openrouter",
|
|
10
|
+
"parse_dsml_tool_calls",
|
|
11
|
+
"recover_dsml_chunks",
|
|
12
|
+
"strip_dsml",
|
|
13
|
+
"xai",
|
|
14
|
+
]
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""OpenAI Chat Completions client that streams LangChain chunks.
|
|
2
|
+
|
|
3
|
+
The loop binds tools and consumes ``astream``. This class is that surface over the
|
|
4
|
+
official ``openai`` SDK. HTTP, SSE, retries-off, and error types stay the SDK's.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from collections.abc import AsyncIterator, Iterable, Mapping, Sequence
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from langchain_core.messages import (
|
|
16
|
+
AIMessage,
|
|
17
|
+
AIMessageChunk,
|
|
18
|
+
BaseMessage,
|
|
19
|
+
HumanMessage,
|
|
20
|
+
SystemMessage,
|
|
21
|
+
ToolMessage,
|
|
22
|
+
)
|
|
23
|
+
from openai import AsyncOpenAI
|
|
24
|
+
|
|
25
|
+
from .dsml import recover_dsml_chunks
|
|
26
|
+
|
|
27
|
+
__all__ = ["ChatModel"]
|
|
28
|
+
|
|
29
|
+
OPENROUTER_URL = "https://openrouter.ai/api/v1"
|
|
30
|
+
XAI_URL = "https://api.x.ai/v1"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class ChatModel:
|
|
35
|
+
"""Stream chat completions from any OpenAI-compatible ``/v1`` endpoint.
|
|
36
|
+
|
|
37
|
+
``bind_tools`` / ``astream`` / ``_identifying_params`` are the planner contract.
|
|
38
|
+
Construct with ``base_url`` for OpenRouter, xAI, Groq, vLLM, Ollama, and the rest
|
|
39
|
+
of the OpenAI-compatible wire. Native Anthropic/Google APIs are not this class.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
model: str
|
|
43
|
+
api_key: str | None = None
|
|
44
|
+
base_url: str | None = None
|
|
45
|
+
default_headers: Mapping[str, str] | None = None
|
|
46
|
+
extra_body: Mapping[str, Any] | None = None
|
|
47
|
+
tools: tuple[dict[str, Any], ...] = ()
|
|
48
|
+
client: Any = None
|
|
49
|
+
timeout: float | None = None
|
|
50
|
+
"""Seconds before a request is abandoned. None leaves the SDK's own default, which
|
|
51
|
+
is generous: a hung provider holds a worker for ten minutes without one."""
|
|
52
|
+
recover_dsml: bool = False
|
|
53
|
+
"""Repair tool markup a gateway left in assistant content. A no-op for a provider
|
|
54
|
+
that does not leak it, so presets turn it on per gateway rather than per model."""
|
|
55
|
+
|
|
56
|
+
def bind_tools(self, tools: Sequence[Any], **_kwargs: Any) -> ChatModel:
|
|
57
|
+
"""Return a copy that sends these OpenAI-format tool definitions."""
|
|
58
|
+
encoded = tuple(_as_openai_tool(tool) for tool in tools)
|
|
59
|
+
return ChatModel(
|
|
60
|
+
model=self.model,
|
|
61
|
+
api_key=self.api_key,
|
|
62
|
+
base_url=self.base_url,
|
|
63
|
+
default_headers=self.default_headers,
|
|
64
|
+
extra_body=self.extra_body,
|
|
65
|
+
tools=encoded,
|
|
66
|
+
client=self.client,
|
|
67
|
+
timeout=self.timeout,
|
|
68
|
+
recover_dsml=self.recover_dsml,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def _identifying_params(self) -> dict[str, Any]:
|
|
73
|
+
"""Stable identity used by the durable model request key."""
|
|
74
|
+
return {
|
|
75
|
+
"model": self.model,
|
|
76
|
+
"base_url": self.base_url or "",
|
|
77
|
+
"tools": list(self.tools),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async def astream(
|
|
81
|
+
self, messages: Iterable[BaseMessage], **_kwargs: Any
|
|
82
|
+
) -> AsyncIterator[AIMessageChunk]:
|
|
83
|
+
"""Yield one LangChain chunk per provider delta, repaired where a gateway leaks."""
|
|
84
|
+
deltas = self._deltas(messages)
|
|
85
|
+
repaired = recover_dsml_chunks(deltas) if self.recover_dsml else deltas
|
|
86
|
+
async for chunk in repaired:
|
|
87
|
+
yield chunk
|
|
88
|
+
|
|
89
|
+
async def _deltas(
|
|
90
|
+
self, messages: Iterable[BaseMessage]
|
|
91
|
+
) -> AsyncIterator[AIMessageChunk]:
|
|
92
|
+
"""The provider's own stream, one LangChain chunk per delta."""
|
|
93
|
+
stream = await self._openai().chat.completions.create(
|
|
94
|
+
model=self.model,
|
|
95
|
+
messages=encode_messages(list(messages)),
|
|
96
|
+
stream=True,
|
|
97
|
+
stream_options={"include_usage": True},
|
|
98
|
+
**self._create_options(),
|
|
99
|
+
)
|
|
100
|
+
async for chunk in stream:
|
|
101
|
+
yield decode_chunk(chunk)
|
|
102
|
+
|
|
103
|
+
def _openai(self) -> Any:
|
|
104
|
+
"""Return the injected client or construct the official async SDK client."""
|
|
105
|
+
if self.client is not None:
|
|
106
|
+
return self.client
|
|
107
|
+
options: dict[str, Any] = {}
|
|
108
|
+
if self.timeout is not None:
|
|
109
|
+
options["timeout"] = self.timeout
|
|
110
|
+
return AsyncOpenAI(
|
|
111
|
+
api_key=self.api_key,
|
|
112
|
+
base_url=self.base_url,
|
|
113
|
+
default_headers=dict(self.default_headers) if self.default_headers else None,
|
|
114
|
+
# Retries stay off here. A retried model call is a second turn the ledger
|
|
115
|
+
# never saw, and deciding whether that is safe is the caller's, not ours.
|
|
116
|
+
max_retries=0,
|
|
117
|
+
**options,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def _create_options(self) -> dict[str, Any]:
|
|
121
|
+
"""Keyword arguments that only exist when the caller set them."""
|
|
122
|
+
options: dict[str, Any] = {}
|
|
123
|
+
if self.tools:
|
|
124
|
+
options["tools"] = list(self.tools)
|
|
125
|
+
if self.extra_body:
|
|
126
|
+
options["extra_body"] = dict(self.extra_body)
|
|
127
|
+
return options
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def encode_messages(messages: list[BaseMessage]) -> list[dict[str, Any]]:
|
|
131
|
+
"""Turn LangChain messages into Chat Completions message dicts."""
|
|
132
|
+
return [_encode_one(message) for message in messages]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def decode_chunk(chunk: Any) -> AIMessageChunk:
|
|
136
|
+
"""Turn one SDK stream chunk into the chunk type the loop already adds."""
|
|
137
|
+
choice = (getattr(chunk, "choices", None) or [None])[0]
|
|
138
|
+
delta = getattr(choice, "delta", None) if choice is not None else None
|
|
139
|
+
text = getattr(delta, "content", None) if delta is not None else None
|
|
140
|
+
content: str | list[dict[str, Any]] = text or ""
|
|
141
|
+
reasoning = _extra(delta, "reasoning")
|
|
142
|
+
if reasoning:
|
|
143
|
+
blocks: list[dict[str, Any]] = [
|
|
144
|
+
{"type": "reasoning", "reasoning": str(reasoning), "index": 0},
|
|
145
|
+
]
|
|
146
|
+
if text:
|
|
147
|
+
blocks.append({"type": "text", "text": str(text)})
|
|
148
|
+
content = blocks
|
|
149
|
+
kwargs: dict[str, Any] = {}
|
|
150
|
+
details = _extra(delta, "reasoning_details")
|
|
151
|
+
if details:
|
|
152
|
+
kwargs["reasoning_details"] = details
|
|
153
|
+
usage = getattr(chunk, "usage", None)
|
|
154
|
+
usage_metadata = _usage(usage)
|
|
155
|
+
model_name = getattr(chunk, "model", None)
|
|
156
|
+
return AIMessageChunk(
|
|
157
|
+
content=content,
|
|
158
|
+
additional_kwargs=kwargs,
|
|
159
|
+
tool_call_chunks=_tool_call_chunks(delta),
|
|
160
|
+
usage_metadata=usage_metadata or None,
|
|
161
|
+
response_metadata={"model_name": model_name} if model_name else {},
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _encode_one(message: BaseMessage) -> dict[str, Any]:
|
|
166
|
+
"""Encode one LangChain message to the Chat Completions shape."""
|
|
167
|
+
if isinstance(message, SystemMessage):
|
|
168
|
+
return {"role": "system", "content": _text(message)}
|
|
169
|
+
if isinstance(message, HumanMessage):
|
|
170
|
+
return {"role": "user", "content": _text(message)}
|
|
171
|
+
if isinstance(message, ToolMessage):
|
|
172
|
+
return {
|
|
173
|
+
"role": "tool",
|
|
174
|
+
"tool_call_id": message.tool_call_id,
|
|
175
|
+
"content": _text(message),
|
|
176
|
+
}
|
|
177
|
+
if isinstance(message, AIMessage):
|
|
178
|
+
body: dict[str, Any] = {"role": "assistant", "content": _text(message) or None}
|
|
179
|
+
if message.tool_calls:
|
|
180
|
+
body["tool_calls"] = [_encode_tool_call(call) for call in message.tool_calls]
|
|
181
|
+
if details := message.additional_kwargs.get("reasoning_details"):
|
|
182
|
+
body["reasoning_details"] = details
|
|
183
|
+
return body
|
|
184
|
+
return {"role": "user", "content": _text(message)}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _encode_tool_call(call: Mapping[str, Any]) -> dict[str, Any]:
|
|
188
|
+
"""Encode one LangChain tool call as an OpenAI function tool call."""
|
|
189
|
+
arguments = call.get("args", {})
|
|
190
|
+
if not isinstance(arguments, str):
|
|
191
|
+
arguments = json.dumps(arguments, ensure_ascii=False)
|
|
192
|
+
return {
|
|
193
|
+
"id": call.get("id") or "",
|
|
194
|
+
"type": "function",
|
|
195
|
+
"function": {"name": call.get("name") or "", "arguments": arguments},
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _as_openai_tool(tool: Any) -> dict[str, Any]:
|
|
200
|
+
"""Accept already-normalized OpenAI tool dicts from ``as_model_tools``."""
|
|
201
|
+
if isinstance(tool, Mapping) and tool.get("type") == "function":
|
|
202
|
+
return dict(tool)
|
|
203
|
+
if isinstance(tool, Mapping) and "name" in tool:
|
|
204
|
+
return {
|
|
205
|
+
"type": "function",
|
|
206
|
+
"function": {
|
|
207
|
+
"name": tool["name"],
|
|
208
|
+
"description": tool.get("description", ""),
|
|
209
|
+
"parameters": tool.get("parameters") or tool.get("schema") or {},
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
raise TypeError(f"unsupported tool definition: {type(tool)!r}")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _tool_call_chunks(delta: Any) -> list[dict[str, Any]]:
|
|
216
|
+
"""Extract streaming tool-call fragments from a delta."""
|
|
217
|
+
calls = getattr(delta, "tool_calls", None) if delta is not None else None
|
|
218
|
+
if not calls:
|
|
219
|
+
return []
|
|
220
|
+
chunks: list[dict[str, Any]] = []
|
|
221
|
+
for call in calls:
|
|
222
|
+
function = getattr(call, "function", None)
|
|
223
|
+
chunks.append(
|
|
224
|
+
{
|
|
225
|
+
"name": getattr(function, "name", None) or "",
|
|
226
|
+
"args": getattr(function, "arguments", None) or "",
|
|
227
|
+
"id": getattr(call, "id", None) or "",
|
|
228
|
+
"index": getattr(call, "index", None) or 0,
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
return chunks
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _usage(usage: Any) -> dict[str, int]:
|
|
235
|
+
"""Map SDK usage onto the names the loop already reads."""
|
|
236
|
+
if usage is None:
|
|
237
|
+
return {}
|
|
238
|
+
prompt = getattr(usage, "prompt_tokens", None) or getattr(usage, "input_tokens", 0) or 0
|
|
239
|
+
completion = (
|
|
240
|
+
getattr(usage, "completion_tokens", None) or getattr(usage, "output_tokens", 0) or 0
|
|
241
|
+
)
|
|
242
|
+
total = getattr(usage, "total_tokens", None) or (prompt + completion)
|
|
243
|
+
return {
|
|
244
|
+
"input_tokens": int(prompt),
|
|
245
|
+
"output_tokens": int(completion),
|
|
246
|
+
"total_tokens": int(total),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _extra(delta: Any, key: str) -> Any:
|
|
251
|
+
"""Read an undocumented delta field without depending on one SDK attribute layout."""
|
|
252
|
+
if delta is None:
|
|
253
|
+
return None
|
|
254
|
+
value = getattr(delta, key, None)
|
|
255
|
+
if value:
|
|
256
|
+
return value
|
|
257
|
+
extra = getattr(delta, "model_extra", None)
|
|
258
|
+
if isinstance(extra, Mapping):
|
|
259
|
+
return extra.get(key)
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _text(message: BaseMessage) -> str:
|
|
264
|
+
"""Flatten message content to the string Chat Completions still accepts."""
|
|
265
|
+
content = message.content
|
|
266
|
+
if isinstance(content, str):
|
|
267
|
+
return content
|
|
268
|
+
if isinstance(content, list):
|
|
269
|
+
parts = [
|
|
270
|
+
str(block.get("text") or block.get("reasoning") or "")
|
|
271
|
+
for block in content
|
|
272
|
+
if isinstance(block, dict)
|
|
273
|
+
]
|
|
274
|
+
return "".join(parts)
|
|
275
|
+
return str(content or "")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def openrouter(model: str, *, api_key: str | None = None, **options: Any) -> ChatModel:
|
|
279
|
+
"""OpenRouter preset: OpenAI wire plus the attribution headers their docs ask for."""
|
|
280
|
+
return ChatModel(
|
|
281
|
+
model,
|
|
282
|
+
api_key=api_key or os.environ.get("OPENROUTER_API_KEY"),
|
|
283
|
+
base_url=OPENROUTER_URL,
|
|
284
|
+
default_headers={
|
|
285
|
+
"HTTP-Referer": options.pop("referer", "https://semora.dev"),
|
|
286
|
+
"X-Title": options.pop("title", "Semora"),
|
|
287
|
+
},
|
|
288
|
+
extra_body=options.pop("extra_body", None),
|
|
289
|
+
# OpenRouter is the gateway observed dropping DeepSeek's markup into content.
|
|
290
|
+
# The repair costs a suffix scan per delta and does nothing to a clean stream.
|
|
291
|
+
recover_dsml=options.pop("recover_dsml", True),
|
|
292
|
+
**options,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def xai(model: str, *, api_key: str | None = None, **options: Any) -> ChatModel:
|
|
297
|
+
"""XAI preset: Grok over the OpenAI-compatible ``api.x.ai`` endpoint."""
|
|
298
|
+
return ChatModel(
|
|
299
|
+
model,
|
|
300
|
+
api_key=api_key or os.environ.get("XAI_API_KEY"),
|
|
301
|
+
base_url=XAI_URL,
|
|
302
|
+
**options,
|
|
303
|
+
)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Recover DeepSeek DSML tool markup that a provider left as assistant text.
|
|
2
|
+
|
|
3
|
+
DeepSeek V4 writes its tool calls as a markup block using fullwidth bars, and some
|
|
4
|
+
gateways (OpenRouter among them) fail to lift that into OpenAI ``tool_calls``,
|
|
5
|
+
streaming it as assistant content instead. Repairing it belongs to the provider
|
|
6
|
+
client: the planner speaks LangChain tool calls and should never learn a vendor's
|
|
7
|
+
markup dialect.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import uuid
|
|
15
|
+
from collections.abc import AsyncIterator
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from langchain_core.messages import AIMessageChunk
|
|
19
|
+
|
|
20
|
+
_BAR = "\uff5c" # FULLWIDTH VERTICAL LINE, what DeepSeek actually emits
|
|
21
|
+
_DSML = rf"(?:\|DSML\||{_BAR}DSML{_BAR})"
|
|
22
|
+
_INVOKE = re.compile(
|
|
23
|
+
rf"<{_DSML}invoke\s+name=\"([^\"]+)\"\s*>(.*?)</{_DSML}invoke>",
|
|
24
|
+
re.DOTALL,
|
|
25
|
+
)
|
|
26
|
+
_PARAM = re.compile(
|
|
27
|
+
rf"<{_DSML}parameter\s+name=\"([^\"]+)\"(?:\s+string=\"(true|false)\")?\s*>"
|
|
28
|
+
rf"(.*?)</{_DSML}parameter>",
|
|
29
|
+
re.DOTALL,
|
|
30
|
+
)
|
|
31
|
+
_OPENS = (
|
|
32
|
+
"<|DSML|tool_calls>",
|
|
33
|
+
"<|DSML|function_calls>",
|
|
34
|
+
"<|DSML|invoke ",
|
|
35
|
+
"<|DSML|invoke>",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _ascii(text: str) -> str:
|
|
40
|
+
"""Normalise the fullwidth bars so one set of tags matches both spellings."""
|
|
41
|
+
return text.replace(_BAR, "|")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_dsml_tool_calls(text: str) -> list[dict[str, Any]]:
|
|
45
|
+
"""Turn DSML markup into LangChain tool_call dicts. Empty if none."""
|
|
46
|
+
calls: list[dict[str, Any]] = []
|
|
47
|
+
for name, body in _INVOKE.findall(text or ""):
|
|
48
|
+
args: dict[str, Any] = {}
|
|
49
|
+
for key, is_str, raw in _PARAM.findall(body):
|
|
50
|
+
value: Any = raw
|
|
51
|
+
if is_str == "false":
|
|
52
|
+
try:
|
|
53
|
+
value = json.loads(raw)
|
|
54
|
+
except json.JSONDecodeError:
|
|
55
|
+
value = raw
|
|
56
|
+
args[key] = value
|
|
57
|
+
calls.append(
|
|
58
|
+
{
|
|
59
|
+
"name": name,
|
|
60
|
+
"args": args,
|
|
61
|
+
"id": f"call_{uuid.uuid4().hex[:12]}",
|
|
62
|
+
"type": "tool_call",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
return calls
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def strip_dsml(text: str) -> str:
|
|
69
|
+
"""Drop DSML markup, including an unfinished open tag at the end."""
|
|
70
|
+
if not text:
|
|
71
|
+
return ""
|
|
72
|
+
start = _open_index(text)
|
|
73
|
+
if start is not None:
|
|
74
|
+
return text[:start].rstrip()
|
|
75
|
+
held = _prefix_len(text)
|
|
76
|
+
return text[:-held].rstrip() if held else text
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _open_index(text: str) -> int | None:
|
|
80
|
+
hits = [_ascii(text).find(tag) for tag in _OPENS]
|
|
81
|
+
found = [index for index in hits if index >= 0]
|
|
82
|
+
return min(found) if found else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _prefix_len(text: str) -> int:
|
|
86
|
+
"""Longest suffix of ``text`` that is a prefix of a DSML open tag."""
|
|
87
|
+
norm = _ascii(text)
|
|
88
|
+
best = 0
|
|
89
|
+
for tag in _OPENS:
|
|
90
|
+
limit = min(len(tag), len(norm))
|
|
91
|
+
for size in range(1, limit + 1):
|
|
92
|
+
if norm.endswith(tag[:size]):
|
|
93
|
+
best = max(best, size)
|
|
94
|
+
return best
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class DsmlFilter:
|
|
98
|
+
"""Hold streamed deltas until they are either ordinary text or DSML markup."""
|
|
99
|
+
|
|
100
|
+
def __init__(self) -> None:
|
|
101
|
+
"""Start with nothing held and nothing swallowed."""
|
|
102
|
+
self._held = ""
|
|
103
|
+
self._swallow = False
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def swallowed(self) -> bool:
|
|
107
|
+
"""True once a real open tag arrived and the rest is markup."""
|
|
108
|
+
return self._swallow
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def markup(self) -> str:
|
|
112
|
+
"""The swallowed block, for parsing. Empty while nothing has been swallowed."""
|
|
113
|
+
return self._held if self._swallow else ""
|
|
114
|
+
|
|
115
|
+
def push(self, delta: str) -> str:
|
|
116
|
+
"""Return the visible slice of ``delta``; empty when it belongs to DSML."""
|
|
117
|
+
if not delta:
|
|
118
|
+
return ""
|
|
119
|
+
if self._swallow:
|
|
120
|
+
self._held += delta
|
|
121
|
+
return ""
|
|
122
|
+
self._held += delta
|
|
123
|
+
start = _open_index(self._held)
|
|
124
|
+
if start is not None:
|
|
125
|
+
visible = self._held[:start]
|
|
126
|
+
self._held = self._held[start:]
|
|
127
|
+
self._swallow = True
|
|
128
|
+
return visible
|
|
129
|
+
held = _prefix_len(self._held)
|
|
130
|
+
if not held:
|
|
131
|
+
visible, self._held = self._held, ""
|
|
132
|
+
return visible
|
|
133
|
+
visible, self._held = self._held[:-held], self._held[-held:]
|
|
134
|
+
return visible
|
|
135
|
+
|
|
136
|
+
def finish(self) -> str:
|
|
137
|
+
"""Flush a prefix that never became markup. Swallowed DSML stays hidden."""
|
|
138
|
+
if self._swallow or "DSML" in _ascii(self._held):
|
|
139
|
+
self._swallow = True
|
|
140
|
+
return ""
|
|
141
|
+
visible, self._held = self._held, ""
|
|
142
|
+
return visible
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _chunk_text(chunk: Any) -> str:
|
|
146
|
+
"""The plain text of a chunk, or empty when its content is not a string."""
|
|
147
|
+
text = getattr(chunk, "text", None)
|
|
148
|
+
if isinstance(text, str) and text:
|
|
149
|
+
return text
|
|
150
|
+
content = getattr(chunk, "content", "")
|
|
151
|
+
return content if isinstance(content, str) else ""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _has_native_calls(chunk: Any) -> bool:
|
|
155
|
+
"""True when the gateway did lift the call, leaving nothing to recover."""
|
|
156
|
+
return bool(getattr(chunk, "tool_calls", None) or getattr(chunk, "tool_call_chunks", None))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def recover_dsml_chunks(
|
|
160
|
+
chunks: AsyncIterator[AIMessageChunk],
|
|
161
|
+
) -> AsyncIterator[AIMessageChunk]:
|
|
162
|
+
"""Hide leaked markup mid-stream and close with the calls parsed out of it.
|
|
163
|
+
|
|
164
|
+
A delta that is entirely the start of an open tag is held back, because the next
|
|
165
|
+
one decides whether it was markup or a stray character. Held chunks are dropped
|
|
166
|
+
once later output carries them, and released unchanged when the block never
|
|
167
|
+
completed — a sentence ending in ``<`` is a sentence, not a tool call.
|
|
168
|
+
"""
|
|
169
|
+
pending: list[AIMessageChunk] = []
|
|
170
|
+
filt = DsmlFilter()
|
|
171
|
+
last: AIMessageChunk | None = None
|
|
172
|
+
native = False
|
|
173
|
+
async for chunk in chunks:
|
|
174
|
+
last = chunk
|
|
175
|
+
if _has_native_calls(chunk):
|
|
176
|
+
held = filt.finish()
|
|
177
|
+
if held:
|
|
178
|
+
yield AIMessageChunk(content=held)
|
|
179
|
+
pending.clear()
|
|
180
|
+
native = True
|
|
181
|
+
yield chunk
|
|
182
|
+
continue
|
|
183
|
+
piece = _chunk_text(chunk)
|
|
184
|
+
visible = filt.push(piece)
|
|
185
|
+
if visible:
|
|
186
|
+
pending.clear()
|
|
187
|
+
yield chunk.model_copy(update={"content": visible}) if piece != visible else chunk
|
|
188
|
+
elif piece:
|
|
189
|
+
pending.append(chunk)
|
|
190
|
+
leftover = filt.finish()
|
|
191
|
+
if leftover:
|
|
192
|
+
yield AIMessageChunk(content=leftover)
|
|
193
|
+
return
|
|
194
|
+
if native:
|
|
195
|
+
return
|
|
196
|
+
calls = parse_dsml_tool_calls(filt.markup)
|
|
197
|
+
if not calls:
|
|
198
|
+
for held_chunk in pending:
|
|
199
|
+
yield held_chunk
|
|
200
|
+
return
|
|
201
|
+
yield AIMessageChunk(
|
|
202
|
+
content="",
|
|
203
|
+
tool_calls=calls,
|
|
204
|
+
id=getattr(last, "id", None),
|
|
205
|
+
response_metadata=getattr(last, "response_metadata", {}) or {},
|
|
206
|
+
)
|
|
File without changes
|