minimalcode 1.5.2__py3-none-any.whl
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.
- minimalcode/__init__.py +3 -0
- minimalcode/__main__.py +3 -0
- minimalcode/agent.py +1084 -0
- minimalcode/cli.py +1520 -0
- minimalcode/clipboard.py +212 -0
- minimalcode/config.py +1065 -0
- minimalcode/defaults.json +491 -0
- minimalcode/errors.py +31 -0
- minimalcode/highlight/__init__.py +47 -0
- minimalcode/highlight/code.py +261 -0
- minimalcode/highlight/color.py +58 -0
- minimalcode/highlight/markdown.py +710 -0
- minimalcode/highlight/palette.py +73 -0
- minimalcode/highlight/sanitize.py +175 -0
- minimalcode/highlight/thinking.py +71 -0
- minimalcode/httpclient.py +278 -0
- minimalcode/importer.py +253 -0
- minimalcode/prompt.py +195 -0
- minimalcode/provider/__init__.py +28 -0
- minimalcode/provider/anthropic.py +805 -0
- minimalcode/provider/anthropic_env.py +161 -0
- minimalcode/provider/chat.py +394 -0
- minimalcode/provider/client.py +243 -0
- minimalcode/provider/common.py +176 -0
- minimalcode/provider/deepseek_web.py +1158 -0
- minimalcode/provider/factory.py +63 -0
- minimalcode/provider/responses.py +692 -0
- minimalcode/provider/sha3_wasm_bg.wasm +0 -0
- minimalcode/provider/transport.py +241 -0
- minimalcode/provider/types.py +54 -0
- minimalcode/sandbox/__init__.py +64 -0
- minimalcode/sandbox/audit.py +120 -0
- minimalcode/sandbox/backends.py +343 -0
- minimalcode/sandbox/execution.py +435 -0
- minimalcode/sandbox/network.py +793 -0
- minimalcode/sandbox/policy.py +253 -0
- minimalcode/sandbox/runner.py +347 -0
- minimalcode/sandbox/sensitive.py +286 -0
- minimalcode/session.py +465 -0
- minimalcode/shell.py +333 -0
- minimalcode/subagent.py +120 -0
- minimalcode/tools/__init__.py +46 -0
- minimalcode/tools/files.py +1139 -0
- minimalcode/tools/process.py +164 -0
- minimalcode/tools/remote.py +74 -0
- minimalcode/tools/runtime.py +219 -0
- minimalcode/tools/schemas.py +418 -0
- minimalcode/tools/search.py +521 -0
- minimalcode/tools/search_tools.py +583 -0
- minimalcode/tools/validation.py +99 -0
- minimalcode/vision.py +231 -0
- minimalcode/web.py +238 -0
- minimalcode-1.5.2.dist-info/METADATA +646 -0
- minimalcode-1.5.2.dist-info/RECORD +58 -0
- minimalcode-1.5.2.dist-info/WHEEL +5 -0
- minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
- minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
- minimalcode-1.5.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1158 @@
|
|
|
1
|
+
"""DeepSeek web (chat.deepseek.com) protocol: proof of work, patch SSE, text tools.
|
|
2
|
+
|
|
3
|
+
The consumer web endpoint differs from every API dialect this package speaks:
|
|
4
|
+
|
|
5
|
+
* Every completion is gated by ``x-ds-pow-response``, a base64 ticket whose
|
|
6
|
+
``answer`` must satisfy ``DeepSeekHashV1(salt_expire_at_nonce) == challenge``.
|
|
7
|
+
That hash ships only as a WebAssembly module, so the module runs DeepSeek's own
|
|
8
|
+
binary through wasmtime instead of reimplementing non-standard Keccak.
|
|
9
|
+
* The stream is not one event per token: it is JSON-Patch over the response
|
|
10
|
+
object, and thinking-mode answers only appear in full in a trailing snapshot.
|
|
11
|
+
* There is no function calling, so tools are rendered into the prompt and parsed
|
|
12
|
+
back out of a delimited block (``tool_mode = "text"``). ``"none"`` disables
|
|
13
|
+
both the instructions and the filter.
|
|
14
|
+
|
|
15
|
+
Everything here is one round trip per ``stream_chat`` call: a fresh chat session
|
|
16
|
+
is created each turn and the whole history is flattened into one prompt, which
|
|
17
|
+
keeps the client stateless and immune to the server's ``invalid message id``
|
|
18
|
+
chain failures.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import struct
|
|
28
|
+
import threading
|
|
29
|
+
import urllib.request
|
|
30
|
+
from collections.abc import Callable, Iterable, Iterator
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
from ..errors import ProviderError
|
|
35
|
+
from .types import ProviderReply, ToolCall
|
|
36
|
+
|
|
37
|
+
CHAT_PATH = "/api/v0/chat/completion"
|
|
38
|
+
SESSION_PATH = "/api/v0/chat_session/create"
|
|
39
|
+
POW_PATH = "/api/v0/chat/create_pow_challenge"
|
|
40
|
+
|
|
41
|
+
# One delimited block instead of bare JSON: the model may still wrap prose around
|
|
42
|
+
# it, and a fenced marker is far easier to find in a token stream than balanced
|
|
43
|
+
# braces.
|
|
44
|
+
TOOL_OPEN = "<<<TOOL_CALLS>>>"
|
|
45
|
+
TOOL_CLOSE = "<<<END_TOOL_CALLS>>>"
|
|
46
|
+
|
|
47
|
+
TOOL_INSTRUCTIONS = f"""\
|
|
48
|
+
# Tool protocol
|
|
49
|
+
|
|
50
|
+
You cannot call functions directly. To use a tool, print exactly one block:
|
|
51
|
+
|
|
52
|
+
{TOOL_OPEN}
|
|
53
|
+
[{{"name": "<tool name>", "arguments": {{<json arguments>}}}}]
|
|
54
|
+
{TOOL_CLOSE}
|
|
55
|
+
|
|
56
|
+
Rules:
|
|
57
|
+
- Emit the block alone, with no prose before or after it.
|
|
58
|
+
- The block must be strict JSON: double quotes only, no comments, no trailing
|
|
59
|
+
commas, and every double quote *inside* a string escaped as \\".
|
|
60
|
+
- Emit several entries in one block to run tools in parallel.
|
|
61
|
+
- After the block, stop: the tool results arrive in the next user message.
|
|
62
|
+
- Only use tools from the list in this prompt; never invent a tool name.
|
|
63
|
+
|
|
64
|
+
Correct, note the escaped inner quotes:
|
|
65
|
+
|
|
66
|
+
{TOOL_OPEN}
|
|
67
|
+
[{{"name": "shell", "arguments": {{"command": "python -c \\"print(1)\\""}}}}]
|
|
68
|
+
{TOOL_CLOSE}
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
# Sent as an extra turn when the tool block cannot be parsed. It names the exact
|
|
72
|
+
# reason so the model repairs its own syntax instead of repeating it.
|
|
73
|
+
_TOOL_CORRECTION = """
|
|
74
|
+
|
|
75
|
+
# Correction
|
|
76
|
+
|
|
77
|
+
Your previous reply was rejected by the tool-call parser: {error}
|
|
78
|
+
|
|
79
|
+
Re-emit the tool call now, exactly once, following every rule above. You may use
|
|
80
|
+
either the JSON block or DeepSeek's invoke markup, but the argument values must
|
|
81
|
+
never contain a stray unescaped double quote or leftover markup tags.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
# The wasm binary is DeepSeek's own build of the hash. It is cached on disk and
|
|
85
|
+
# only fetched when missing, so a normal install never needs the network twice.
|
|
86
|
+
WASM_FILENAME = "sha3_wasm_bg.wasm"
|
|
87
|
+
WASM_URLS = (
|
|
88
|
+
"https://fe-static.deepseek.com/chat/static/sha3_wasm_bg.7b9ca65ddd.wasm",
|
|
89
|
+
"https://raw.githubusercontent.com/sums001/Deepseek-API/main/deepseek/sha3_wasm_bg.wasm",
|
|
90
|
+
)
|
|
91
|
+
# A downloaded fallback is executed, so it has to match the module that ships in
|
|
92
|
+
# the wheel; the mirror above is third-party and must not be able to swap it.
|
|
93
|
+
WASM_SHA256 = "b3fca8cc072c1defbd60c02266a8e48bd307a1804aaff4314900aea720e72f7d"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _cache_dir() -> Path:
|
|
97
|
+
override = os.environ.get("MINIMALCODE_CACHE_DIR")
|
|
98
|
+
root = Path(override) if override else Path.home() / ".minimalcode"
|
|
99
|
+
return root / "cache"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _ensure_wasm(configured: str | None = None) -> Path:
|
|
103
|
+
"""Return a usable wasm path, downloading once into the cache."""
|
|
104
|
+
candidates = []
|
|
105
|
+
if configured:
|
|
106
|
+
candidates.append(Path(configured).expanduser())
|
|
107
|
+
candidates.append(Path(__file__).resolve().parent / WASM_FILENAME)
|
|
108
|
+
candidates.append(_cache_dir() / WASM_FILENAME)
|
|
109
|
+
for path in candidates:
|
|
110
|
+
if path.is_file() and path.stat().st_size > 1024:
|
|
111
|
+
return path
|
|
112
|
+
import hashlib
|
|
113
|
+
|
|
114
|
+
target = _cache_dir() / WASM_FILENAME
|
|
115
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
errors = []
|
|
117
|
+
for url in WASM_URLS:
|
|
118
|
+
try:
|
|
119
|
+
request = urllib.request.Request(url, headers={"User-Agent": "MinimalCode"})
|
|
120
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
121
|
+
data = response.read()
|
|
122
|
+
if data[:4] != b"\x00asm":
|
|
123
|
+
raise ValueError("not a wasm binary")
|
|
124
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
125
|
+
if digest != WASM_SHA256:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"sha256 {digest} does not match the bundled module "
|
|
128
|
+
f"({WASM_SHA256}); refusing to execute it"
|
|
129
|
+
)
|
|
130
|
+
target.write_bytes(data)
|
|
131
|
+
return target
|
|
132
|
+
except Exception as exc: # noqa: BLE001 - try the next mirror
|
|
133
|
+
errors.append(f"{url}: {exc}")
|
|
134
|
+
raise ProviderError(
|
|
135
|
+
"deepseek_web: cannot obtain sha3_wasm_bg.wasm; place it at "
|
|
136
|
+
f"{target} or set providers.<name>.pow_wasm_path\n" + "\n".join(errors)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class _PowSolver:
|
|
141
|
+
"""DeepSeekHashV1 solver backed by the vendor's wasm in wasmtime.
|
|
142
|
+
|
|
143
|
+
A wasmtime store is not reentrant, so ``solve`` serialises on a lock; the
|
|
144
|
+
computation itself is milliseconds at the usual difficulty of 144000.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(self, wasm_path: Path):
|
|
148
|
+
self._lock = threading.Lock()
|
|
149
|
+
self._wasm_path = wasm_path
|
|
150
|
+
|
|
151
|
+
def _load(self) -> None:
|
|
152
|
+
try:
|
|
153
|
+
import wasmtime
|
|
154
|
+
except ImportError as exc:
|
|
155
|
+
raise ProviderError(
|
|
156
|
+
"deepseek_web: proof of work needs the optional wasmtime package; "
|
|
157
|
+
"install it with `pip install 'minimalcode[deepseek-web]'`"
|
|
158
|
+
) from exc
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
engine = wasmtime.Engine()
|
|
162
|
+
module = wasmtime.Module.from_file(engine, str(self._wasm_path))
|
|
163
|
+
store = wasmtime.Store(engine)
|
|
164
|
+
linker = wasmtime.Linker(engine)
|
|
165
|
+
linker.define_wasi()
|
|
166
|
+
instance = linker.instantiate(store, module)
|
|
167
|
+
exports = instance.exports(store)
|
|
168
|
+
self._memory = exports["memory"]
|
|
169
|
+
self._solve_fn = exports["wasm_solve"]
|
|
170
|
+
self._stack = exports["__wbindgen_add_to_stack_pointer"]
|
|
171
|
+
names = ("__wbindgen_malloc", "__wbindgen_export_0")
|
|
172
|
+
self._malloc = next(exports[name] for name in names if name in exports)
|
|
173
|
+
except ProviderError:
|
|
174
|
+
raise
|
|
175
|
+
except Exception as exc: # wasmtime raises its own error types
|
|
176
|
+
raise ProviderError(
|
|
177
|
+
"deepseek_web: cannot load the proof-of-work module "
|
|
178
|
+
f"{self._wasm_path}: {exc}"
|
|
179
|
+
) from exc
|
|
180
|
+
self._store = store
|
|
181
|
+
|
|
182
|
+
def _write(self, text: str) -> tuple[int, int]:
|
|
183
|
+
"""Copy one UTF-8 string into wasm memory and return ``(ptr, len)``.
|
|
184
|
+
|
|
185
|
+
The allocation is exactly the string length, never a reused or rounded
|
|
186
|
+
buffer: the module takes ownership of the block and frees it with the
|
|
187
|
+
length it was handed, so a larger allocation corrupts its allocator and
|
|
188
|
+
the call traps. The block is intentionally never freed from here — a
|
|
189
|
+
free after ``wasm_solve`` panics the module too, and a challenge only
|
|
190
|
+
leaks a few dozen bytes.
|
|
191
|
+
"""
|
|
192
|
+
data = text.encode("utf-8")
|
|
193
|
+
pointer = self._malloc(self._store, max(len(data), 1), 1)
|
|
194
|
+
self._memory.write(self._store, data, pointer)
|
|
195
|
+
return pointer, len(data)
|
|
196
|
+
|
|
197
|
+
def _solve(self, challenge: str, prefix: str, difficulty: float) -> int | None:
|
|
198
|
+
store = self._store
|
|
199
|
+
ret = self._stack(store, -16)
|
|
200
|
+
try:
|
|
201
|
+
c_ptr, c_len = self._write(challenge)
|
|
202
|
+
p_ptr, p_len = self._write(prefix)
|
|
203
|
+
self._solve_fn(store, ret, c_ptr, c_len, p_ptr, p_len, float(difficulty))
|
|
204
|
+
status = struct.unpack("<i", self._memory.read(store, ret, ret + 4))[0]
|
|
205
|
+
value = struct.unpack("<d", self._memory.read(store, ret + 8, ret + 16))[0]
|
|
206
|
+
finally:
|
|
207
|
+
self._stack(store, 16)
|
|
208
|
+
return int(value) if status else None
|
|
209
|
+
|
|
210
|
+
def header(self, challenge: dict[str, Any]) -> str:
|
|
211
|
+
"""Build the base64 ``x-ds-pow-response`` value for one challenge."""
|
|
212
|
+
if not isinstance(challenge, dict) or not challenge:
|
|
213
|
+
raise ProviderError(
|
|
214
|
+
f"deepseek_web: pow challenge is {type(challenge).__name__}, "
|
|
215
|
+
"not an object"
|
|
216
|
+
)
|
|
217
|
+
if challenge.get("algorithm") != "DeepSeekHashV1":
|
|
218
|
+
raise ProviderError(
|
|
219
|
+
"deepseek_web: unsupported pow algorithm "
|
|
220
|
+
f"{challenge.get('algorithm')!r}"
|
|
221
|
+
)
|
|
222
|
+
required = ("challenge", "salt", "expire_at", "difficulty", "signature")
|
|
223
|
+
missing = [key for key in required if key not in challenge]
|
|
224
|
+
if missing:
|
|
225
|
+
raise ProviderError(
|
|
226
|
+
"deepseek_web: pow challenge is missing "
|
|
227
|
+
f"{', '.join(missing)}; the server changed its challenge shape"
|
|
228
|
+
)
|
|
229
|
+
prefix = f"{challenge['salt']}_{challenge['expire_at']}_"
|
|
230
|
+
with self._lock:
|
|
231
|
+
if not hasattr(self, "_store"):
|
|
232
|
+
self._load()
|
|
233
|
+
answer = self._solve(
|
|
234
|
+
challenge["challenge"], prefix, challenge["difficulty"]
|
|
235
|
+
)
|
|
236
|
+
if answer is None:
|
|
237
|
+
raise ProviderError("deepseek_web: pow challenge expired or unsolvable")
|
|
238
|
+
payload = {
|
|
239
|
+
"algorithm": challenge["algorithm"],
|
|
240
|
+
"challenge": challenge["challenge"],
|
|
241
|
+
"salt": challenge["salt"],
|
|
242
|
+
"answer": answer,
|
|
243
|
+
"signature": challenge["signature"],
|
|
244
|
+
"target_path": challenge.get("target_path", ""),
|
|
245
|
+
}
|
|
246
|
+
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
247
|
+
return base64.b64encode(raw).decode("ascii")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
_SOLVERS: dict[str, _PowSolver] = {}
|
|
251
|
+
_SOLVERS_LOCK = threading.Lock()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _pow_solver(wasm_path: Path) -> _PowSolver:
|
|
255
|
+
with _SOLVERS_LOCK:
|
|
256
|
+
solver = _SOLVERS.get(str(wasm_path))
|
|
257
|
+
if solver is None:
|
|
258
|
+
solver = _PowSolver(wasm_path)
|
|
259
|
+
_SOLVERS[str(wasm_path)] = solver
|
|
260
|
+
return solver
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _client_stream_id() -> str:
|
|
264
|
+
"""``YYYYMMDD-<16 hex>``: the id the web client sends with every stream."""
|
|
265
|
+
import secrets
|
|
266
|
+
import time
|
|
267
|
+
|
|
268
|
+
return f"{time.strftime('%Y%m%d')}-{secrets.token_hex(8)}"
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _frag_kind(kind: str | None) -> str:
|
|
272
|
+
"""Map a fragment type onto the three display channels.
|
|
273
|
+
|
|
274
|
+
The type is compared case-insensitively, and an untyped fragment counts as
|
|
275
|
+
answer text: the server omitting ``type`` used to drop the whole reply
|
|
276
|
+
silently, which is worse than showing a SEARCH/TIP line by accident.
|
|
277
|
+
"""
|
|
278
|
+
if isinstance(kind, str):
|
|
279
|
+
lowered = kind.strip().lower()
|
|
280
|
+
if lowered == "think":
|
|
281
|
+
return "thinking"
|
|
282
|
+
if lowered in {"response", "answer"}:
|
|
283
|
+
return "answer"
|
|
284
|
+
if kind is None:
|
|
285
|
+
return "answer"
|
|
286
|
+
return "other"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class _Fragments:
|
|
290
|
+
"""Track per-fragment output length so snapshots never repeat text.
|
|
291
|
+
|
|
292
|
+
Thinking-mode replies stream THINK deltas and then send a trailing full
|
|
293
|
+
snapshot whose RESPONSE fragment was never streamed. Differencing against the
|
|
294
|
+
already emitted length delivers that tail exactly once.
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
def __init__(self) -> None:
|
|
298
|
+
self.kinds: list[str | None] = []
|
|
299
|
+
self.emitted: list[int] = []
|
|
300
|
+
|
|
301
|
+
def _grow(self, size: int) -> None:
|
|
302
|
+
while len(self.kinds) < size:
|
|
303
|
+
self.kinds.append(None)
|
|
304
|
+
self.emitted.append(0)
|
|
305
|
+
|
|
306
|
+
def full(self, index: int, fragment: dict[str, Any]) -> tuple[str, str] | None:
|
|
307
|
+
self._grow(index + 1)
|
|
308
|
+
if fragment.get("type") is not None:
|
|
309
|
+
self.kinds[index] = fragment.get("type")
|
|
310
|
+
content = fragment.get("content") or ""
|
|
311
|
+
if len(content) <= self.emitted[index]:
|
|
312
|
+
return None
|
|
313
|
+
delta = content[self.emitted[index] :]
|
|
314
|
+
self.emitted[index] = len(content)
|
|
315
|
+
return _frag_kind(self.kinds[index]), delta
|
|
316
|
+
|
|
317
|
+
def append(self, index: int, text: str) -> tuple[str, str]:
|
|
318
|
+
self._grow(index + 1)
|
|
319
|
+
self.emitted[index] += len(text)
|
|
320
|
+
return _frag_kind(self.kinds[index]), text
|
|
321
|
+
|
|
322
|
+
def next_index(self) -> int:
|
|
323
|
+
return len(self.kinds)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _frag_index(path: str, count: int) -> int:
|
|
327
|
+
"""Parse the index out of ``response/fragments/<i>/content`` (-1 = last)."""
|
|
328
|
+
head = path[: -len("/content")]
|
|
329
|
+
try:
|
|
330
|
+
index = int(head.rsplit("/", 1)[-1])
|
|
331
|
+
except ValueError:
|
|
332
|
+
return 0
|
|
333
|
+
if index < 0:
|
|
334
|
+
index += count
|
|
335
|
+
return max(index, 0)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def iter_reply_chunks(lines: Iterable[str]) -> Iterator[tuple[str, str]]:
|
|
339
|
+
"""Yield ``(kind, text)`` increments from DeepSeek's patch-style SSE stream."""
|
|
340
|
+
tracker = _Fragments()
|
|
341
|
+
active: int | None = None
|
|
342
|
+
current = ""
|
|
343
|
+
|
|
344
|
+
def apply(path: str, value: Any) -> Iterator[tuple[str, str]]:
|
|
345
|
+
nonlocal active
|
|
346
|
+
if path == "response/fragments":
|
|
347
|
+
items = value if isinstance(value, list) else [value]
|
|
348
|
+
for offset, fragment in enumerate(items):
|
|
349
|
+
if not isinstance(fragment, dict):
|
|
350
|
+
continue
|
|
351
|
+
index = offset if isinstance(value, list) else tracker.next_index()
|
|
352
|
+
chunk = tracker.full(index, fragment)
|
|
353
|
+
if chunk is not None:
|
|
354
|
+
yield chunk
|
|
355
|
+
return
|
|
356
|
+
if isinstance(value, list) and all(
|
|
357
|
+
isinstance(sub, dict) and "p" in sub for sub in value
|
|
358
|
+
):
|
|
359
|
+
# {"p": "response", "o": "BATCH", "v": [{"p": "fragments", ...}]}
|
|
360
|
+
for sub in value:
|
|
361
|
+
yield from apply(f"{path}/{sub['p']}".lstrip("/"), sub.get("v"))
|
|
362
|
+
return
|
|
363
|
+
if "/fragments/" in path and path.endswith("/content"):
|
|
364
|
+
active = _frag_index(path, tracker.next_index())
|
|
365
|
+
if isinstance(value, str) and value:
|
|
366
|
+
yield tracker.append(active, value)
|
|
367
|
+
return
|
|
368
|
+
if path.endswith("thinking_content") and isinstance(value, str) and value:
|
|
369
|
+
yield "thinking", value
|
|
370
|
+
return
|
|
371
|
+
if path == "response/content" and isinstance(value, str) and value:
|
|
372
|
+
# Same text as the fragment append above, addressed without an index.
|
|
373
|
+
index = active if active is not None else 0
|
|
374
|
+
yield tracker.append(index, value)
|
|
375
|
+
|
|
376
|
+
for line in lines:
|
|
377
|
+
if not line or not line.startswith("data:"):
|
|
378
|
+
continue
|
|
379
|
+
raw = line[len("data:") :].strip()
|
|
380
|
+
if not raw or raw == "[DONE]":
|
|
381
|
+
continue
|
|
382
|
+
try:
|
|
383
|
+
event = json.loads(raw)
|
|
384
|
+
except ValueError:
|
|
385
|
+
continue
|
|
386
|
+
if not isinstance(event, dict):
|
|
387
|
+
continue
|
|
388
|
+
value = event.get("v", event)
|
|
389
|
+
response = value.get("response") if isinstance(value, dict) else None
|
|
390
|
+
if isinstance(response, dict) and "fragments" in response:
|
|
391
|
+
current = "response"
|
|
392
|
+
for index, fragment in enumerate(response.get("fragments") or []):
|
|
393
|
+
chunk = tracker.full(index, fragment)
|
|
394
|
+
if chunk is not None:
|
|
395
|
+
yield chunk
|
|
396
|
+
continue
|
|
397
|
+
path = event.get("p")
|
|
398
|
+
if isinstance(path, str):
|
|
399
|
+
current = path
|
|
400
|
+
yield from apply(path, value)
|
|
401
|
+
continue
|
|
402
|
+
if isinstance(value, list):
|
|
403
|
+
yield from apply(current, value)
|
|
404
|
+
continue
|
|
405
|
+
if isinstance(value, str) and value and active is not None:
|
|
406
|
+
yield tracker.append(active, value)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class _ToolParseError(ProviderError):
|
|
410
|
+
"""A tool block was found but could not be decoded.
|
|
411
|
+
|
|
412
|
+
Separate from ``ProviderError`` so a turn can retry on a syntax problem while
|
|
413
|
+
transport and auth failures still abort immediately.
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# The model is not consistent about the markup it emits, so every shape seen in
|
|
418
|
+
# practice is accepted: the requested JSON block (ASCII or fullwidth bars, with or
|
|
419
|
+
# without the ``/`` and ``END_`` spelling) and DeepSeek's own DSML invoke markup.
|
|
420
|
+
# One alternation is what keeps an unexpected spelling from leaking into the
|
|
421
|
+
# user-visible text, which used to leave raw tags inside tool arguments.
|
|
422
|
+
_TAG = (
|
|
423
|
+
r"<<<\s*/?\s*(?:END_)?\s*TOOL_CALLS\s*>>>"
|
|
424
|
+
r"|<<\s*/?\s*(?:END_)?\s*TOOL_CALLS\s*>>"
|
|
425
|
+
r"|</?\s*(?:END_)?\s*TOOL_CALLS\s*>"
|
|
426
|
+
# A DSML tag carries attributes, so the whole tag up to its own `>` is
|
|
427
|
+
# consumed; stopping at the name left `name="path"` behind in stripped
|
|
428
|
+
# values. The last branch is the same tag truncated at end of string.
|
|
429
|
+
r"|<[^<>]*\bDSML\b[^<>]*>"
|
|
430
|
+
r"|<[^<>]*\bDSML\b[^<>]*\Z"
|
|
431
|
+
)
|
|
432
|
+
_TAG_RE = re.compile(_TAG, re.IGNORECASE)
|
|
433
|
+
_DSML_TAG = re.compile(
|
|
434
|
+
r"<\s*(?P<closing>/)?\s*[||]{0,2}\s*DSML[||]{0,2}\s*"
|
|
435
|
+
r"(?P<kind>[A-Za-z_]+)?\s*(?P<attrs>[^>]*)>",
|
|
436
|
+
re.IGNORECASE,
|
|
437
|
+
)
|
|
438
|
+
_MARKUP_RE = re.compile(_TAG, re.IGNORECASE)
|
|
439
|
+
# Longest accepted opener, so a marker split across chunks is never displayed.
|
|
440
|
+
_TAG_HOLD = 24
|
|
441
|
+
# A bare payload without markers is only a tool call when it carries an argument
|
|
442
|
+
# key; ``{"name": "Alice", "age": 30}`` is an answer that happens to have a name.
|
|
443
|
+
_BARE_CALL_KEYS = ('"arguments"', '"parameters"', '"args"')
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _looks_like_bare_call(text: str) -> bool:
|
|
447
|
+
return (
|
|
448
|
+
text[:1] in {"[", "{"}
|
|
449
|
+
and '"name"' in text
|
|
450
|
+
and any(key in text for key in _BARE_CALL_KEYS)
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class _ToolFilter:
|
|
455
|
+
"""Split streamed text into display text and one trailing tool-call block.
|
|
456
|
+
|
|
457
|
+
Both the requested JSON block and native DSML invokes are recognized. An opener
|
|
458
|
+
can be split across chunks, so a short suffix is held back until it is provably
|
|
459
|
+
not the start of one. Once an opener is seen nothing further is displayed: the
|
|
460
|
+
remainder is markup plus arguments, and showing it is what put raw tags into the
|
|
461
|
+
transcript before.
|
|
462
|
+
"""
|
|
463
|
+
|
|
464
|
+
def __init__(self, enabled: bool):
|
|
465
|
+
self.enabled = enabled
|
|
466
|
+
self.buffer = ""
|
|
467
|
+
self.calls: list[dict[str, Any]] = []
|
|
468
|
+
self._collecting = False
|
|
469
|
+
self._closed = False
|
|
470
|
+
|
|
471
|
+
def feed(self, text: str) -> str:
|
|
472
|
+
if not self.enabled or self._closed:
|
|
473
|
+
return text
|
|
474
|
+
self.buffer += text
|
|
475
|
+
if self._collecting:
|
|
476
|
+
return ""
|
|
477
|
+
match = _TAG_RE.search(self.buffer)
|
|
478
|
+
if match is not None:
|
|
479
|
+
head = self.buffer[: match.start()]
|
|
480
|
+
self.buffer = self.buffer[match.end() :]
|
|
481
|
+
self._collecting = True
|
|
482
|
+
return head
|
|
483
|
+
if self.buffer.lstrip()[:1] in {"[", "{"}:
|
|
484
|
+
# A reply that opens with JSON is either a marker-less tool call or a
|
|
485
|
+
# JSON answer; emitting it now would print the payload before
|
|
486
|
+
# ``finish`` can classify it, so nothing is shown until then.
|
|
487
|
+
return ""
|
|
488
|
+
keep = _TAG_HOLD - 1
|
|
489
|
+
if len(self.buffer) > keep:
|
|
490
|
+
out, self.buffer = self.buffer[:-keep], self.buffer[-keep:]
|
|
491
|
+
return out
|
|
492
|
+
return ""
|
|
493
|
+
|
|
494
|
+
def finish(self) -> str:
|
|
495
|
+
"""Return still-unemitted display text and decode any collected block."""
|
|
496
|
+
if not self.enabled or self._closed:
|
|
497
|
+
return ""
|
|
498
|
+
self._closed = True
|
|
499
|
+
if self._collecting:
|
|
500
|
+
body = self.buffer
|
|
501
|
+
self.buffer = ""
|
|
502
|
+
self.calls = extract_tool_calls(body)
|
|
503
|
+
return ""
|
|
504
|
+
tail, self.buffer = self.buffer, ""
|
|
505
|
+
stripped = tail.strip()
|
|
506
|
+
# Models sometimes drop the markers entirely and answer with bare JSON.
|
|
507
|
+
# Treating that as prose would bury a tool call inside the reply and print
|
|
508
|
+
# the payload at the user, so it is parsed instead; only the leading
|
|
509
|
+
# whitespace is kept so the transcript keeps its spacing. An ordinary JSON
|
|
510
|
+
# answer has no argument key and stays prose, and a payload that looks like
|
|
511
|
+
# a call but does not decode raises so the model is asked to repair it.
|
|
512
|
+
if _looks_like_bare_call(stripped):
|
|
513
|
+
self.calls = extract_tool_calls(stripped)
|
|
514
|
+
return tail[: len(tail) - len(tail.lstrip())]
|
|
515
|
+
return tail
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _dsml_attrs(blob: str) -> dict[str, str]:
|
|
519
|
+
"""Parse ``name="value"`` pairs out of a tag's attribute blob."""
|
|
520
|
+
return {
|
|
521
|
+
key.lower(): value
|
|
522
|
+
for key, value in re.findall(r"([A-Za-z_]+)\s*=\s*\"([^\"]*)\"", blob)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _strip_markup(text: str) -> str:
|
|
527
|
+
"""Remove any residual tag so it can never reach a tool argument."""
|
|
528
|
+
return _MARKUP_RE.sub("", text)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _dsml_value(raw: str, attributes: str) -> Any:
|
|
532
|
+
"""Decode one DSML parameter: only ``string="false"`` marks a JSON literal."""
|
|
533
|
+
cleaned = _strip_markup(raw).strip()
|
|
534
|
+
if 'string="false"' in attributes or "string='false'" in attributes:
|
|
535
|
+
try:
|
|
536
|
+
return json.loads(cleaned)
|
|
537
|
+
except ValueError:
|
|
538
|
+
return cleaned
|
|
539
|
+
return cleaned
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _split_dsml(text: str) -> tuple[str, list[dict[str, Any]]]:
|
|
543
|
+
"""Parse DSML by walking tags, tolerating missing close tags.
|
|
544
|
+
|
|
545
|
+
A regex that required ``</...parameter>`` swallowed the following markup into
|
|
546
|
+
the value whenever the model omitted the close, which is how a path argument
|
|
547
|
+
ended up as ``D:\\MinimalCode\\`` plus tags. Bounding every value by the *next
|
|
548
|
+
tag* instead makes a missing close tag harmless, and each value is stripped of
|
|
549
|
+
residual markup afterwards.
|
|
550
|
+
"""
|
|
551
|
+
calls: list[dict[str, Any]] = []
|
|
552
|
+
plain: list[str] = []
|
|
553
|
+
current: dict[str, Any] | None = None
|
|
554
|
+
param_name: str | None = None
|
|
555
|
+
param_attrs = ""
|
|
556
|
+
value: list[str] = []
|
|
557
|
+
cursor = 0
|
|
558
|
+
|
|
559
|
+
def flush_param() -> None:
|
|
560
|
+
nonlocal param_name, param_attrs, value
|
|
561
|
+
if current is not None and param_name:
|
|
562
|
+
current["arguments"][param_name] = _dsml_value("".join(value), param_attrs)
|
|
563
|
+
param_name, param_attrs, value = None, "", []
|
|
564
|
+
|
|
565
|
+
def flush_call() -> None:
|
|
566
|
+
nonlocal current
|
|
567
|
+
flush_param()
|
|
568
|
+
if current is not None and str(current.get("name") or "").strip():
|
|
569
|
+
calls.append(current)
|
|
570
|
+
current = None
|
|
571
|
+
|
|
572
|
+
for match in _DSML_TAG.finditer(text):
|
|
573
|
+
chunk = text[cursor : match.start()]
|
|
574
|
+
cursor = match.end()
|
|
575
|
+
if param_name is not None:
|
|
576
|
+
value.append(chunk)
|
|
577
|
+
elif current is None:
|
|
578
|
+
plain.append(chunk)
|
|
579
|
+
closing = bool(match.group("closing"))
|
|
580
|
+
kind = (match.group("kind") or "").lower()
|
|
581
|
+
attrs = _dsml_attrs(match.group("attrs") or "")
|
|
582
|
+
if closing:
|
|
583
|
+
if kind == "parameter":
|
|
584
|
+
flush_param()
|
|
585
|
+
elif kind in {"invoke", "calls"}:
|
|
586
|
+
flush_call()
|
|
587
|
+
continue
|
|
588
|
+
if kind == "invoke":
|
|
589
|
+
flush_call()
|
|
590
|
+
current = {"name": attrs.get("name") or "", "arguments": {}}
|
|
591
|
+
elif kind == "parameter" and current is not None:
|
|
592
|
+
flush_param()
|
|
593
|
+
param_name = attrs.get("name") or None
|
|
594
|
+
param_attrs = match.group("attrs") or ""
|
|
595
|
+
elif kind == "calls":
|
|
596
|
+
flush_call()
|
|
597
|
+
tail = text[cursor:]
|
|
598
|
+
if param_name is not None:
|
|
599
|
+
value.append(tail)
|
|
600
|
+
elif current is None and tail:
|
|
601
|
+
plain.append(tail)
|
|
602
|
+
flush_call()
|
|
603
|
+
return "".join(plain), calls
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _escape_inner_quotes(text: str) -> str:
|
|
607
|
+
"""Escape quotes that appear inside a JSON string instead of ending it.
|
|
608
|
+
|
|
609
|
+
The model writes ``"command": "python -c "print(1)""`` often enough that
|
|
610
|
+
rejecting it wastes a whole turn. A quote only closes a string when the next
|
|
611
|
+
significant character is a delimiter, which is exactly the case the model gets
|
|
612
|
+
wrong.
|
|
613
|
+
"""
|
|
614
|
+
out: list[str] = []
|
|
615
|
+
in_string = False
|
|
616
|
+
escaped = False
|
|
617
|
+
length = len(text)
|
|
618
|
+
for index, char in enumerate(text):
|
|
619
|
+
if escaped:
|
|
620
|
+
out.append(char)
|
|
621
|
+
escaped = False
|
|
622
|
+
continue
|
|
623
|
+
if char == "\\":
|
|
624
|
+
out.append(char)
|
|
625
|
+
escaped = True
|
|
626
|
+
continue
|
|
627
|
+
if char == '"':
|
|
628
|
+
if not in_string:
|
|
629
|
+
in_string = True
|
|
630
|
+
out.append(char)
|
|
631
|
+
continue
|
|
632
|
+
look = index + 1
|
|
633
|
+
while look < length and text[look] in " \t\r\n":
|
|
634
|
+
look += 1
|
|
635
|
+
if look >= length or text[look] in ",:}]":
|
|
636
|
+
in_string = False
|
|
637
|
+
out.append(char)
|
|
638
|
+
else:
|
|
639
|
+
out.append('\\"')
|
|
640
|
+
continue
|
|
641
|
+
if in_string and char in "\r\n":
|
|
642
|
+
out.append("\\n")
|
|
643
|
+
continue
|
|
644
|
+
out.append(char)
|
|
645
|
+
return "".join(out)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _strip_trailing_commas(text: str) -> str:
|
|
649
|
+
"""Drop commas before ``}``/``]`` without touching string contents.
|
|
650
|
+
A plain regex that matched a comma before a closing brace also rewrote the
|
|
651
|
+
same sequence inside a string literal, corrupting the argument that the
|
|
652
|
+
repair pass was trying to save.
|
|
653
|
+
"""
|
|
654
|
+
out: list[str] = []
|
|
655
|
+
in_string = False
|
|
656
|
+
escaped = False
|
|
657
|
+
length = len(text)
|
|
658
|
+
for index, char in enumerate(text):
|
|
659
|
+
if in_string:
|
|
660
|
+
out.append(char)
|
|
661
|
+
if escaped:
|
|
662
|
+
escaped = False
|
|
663
|
+
elif char == "\\":
|
|
664
|
+
escaped = True
|
|
665
|
+
elif char == '"':
|
|
666
|
+
in_string = False
|
|
667
|
+
continue
|
|
668
|
+
if char == '"':
|
|
669
|
+
in_string = True
|
|
670
|
+
out.append(char)
|
|
671
|
+
continue
|
|
672
|
+
if char == ",":
|
|
673
|
+
look = index + 1
|
|
674
|
+
while look < length and text[look] in " \t\r\n":
|
|
675
|
+
look += 1
|
|
676
|
+
if look < length and text[look] in "}]":
|
|
677
|
+
continue
|
|
678
|
+
out.append(char)
|
|
679
|
+
return "".join(out)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _loads_lenient(text: str) -> Any:
|
|
683
|
+
"""Parse JSON, repairing the two mistakes the model actually makes."""
|
|
684
|
+
repaired = _escape_inner_quotes(text)
|
|
685
|
+
attempts = [
|
|
686
|
+
text,
|
|
687
|
+
repaired,
|
|
688
|
+
_strip_trailing_commas(text),
|
|
689
|
+
_strip_trailing_commas(repaired),
|
|
690
|
+
]
|
|
691
|
+
last: ValueError | None = None
|
|
692
|
+
for candidate in attempts:
|
|
693
|
+
try:
|
|
694
|
+
return json.loads(candidate)
|
|
695
|
+
except ValueError as exc:
|
|
696
|
+
last = exc
|
|
697
|
+
raise _ToolParseError(
|
|
698
|
+
f"invalid tool-call JSON ({last}); argument strings likely contain "
|
|
699
|
+
'unescaped double quotes, which must be written as \\"'
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _iter_json_arrays(text: str) -> Iterator[str]:
|
|
704
|
+
"""Yield every balanced top-level ``[...]`` slice, ignoring brackets in strings."""
|
|
705
|
+
depth = 0
|
|
706
|
+
start = -1
|
|
707
|
+
in_string = False
|
|
708
|
+
escaped = False
|
|
709
|
+
for index, char in enumerate(text):
|
|
710
|
+
if in_string:
|
|
711
|
+
if escaped:
|
|
712
|
+
escaped = False
|
|
713
|
+
elif char == "\\":
|
|
714
|
+
escaped = True
|
|
715
|
+
elif char == '"':
|
|
716
|
+
in_string = False
|
|
717
|
+
continue
|
|
718
|
+
if char == '"':
|
|
719
|
+
in_string = True
|
|
720
|
+
elif char == "[":
|
|
721
|
+
if depth == 0:
|
|
722
|
+
start = index
|
|
723
|
+
depth += 1
|
|
724
|
+
elif char == "]" and depth:
|
|
725
|
+
depth -= 1
|
|
726
|
+
if depth == 0 and start >= 0:
|
|
727
|
+
yield text[start : index + 1]
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def _entries_from_json(payload: Any) -> list[dict[str, Any]]:
|
|
731
|
+
"""Normalize one decoded JSON payload into ``{name, arguments}`` entries."""
|
|
732
|
+
if isinstance(payload, dict):
|
|
733
|
+
payload = [payload]
|
|
734
|
+
if not isinstance(payload, list):
|
|
735
|
+
return []
|
|
736
|
+
entries: list[dict[str, Any]] = []
|
|
737
|
+
for item in payload:
|
|
738
|
+
if not isinstance(item, dict):
|
|
739
|
+
continue
|
|
740
|
+
name = item.get("name") or item.get("tool")
|
|
741
|
+
arguments = item.get("arguments", item.get("parameters", item.get("args")))
|
|
742
|
+
if not isinstance(name, str):
|
|
743
|
+
# Also accept the OpenAI-shaped {"function": {"name", "arguments"}}.
|
|
744
|
+
function = item.get("function")
|
|
745
|
+
if isinstance(function, dict):
|
|
746
|
+
name = function.get("name")
|
|
747
|
+
arguments = function.get("arguments", arguments)
|
|
748
|
+
if not isinstance(name, str) or not name.strip():
|
|
749
|
+
continue
|
|
750
|
+
if arguments is None:
|
|
751
|
+
arguments = {}
|
|
752
|
+
if isinstance(arguments, str):
|
|
753
|
+
# A string here is a JSON object serialized one level too deep. If it
|
|
754
|
+
# cannot be decoded the call is broken: silently passing the raw text
|
|
755
|
+
# as `{"value": ...}` handed garbage to the tool.
|
|
756
|
+
arguments = _loads_lenient(arguments)
|
|
757
|
+
if not isinstance(arguments, (dict, list)):
|
|
758
|
+
raise _ToolParseError(
|
|
759
|
+
f"tool call {name.strip()!r} has {type(arguments).__name__} "
|
|
760
|
+
"arguments; expected a JSON object"
|
|
761
|
+
)
|
|
762
|
+
entries.append({"name": name.strip(), "arguments": arguments})
|
|
763
|
+
return entries
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def extract_tool_calls(body: str) -> list[dict[str, Any]]:
|
|
767
|
+
"""Decode a collected block, accepting DSML markup or a JSON array.
|
|
768
|
+
|
|
769
|
+
Raises ``_ToolParseError`` when the block clearly tried to call a tool but
|
|
770
|
+
could not be decoded, so the caller can ask the model to regenerate instead of
|
|
771
|
+
passing garbage to a tool.
|
|
772
|
+
"""
|
|
773
|
+
markupped = _DSML_TAG.search(body) is not None
|
|
774
|
+
remaining, dsml_calls = _split_dsml(body)
|
|
775
|
+
text = _strip_markup(remaining)
|
|
776
|
+
calls: list[dict[str, Any]] = list(dsml_calls)
|
|
777
|
+
parse_error: _ToolParseError | None = None
|
|
778
|
+
for candidate in _iter_json_arrays(text):
|
|
779
|
+
try:
|
|
780
|
+
calls.extend(_entries_from_json(_loads_lenient(candidate)))
|
|
781
|
+
except _ToolParseError as exc:
|
|
782
|
+
# Keep the specific reason: "arguments are not JSON" tells the model
|
|
783
|
+
# what to repair, while the generic message below does not.
|
|
784
|
+
parse_error = exc
|
|
785
|
+
continue
|
|
786
|
+
if not calls and parse_error is not None:
|
|
787
|
+
raise parse_error
|
|
788
|
+
looks_like_call = any(
|
|
789
|
+
token in text for token in ("name", "arguments", "invoke", "{")
|
|
790
|
+
)
|
|
791
|
+
# Empty DSML markup is a broken call, not prose: dropping it silently would
|
|
792
|
+
# leave the turn with no tool call and no explanation.
|
|
793
|
+
if not calls and (looks_like_call or markupped):
|
|
794
|
+
raise _ToolParseError(f"tool block decoded to no calls: {body.strip()[:200]!r}")
|
|
795
|
+
return calls
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _parse_dsml(text: str) -> list[dict[str, Any]]:
|
|
799
|
+
"""Parse native ``invoke``/``parameter`` markup into tool-call entries."""
|
|
800
|
+
_plain, calls = _split_dsml(text)
|
|
801
|
+
if not calls and _DSML_TAG.search(text) is not None:
|
|
802
|
+
raise _ToolParseError(f"DSML markup holds no named invoke: {text[:200]!r}")
|
|
803
|
+
return calls
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _parse_tool_block(body: str) -> list[dict[str, Any]]:
|
|
807
|
+
"""Parse the JSON array inside one tool block, tolerating fences and prose."""
|
|
808
|
+
text = body.strip()
|
|
809
|
+
if text.startswith("```"):
|
|
810
|
+
text = text.split("\n", 1)[-1]
|
|
811
|
+
if text.rstrip().endswith("```"):
|
|
812
|
+
text = text.rstrip()[:-3]
|
|
813
|
+
for candidate in _iter_json_arrays(text):
|
|
814
|
+
try:
|
|
815
|
+
entries = _entries_from_json(_loads_lenient(candidate))
|
|
816
|
+
except _ToolParseError:
|
|
817
|
+
continue
|
|
818
|
+
if entries:
|
|
819
|
+
return entries
|
|
820
|
+
if not text:
|
|
821
|
+
return []
|
|
822
|
+
raise _ToolParseError(f"tool block is not a JSON array: {body.strip()[:200]!r}")
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
class DeepSeekWebProtocolMixin:
|
|
826
|
+
"""``deepseek_web`` wire format for the provider client."""
|
|
827
|
+
|
|
828
|
+
# Supplied by OpenAICompatibleProvider.
|
|
829
|
+
config: dict[str, Any]
|
|
830
|
+
_client: Any
|
|
831
|
+
model: str
|
|
832
|
+
thinking: str
|
|
833
|
+
|
|
834
|
+
def _open(self, payload: dict[str, Any]) -> Any:
|
|
835
|
+
raise NotImplementedError
|
|
836
|
+
|
|
837
|
+
def _read_body(self, response: Any) -> bytes:
|
|
838
|
+
raise NotImplementedError
|
|
839
|
+
|
|
840
|
+
# ---- payload ---------------------------------------------------------
|
|
841
|
+
@staticmethod
|
|
842
|
+
def _render_message(message: dict[str, Any]) -> str:
|
|
843
|
+
role = str(message.get("role") or "user")
|
|
844
|
+
content = message.get("content")
|
|
845
|
+
if isinstance(content, list):
|
|
846
|
+
parts = [
|
|
847
|
+
str(block.get("text") or "")
|
|
848
|
+
for block in content
|
|
849
|
+
if isinstance(block, dict)
|
|
850
|
+
]
|
|
851
|
+
text = "\n".join(part for part in parts if part)
|
|
852
|
+
else:
|
|
853
|
+
text = "" if content is None else str(content)
|
|
854
|
+
calls = message.get("tool_calls")
|
|
855
|
+
if isinstance(calls, list) and calls:
|
|
856
|
+
rendered = []
|
|
857
|
+
for call in calls:
|
|
858
|
+
function = call.get("function") or {}
|
|
859
|
+
rendered.append(
|
|
860
|
+
json.dumps(
|
|
861
|
+
{
|
|
862
|
+
"name": function.get("name"),
|
|
863
|
+
"arguments": function.get("arguments"),
|
|
864
|
+
},
|
|
865
|
+
ensure_ascii=False,
|
|
866
|
+
)
|
|
867
|
+
)
|
|
868
|
+
text = (text + "\n" + "\n".join(rendered)).strip()
|
|
869
|
+
label = "tool result" if role == "tool" else role
|
|
870
|
+
return f"## {label}\n{text}".rstrip()
|
|
871
|
+
|
|
872
|
+
def _ds_web_prompt(
|
|
873
|
+
self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None
|
|
874
|
+
) -> str:
|
|
875
|
+
"""Flatten the conversation (and tool schemas) into one prompt."""
|
|
876
|
+
blocks: list[str] = []
|
|
877
|
+
tool_mode = str(self.config.get("tool_mode", "text")).lower()
|
|
878
|
+
if tools and tool_mode == "text":
|
|
879
|
+
schema = json.dumps(tools, ensure_ascii=False)
|
|
880
|
+
blocks.append(f"{TOOL_INSTRUCTIONS}\nAvailable tools:\n{schema}")
|
|
881
|
+
for message in messages:
|
|
882
|
+
if not isinstance(message, dict):
|
|
883
|
+
continue
|
|
884
|
+
if not isinstance(message.get("role"), str):
|
|
885
|
+
continue
|
|
886
|
+
blocks.append(self._render_message(message))
|
|
887
|
+
prompt = "\n\n".join(blocks).strip()
|
|
888
|
+
if not prompt:
|
|
889
|
+
raise ProviderError("deepseek_web: refusing to send an empty prompt")
|
|
890
|
+
return prompt
|
|
891
|
+
|
|
892
|
+
# ---- handshake -------------------------------------------------------
|
|
893
|
+
def _ds_web_url(self, path: str) -> str:
|
|
894
|
+
"""Absolute URL: the pooled client carries no base_url of its own."""
|
|
895
|
+
base = str(
|
|
896
|
+
getattr(self, "base_url", "") or self.config.get("base_url") or ""
|
|
897
|
+
).rstrip("/")
|
|
898
|
+
if not base:
|
|
899
|
+
raise ProviderError("deepseek_web: provider requires base_url")
|
|
900
|
+
return f"{base}{path}"
|
|
901
|
+
|
|
902
|
+
def _ds_web_post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
903
|
+
"""POST a handshake request, retrying TLS resets the same way a turn does.
|
|
904
|
+
|
|
905
|
+
These calls used to go through a bare ``client.post``, so a single
|
|
906
|
+
"[SSL: UNEXPECTED_EOF_WHILE_READING]" aborted the whole turn even though the
|
|
907
|
+
request is idempotent and the endpoint answers fine on the next attempt.
|
|
908
|
+
"""
|
|
909
|
+
url = self._ds_web_url(path)
|
|
910
|
+
try:
|
|
911
|
+
body_bytes = self._post_json(url, payload)
|
|
912
|
+
except ProviderError as exc:
|
|
913
|
+
message = str(exc)
|
|
914
|
+
if "SSL" in message or "UNEXPECTED_EOF" in message.upper():
|
|
915
|
+
raise ProviderError(
|
|
916
|
+
f"deepseek_web: TLS connection to {url} dropped before a reply "
|
|
917
|
+
f"({message}). A local proxy or VPN is the usual cause: set "
|
|
918
|
+
"HTTPS_PROXY (and NO_PROXY=127.0.0.1,localhost) so the request "
|
|
919
|
+
"and the proxy agree, then retry."
|
|
920
|
+
) from exc
|
|
921
|
+
raise
|
|
922
|
+
text = body_bytes.decode("utf-8", errors="replace")
|
|
923
|
+
try:
|
|
924
|
+
body = json.loads(text)
|
|
925
|
+
except ValueError as exc:
|
|
926
|
+
raise ProviderError(
|
|
927
|
+
f"deepseek_web: {path} returned non-JSON (Cloudflare block?): "
|
|
928
|
+
f"{text[:300]}"
|
|
929
|
+
) from exc
|
|
930
|
+
if not isinstance(body, dict):
|
|
931
|
+
raise ProviderError(
|
|
932
|
+
f"deepseek_web: {path} returned {type(body).__name__} instead of "
|
|
933
|
+
f"a JSON object: {text[:300]}"
|
|
934
|
+
)
|
|
935
|
+
if body.get("code") not in (0, None):
|
|
936
|
+
raise ProviderError(
|
|
937
|
+
f"deepseek_web: {path} rejected the request: {body.get('msg') or body}"
|
|
938
|
+
)
|
|
939
|
+
data = body.get("data")
|
|
940
|
+
biz = data.get("biz_data") if isinstance(data, dict) else None
|
|
941
|
+
if not isinstance(biz, dict):
|
|
942
|
+
raise ProviderError(
|
|
943
|
+
f"deepseek_web: {path} returned no biz_data: {text[:300]}"
|
|
944
|
+
)
|
|
945
|
+
return biz
|
|
946
|
+
|
|
947
|
+
def _ds_web_session(self) -> str:
|
|
948
|
+
# The live endpoint returns the id as `biz_data.id`; older shapes nest it
|
|
949
|
+
# under `chat_session`, which is kept as a fallback.
|
|
950
|
+
biz = self._ds_web_post(SESSION_PATH, {"character_id": None})
|
|
951
|
+
session = biz.get("chat_session")
|
|
952
|
+
session_id = biz.get("id")
|
|
953
|
+
if not session_id and isinstance(session, dict):
|
|
954
|
+
session_id = session.get("id")
|
|
955
|
+
if not session_id:
|
|
956
|
+
raise ProviderError(f"deepseek_web: session create returned {biz!r}")
|
|
957
|
+
return str(session_id)
|
|
958
|
+
|
|
959
|
+
def _ds_web_pow(self) -> str:
|
|
960
|
+
wasm = _ensure_wasm(self.config.get("pow_wasm_path"))
|
|
961
|
+
biz = self._ds_web_post(POW_PATH, {"target_path": CHAT_PATH})
|
|
962
|
+
challenge = biz.get("challenge") or {}
|
|
963
|
+
return _pow_solver(wasm).header(challenge)
|
|
964
|
+
|
|
965
|
+
def _ds_web_apply_cookie(self) -> None:
|
|
966
|
+
"""Attach the session cookie from config or the environment.
|
|
967
|
+
|
|
968
|
+
The Bearer token is the credential; the cookie only matters behind a WAF
|
|
969
|
+
that inspects it, so a missing cookie is not an error. It is not a preset
|
|
970
|
+
header on purpose: a ``${...}`` placeholder in a builtin preset would make
|
|
971
|
+
an unconfigured install fail to build its endpoint.
|
|
972
|
+
"""
|
|
973
|
+
cookie = self.config.get("cookie")
|
|
974
|
+
if not cookie:
|
|
975
|
+
env_name = str(self.config.get("cookie_env") or "DEEPSEEK_WEB_COOKIE")
|
|
976
|
+
cookie = os.environ.get(env_name, "")
|
|
977
|
+
if cookie:
|
|
978
|
+
self._client.headers["Cookie"] = str(cookie)
|
|
979
|
+
|
|
980
|
+
# ---- one turn --------------------------------------------------------
|
|
981
|
+
def _ds_web_endpoint_checked(self) -> str:
|
|
982
|
+
"""Return the completion URL after proving the request really is "web".
|
|
983
|
+
|
|
984
|
+
``protocol`` selects this mixin, but a provider entry that pairs a web
|
|
985
|
+
protocol with an API ``base_url`` would otherwise send a web-shaped body to
|
|
986
|
+
an API host and fail obscurely. The host is verified against ``base_url``
|
|
987
|
+
and the resolved URL is reported through ``notify``, so the transcript
|
|
988
|
+
records where the turn actually went.
|
|
989
|
+
"""
|
|
990
|
+
from urllib.parse import urlsplit
|
|
991
|
+
|
|
992
|
+
if getattr(self, "protocol", "") != "deepseek_web":
|
|
993
|
+
raise ProviderError(
|
|
994
|
+
"deepseek_web: turn dispatched for protocol "
|
|
995
|
+
f"{getattr(self, 'protocol', None)!r}; expected 'deepseek_web'"
|
|
996
|
+
)
|
|
997
|
+
url = self._endpoint()
|
|
998
|
+
expected = urlsplit(str(self.config.get("base_url") or "")).hostname
|
|
999
|
+
actual = urlsplit(url).hostname
|
|
1000
|
+
if not actual or actual != expected:
|
|
1001
|
+
raise ProviderError(
|
|
1002
|
+
f"deepseek_web: endpoint host {actual!r} does not match base_url "
|
|
1003
|
+
f"{expected!r}; refusing to send a web payload elsewhere"
|
|
1004
|
+
)
|
|
1005
|
+
if not url.endswith(CHAT_PATH):
|
|
1006
|
+
raise ProviderError(
|
|
1007
|
+
f"deepseek_web: endpoint {url!r} does not end with {CHAT_PATH}"
|
|
1008
|
+
)
|
|
1009
|
+
notify = getattr(self, "notify", None)
|
|
1010
|
+
if callable(notify):
|
|
1011
|
+
notify(f"[provider] deepseek_web POST {url}")
|
|
1012
|
+
return url
|
|
1013
|
+
|
|
1014
|
+
def _ds_web_once(
|
|
1015
|
+
self,
|
|
1016
|
+
prompt: str,
|
|
1017
|
+
tools: list[dict[str, Any]] | None,
|
|
1018
|
+
*,
|
|
1019
|
+
on_text: Callable[[str], None] | None,
|
|
1020
|
+
on_thinking: Callable[[str], None] | None,
|
|
1021
|
+
) -> ProviderReply:
|
|
1022
|
+
endpoint = self._ds_web_endpoint_checked()
|
|
1023
|
+
# The Bearer token authenticates every call; the optional cookie is
|
|
1024
|
+
# attached first so a WAF that inspects it also sees the handshakes.
|
|
1025
|
+
self._ds_web_apply_cookie()
|
|
1026
|
+
thinking = self.thinking.strip().lower() not in {"", "none", "off"}
|
|
1027
|
+
payload: dict[str, Any] = {
|
|
1028
|
+
"chat_session_id": self._ds_web_session(),
|
|
1029
|
+
"parent_message_id": None,
|
|
1030
|
+
"prompt": prompt,
|
|
1031
|
+
"ref_file_ids": [],
|
|
1032
|
+
"thinking_enabled": thinking,
|
|
1033
|
+
"search_enabled": bool(self.config.get("web_search", False)),
|
|
1034
|
+
"action": None,
|
|
1035
|
+
"preempt": False,
|
|
1036
|
+
# Without these two the endpoint answers with one non-SSE JSON body,
|
|
1037
|
+
# which the stream parser then has to reject.
|
|
1038
|
+
"stream": True,
|
|
1039
|
+
"client_stream_id": _client_stream_id(),
|
|
1040
|
+
}
|
|
1041
|
+
# `-m`/`/model` has to win over the preset's `model_type`, otherwise a
|
|
1042
|
+
# chosen web model was silently replaced by the preset's `default`.
|
|
1043
|
+
model_type = str(self.model or "").strip()
|
|
1044
|
+
configured_model = str(self.config.get("model") or "").strip()
|
|
1045
|
+
if self.config.get("model_type") and model_type in {"", configured_model}:
|
|
1046
|
+
model_type = str(self.config["model_type"]).strip()
|
|
1047
|
+
# The web UI's default model is the fast one; an explicit type is only
|
|
1048
|
+
# honoured when the account's model_configs enable it.
|
|
1049
|
+
if model_type and model_type not in {"auto", "web"}:
|
|
1050
|
+
payload["model_type"] = model_type
|
|
1051
|
+
|
|
1052
|
+
# ``_open`` builds the request from the client headers, so the ticket has
|
|
1053
|
+
# to be installed there; challenges are single-use and short lived, which
|
|
1054
|
+
# is why this runs immediately before every request. It is removed again
|
|
1055
|
+
# right after: a ticket minted for the completion path must not ride along
|
|
1056
|
+
# on the next turn's handshake.
|
|
1057
|
+
self._client.headers["x-ds-pow-response"] = self._ds_web_pow()
|
|
1058
|
+
try:
|
|
1059
|
+
response = self._open(payload)
|
|
1060
|
+
finally:
|
|
1061
|
+
self._client.headers.pop("x-ds-pow-response", None)
|
|
1062
|
+
kind = response.headers.get("content-type", "")
|
|
1063
|
+
if "text/event-stream" not in kind:
|
|
1064
|
+
status = response.status_code
|
|
1065
|
+
try:
|
|
1066
|
+
body = self._read_body(response).decode("utf-8", errors="replace")
|
|
1067
|
+
finally:
|
|
1068
|
+
response.close()
|
|
1069
|
+
raise ProviderError(
|
|
1070
|
+
f"deepseek_web: {endpoint} returned {kind or 'unknown'} "
|
|
1071
|
+
f"({status}) instead of text/event-stream: {body[:300]}"
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
enabled = str(self.config.get("tool_mode", "text")).lower() == "text"
|
|
1075
|
+
tool_filter = _ToolFilter(enabled and bool(tools))
|
|
1076
|
+
parts: list[str] = []
|
|
1077
|
+
usage: dict[str, int] = {}
|
|
1078
|
+
try:
|
|
1079
|
+
for chunk_kind, text in iter_reply_chunks(self._sse_lines(response)):
|
|
1080
|
+
if chunk_kind == "thinking":
|
|
1081
|
+
if on_thinking is not None:
|
|
1082
|
+
on_thinking(text)
|
|
1083
|
+
continue
|
|
1084
|
+
if chunk_kind != "answer":
|
|
1085
|
+
continue
|
|
1086
|
+
visible = tool_filter.feed(text)
|
|
1087
|
+
if visible:
|
|
1088
|
+
parts.append(visible)
|
|
1089
|
+
if on_text is not None:
|
|
1090
|
+
on_text(visible)
|
|
1091
|
+
tail = tool_filter.finish()
|
|
1092
|
+
if tail:
|
|
1093
|
+
parts.append(tail)
|
|
1094
|
+
if on_text is not None:
|
|
1095
|
+
on_text(tail)
|
|
1096
|
+
finally:
|
|
1097
|
+
response.close()
|
|
1098
|
+
|
|
1099
|
+
calls: list[ToolCall] = []
|
|
1100
|
+
for index, entry in enumerate(tool_filter.calls):
|
|
1101
|
+
name = entry.get("name")
|
|
1102
|
+
if not isinstance(name, str) or not name.strip():
|
|
1103
|
+
continue
|
|
1104
|
+
# `_entries_from_json` only produces objects/arrays, so anything else
|
|
1105
|
+
# here is a defensive default rather than a silent coercion of raw
|
|
1106
|
+
# text into a `{"value": ...}` argument.
|
|
1107
|
+
arguments = entry.get("arguments")
|
|
1108
|
+
if not isinstance(arguments, (dict, list)):
|
|
1109
|
+
arguments = {}
|
|
1110
|
+
rendered = json.dumps(arguments, ensure_ascii=False)
|
|
1111
|
+
calls.append(ToolCall(id=f"dsweb_{index}", name=name, arguments=rendered))
|
|
1112
|
+
|
|
1113
|
+
return ProviderReply(
|
|
1114
|
+
content="".join(parts).strip(),
|
|
1115
|
+
tool_calls=calls,
|
|
1116
|
+
finish_reason="tool_calls" if calls else "stop",
|
|
1117
|
+
usage=usage,
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
def _ds_web_turn(
|
|
1121
|
+
self,
|
|
1122
|
+
messages: list[dict[str, Any]],
|
|
1123
|
+
tools: list[dict[str, Any]] | None,
|
|
1124
|
+
*,
|
|
1125
|
+
on_text: Callable[[str], None] | None,
|
|
1126
|
+
on_thinking: Callable[[str], None] | None,
|
|
1127
|
+
) -> ProviderReply:
|
|
1128
|
+
"""Run one turn, re-asking once when the tool block cannot be parsed.
|
|
1129
|
+
|
|
1130
|
+
A malformed block used to end the turn, and raw markup could still reach a
|
|
1131
|
+
tool as an argument value. Now the failure is reported back to the model
|
|
1132
|
+
with its exact reason, since the model is the only party that can fix its
|
|
1133
|
+
own syntax.
|
|
1134
|
+
"""
|
|
1135
|
+
prompt = self._ds_web_prompt(messages, tools)
|
|
1136
|
+
try:
|
|
1137
|
+
retries = int(self.config.get("tool_parse_retries", 1) or 0)
|
|
1138
|
+
except (TypeError, ValueError):
|
|
1139
|
+
retries = 1
|
|
1140
|
+
last_error: _ToolParseError | None = None
|
|
1141
|
+
for attempt in range(max(0, retries) + 1):
|
|
1142
|
+
ask = (
|
|
1143
|
+
prompt
|
|
1144
|
+
if attempt == 0
|
|
1145
|
+
else prompt + _TOOL_CORRECTION.format(error=last_error)
|
|
1146
|
+
)
|
|
1147
|
+
try:
|
|
1148
|
+
return self._ds_web_once(
|
|
1149
|
+
ask, tools, on_text=on_text, on_thinking=on_thinking
|
|
1150
|
+
)
|
|
1151
|
+
except _ToolParseError as exc:
|
|
1152
|
+
last_error = exc
|
|
1153
|
+
notify = getattr(self, "notify", None)
|
|
1154
|
+
if callable(notify):
|
|
1155
|
+
notify(f"[retry] tool call rejected: {exc}")
|
|
1156
|
+
if last_error is not None:
|
|
1157
|
+
raise last_error
|
|
1158
|
+
raise ProviderError("deepseek_web: turn produced no reply")
|