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.
Files changed (58) hide show
  1. minimalcode/__init__.py +3 -0
  2. minimalcode/__main__.py +3 -0
  3. minimalcode/agent.py +1084 -0
  4. minimalcode/cli.py +1520 -0
  5. minimalcode/clipboard.py +212 -0
  6. minimalcode/config.py +1065 -0
  7. minimalcode/defaults.json +491 -0
  8. minimalcode/errors.py +31 -0
  9. minimalcode/highlight/__init__.py +47 -0
  10. minimalcode/highlight/code.py +261 -0
  11. minimalcode/highlight/color.py +58 -0
  12. minimalcode/highlight/markdown.py +710 -0
  13. minimalcode/highlight/palette.py +73 -0
  14. minimalcode/highlight/sanitize.py +175 -0
  15. minimalcode/highlight/thinking.py +71 -0
  16. minimalcode/httpclient.py +278 -0
  17. minimalcode/importer.py +253 -0
  18. minimalcode/prompt.py +195 -0
  19. minimalcode/provider/__init__.py +28 -0
  20. minimalcode/provider/anthropic.py +805 -0
  21. minimalcode/provider/anthropic_env.py +161 -0
  22. minimalcode/provider/chat.py +394 -0
  23. minimalcode/provider/client.py +243 -0
  24. minimalcode/provider/common.py +176 -0
  25. minimalcode/provider/deepseek_web.py +1158 -0
  26. minimalcode/provider/factory.py +63 -0
  27. minimalcode/provider/responses.py +692 -0
  28. minimalcode/provider/sha3_wasm_bg.wasm +0 -0
  29. minimalcode/provider/transport.py +241 -0
  30. minimalcode/provider/types.py +54 -0
  31. minimalcode/sandbox/__init__.py +64 -0
  32. minimalcode/sandbox/audit.py +120 -0
  33. minimalcode/sandbox/backends.py +343 -0
  34. minimalcode/sandbox/execution.py +435 -0
  35. minimalcode/sandbox/network.py +793 -0
  36. minimalcode/sandbox/policy.py +253 -0
  37. minimalcode/sandbox/runner.py +347 -0
  38. minimalcode/sandbox/sensitive.py +286 -0
  39. minimalcode/session.py +465 -0
  40. minimalcode/shell.py +333 -0
  41. minimalcode/subagent.py +120 -0
  42. minimalcode/tools/__init__.py +46 -0
  43. minimalcode/tools/files.py +1139 -0
  44. minimalcode/tools/process.py +164 -0
  45. minimalcode/tools/remote.py +74 -0
  46. minimalcode/tools/runtime.py +219 -0
  47. minimalcode/tools/schemas.py +418 -0
  48. minimalcode/tools/search.py +521 -0
  49. minimalcode/tools/search_tools.py +583 -0
  50. minimalcode/tools/validation.py +99 -0
  51. minimalcode/vision.py +231 -0
  52. minimalcode/web.py +238 -0
  53. minimalcode-1.5.2.dist-info/METADATA +646 -0
  54. minimalcode-1.5.2.dist-info/RECORD +58 -0
  55. minimalcode-1.5.2.dist-info/WHEEL +5 -0
  56. minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
  57. minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
  58. 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")