ds4: cache-cleanup safety, in-flight gate, default-model downloader, low-quant tool parse

- ds4 kv janitor: a checkpoint is deleted only when ALL hold — untouched by
  max(mtime, atime) for the age (so a checkpoint ds4 merely READS, which bumps
  atime not mtime, is spared); not currently open (fd/mmap) by a ds4-server;
  and ds4 is not serving any request. New in-flight counter on Ds4Backend
  (any_request_active) gates the sweep.
- settings: "Download a default DeepSeek V4 model" — select + button backed by
  new /admin/api/ds4/default-models catalog (q2-imatrix / q2-q4 / q4 / mtp from
  antirez/deepseek-v4-gguf). Reuses the normal downloader, which flattens the
  gguf into the cache and surfaces it in the model list; live progress.
- parser: rescue the degraded plaintext <tool>name arg: value</tool> form that
  heavy quants (ds4 q2-imatrix) emit when they can't reproduce DSML. Scoped to
  DeepSeekParser only (never the shared ToolCallParser, so other families are
  untouched), requires a DECLARED tool name, plaintext-only inner, and the
  block(s) to be the message's trailing action — so a <tool> example inside a
  prose reply is not misread as a call.
- settings: corrected ds4 perf note (i-quants/Q2_K fail CUDA prefill; use Q4_K+).
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent 681b19e0
...@@ -1197,6 +1197,49 @@ async def api_list_downloads(username: str = Depends(require_admin)): ...@@ -1197,6 +1197,49 @@ async def api_list_downloads(username: str = Depends(require_admin)):
return list(_download_status.values()) return list(_download_status.values())
# Catalog of the official DeepSeek V4 GGUF weights ds4 ships (matches antirez's
# download_model.sh). Picking one and downloading it reuses the normal model
# downloader (/admin/api/model-download with file_pattern = the exact .gguf), which
# flattens the file into the gguf cache and surfaces it in the model list.
_DS4_DEFAULT_REPO = "antirez/deepseek-v4-gguf"
_DS4_DEFAULT_MODELS = [
{"key": "q2-imatrix", "label": "Flash q2-imatrix (~81 GB) — recommended for 96/128 GB RAM",
"file": "DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf",
"size_gb": 81},
{"key": "q2-q4-imatrix", "label": "Flash q2-q4-imatrix (~98 GB) — higher quality, last layers Q4",
"file": "DeepSeek-V4-Flash-Layers37-42Q4KExperts-OtherExpertLayersIQ2XXSGateUp-Q2KDown-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix-fixed.gguf",
"size_gb": 98},
{"key": "q4-imatrix", "label": "Flash q4-imatrix (~153 GB) — best quality, 256 GB+ RAM",
"file": "DeepSeek-V4-Flash-Q4KExperts-F16HC-F16Compressor-F16Indexer-Q8Attn-Q8Shared-Q8Out-chat-v2-imatrix.gguf",
"size_gb": 153},
{"key": "mtp", "label": "MTP speculative-decoding component (~3.5 GB) — optional, run ds4 with --mtp",
"file": "DeepSeek-V4-Flash-MTP-Q4K-Q8_0-F32.gguf",
"size_gb": 4},
]
@router.get("/admin/api/ds4/default-models", summary="List downloadable ds4 default models")
async def api_ds4_default_models(username: str = Depends(require_admin)):
"""Catalog of official DeepSeek V4 GGUF variants ds4 can serve. The UI offers
these in a select; downloading one goes through /admin/api/model-download."""
gguf_dir = ""
try:
if config_manager is not None and config_manager.config is not None:
gguf_dir = (config_manager.config.models.gguf_cache_dir or "").strip()
except Exception:
gguf_dir = ""
out = []
for m in _DS4_DEFAULT_MODELS:
present = False
if gguf_dir:
try:
present = os.path.exists(os.path.join(os.path.expanduser(gguf_dir), m["file"]))
except Exception:
present = False
out.append({**m, "repo": _DS4_DEFAULT_REPO, "present": present})
return {"repo": _DS4_DEFAULT_REPO, "gguf_cache_dir": gguf_dir, "models": out}
def _cancel_download_session(session_id: str) -> bool: def _cancel_download_session(session_id: str) -> bool:
"""Cancel an active download by flagging the session and terminating its worker """Cancel an active download by flagging the session and terminating its worker
process. Returns False if there is no such download session. process. Returns False if there is no such download session.
......
...@@ -390,6 +390,15 @@ ...@@ -390,6 +390,15 @@
</label> </label>
</div> </div>
<div id="ds4-fields" style="display:none"> <div id="ds4-fields" style="display:none">
<div class="form-row" style="background:var(--bg-2,#1a1a1a);border:1px solid var(--border,#333);border-radius:8px;padding:.7rem .8rem">
<label class="form-label" style="margin-bottom:.3rem">⬇ Download a default DeepSeek V4 model</label>
<span class="form-hint" style="margin-bottom:.5rem">Fetches an official GGUF from <code>antirez/deepseek-v4-gguf</code> straight into the gguf cache, where it appears in the model list to configure. Large (tens to ~150 GB) — runs in the background.</span>
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap">
<select id="s-ds4-dl-select" class="form-input" style="flex:1;min-width:280px"></select>
<button type="button" id="s-ds4-dl-btn" class="btn btn-secondary" onclick="ds4DownloadDefault()">Download</button>
</div>
<div id="s-ds4-dl-status" class="form-hint" style="margin-top:.45rem;display:none"></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;align-items:start"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem;align-items:start">
<div class="form-row" style="margin:0"> <div class="form-row" style="margin:0">
<label class="form-label">Model id / alias</label> <label class="form-label">Model id / alias</label>
...@@ -496,7 +505,7 @@ ...@@ -496,7 +505,7 @@
<input type="checkbox" id="s-ds4-kv-cleanup" onchange="toggleDs4KvCleanup()"> <input type="checkbox" id="s-ds4-kv-cleanup" onchange="toggleDs4KvCleanup()">
<span style="font-size:13px;font-weight:500">Auto-clean the on-disk KV cache</span> <span style="font-size:13px;font-weight:500">Auto-clean the on-disk KV cache</span>
</label> </label>
<span class="form-hint">ds4 writes prompt KV checkpoints to its <code>--kv-disk-dir</code> (<code>&lt;offload&gt;/ds4-kv</code>) to reuse prefixes across requests; abandoned sessions accumulate large files that never self-prune. When on, a janitor deletes entries whose newest file is older than the age below. Active (recently-reused) checkpoints are spared.</span> <span class="form-hint">ds4 writes prompt KV checkpoints to its <code>--kv-disk-dir</code> (<code>&lt;offload&gt;/ds4-kv</code>) to reuse prefixes across requests; abandoned sessions accumulate large files that never self-prune. When on, a janitor deletes a checkpoint only when BOTH: its newest file is older than the age below, AND no running <code>ds4-server</code> currently has it open (fd or mmap). Files ds4 is actively using are always kept, regardless of age.</span>
</div> </div>
<div id="ds4-kv-cleanup-fields" style="display:none"> <div id="ds4-kv-cleanup-fields" style="display:none">
<div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem"> <div class="form-row" style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem">
...@@ -622,6 +631,60 @@ function toggleDs4KvCleanup(){ ...@@ -622,6 +631,60 @@ function toggleDs4KvCleanup(){
document.getElementById('ds4-kv-cleanup-fields').style.display = document.getElementById('ds4-kv-cleanup-fields').style.display =
document.getElementById('s-ds4-kv-cleanup').checked ? 'block' : 'none'; document.getElementById('s-ds4-kv-cleanup').checked ? 'block' : 'none';
} }
let _ds4DefaultModels = [];
async function loadDs4DefaultModels(){
const sel = document.getElementById('s-ds4-dl-select');
if(!sel) return;
try{
const d = await fetch(ROOT_PATH + '/admin/api/ds4/default-models').then(r=>r.json());
_ds4DefaultModels = d.models || [];
sel.innerHTML = _ds4DefaultModels.map(m =>
`<option value="${m.file}">${m.label}${m.present ? ' ✓ downloaded' : ''}</option>`).join('');
}catch(e){
sel.innerHTML = '<option value="">(failed to load catalog)</option>';
}
}
async function ds4DownloadDefault(){
const sel = document.getElementById('s-ds4-dl-select');
const btn = document.getElementById('s-ds4-dl-btn');
const statusEl = document.getElementById('s-ds4-dl-status');
const file = sel.value;
const meta = _ds4DefaultModels.find(m => m.file === file);
if(!file || !meta){ return; }
if(meta.present && !confirm('This model is already in the gguf cache. Download again?')) return;
btn.disabled = true;
statusEl.style.display = 'block';
statusEl.textContent = 'Starting download…';
try{
const r = await fetch(ROOT_PATH + '/admin/api/model-download', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({model_id: meta.repo, file_pattern: file}),
});
if(!r.ok) throw new Error('HTTP '+r.status);
const {session_id} = await r.json();
const es = new EventSource(ROOT_PATH + '/admin/api/download-stream/' + session_id);
es.onmessage = (ev) => {
let m; try{ m = JSON.parse(ev.data); }catch(_){ return; }
if(m.type === 'progress'){
const pct = (m.percent||0).toFixed(1);
const rate = m.rate ? ' · ' + (m.rate/1048576).toFixed(1) + ' MB/s' : '';
statusEl.textContent = `Downloading ${meta.label.split(' (')[0]}: ${pct}%${rate}`;
}else if(m.type === 'done'){
statusEl.textContent = '✅ Downloaded — now available in the model list to configure.';
es.close(); btn.disabled = false; loadDs4DefaultModels();
}else if(m.type === 'error'){
statusEl.textContent = '⚠ Download failed: ' + (m.message||'unknown error');
es.close(); btn.disabled = false;
}
};
es.onerror = () => { es.close(); btn.disabled = false; };
}catch(e){
statusEl.textContent = '⚠ Could not start download: ' + e.message;
btn.disabled = false;
}
}
function toggleHttps(){ function toggleHttps(){
document.getElementById('https-fields').style.display = document.getElementById('https-fields').style.display =
document.getElementById('s-https').checked ? 'block' : 'none'; document.getElementById('s-https').checked ? 'block' : 'none';
...@@ -783,6 +846,7 @@ async function loadSettings(){ ...@@ -783,6 +846,7 @@ async function loadSettings(){
document.getElementById('s-ds4-kv-interval').value = ds4.kv_cache_cleanup_interval_minutes ?? 360; document.getElementById('s-ds4-kv-interval').value = ds4.kv_cache_cleanup_interval_minutes ?? 360;
toggleDs4KvCleanup(); toggleDs4KvCleanup();
toggleDs4Fields(); toggleDs4Fields();
loadDs4DefaultModels();
}catch(e){ showAlert('error','Failed to load settings: '+e.message); } }catch(e){ showAlert('error','Failed to load settings: '+e.message); }
} }
......
...@@ -7,24 +7,58 @@ accumulate on disk indefinitely (a streamed MoE's KV files are large). ...@@ -7,24 +7,58 @@ accumulate on disk indefinitely (a streamed MoE's KV files are large).
This background janitor age-prunes that directory: every This background janitor age-prunes that directory: every
``interval_minutes`` it deletes top-level entries whose most-recent mtime is older ``interval_minutes`` it deletes top-level entries whose most-recent mtime is older
than ``max_age_hours``. Age is taken from the *newest* file in each entry, so an than ``max_age_hours``. Age is taken from the *newest* file in each entry, so a
actively-reused checkpoint (touched recently) is spared while stale ones are recently-rewritten checkpoint is spared.
reclaimed.
A checkpoint is removed ONLY when BOTH hold:
It reuses the proven sweep primitives from ``codai.models.tmp_janitor`` and keeps 1. ds4 has not USED it for ``max_age_hours`` — age is measured by the newest of
its own module-level state (a separate, independently-configured janitor). Started mtime AND **atime**, so a checkpoint ds4 merely *reads* (a cache hit, which
once from ``codai.main`` in the engine process that owns the ds4 worker. bumps atime but not mtime) counts as recently used and is kept; AND
2. ds4 is not currently using it — no live ``ds4-server`` process holds it open
via a file descriptor or an mmap (a belt-and-suspenders guard against deleting
a checkpoint mid-load, which would corrupt a running generation).
Any file ds4 has read/written recently, or has open right now, is always kept.
Caveat: atime relies on the filesystem not being mounted ``noatime``; with the
common ``relatime`` default atime lags by up to ~24 h, which is negligible against
the multi-hour/day cleanup threshold.
It reuses the age/size primitives from ``codai.models.tmp_janitor`` and keeps its
own module-level state (a separate, independently-configured janitor). Started once
from ``codai.main`` in the engine process that owns the ds4 worker.
""" """
import os import os
import shutil
import threading import threading
import time import time
import logging import logging
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, Set
from codai.models.tmp_janitor import _sweep, _FORBIDDEN from codai.models.tmp_janitor import _dir_size, _FORBIDDEN
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
def _entry_newest_activity(path: str) -> float:
"""Most-recent activity time under ``path`` = max(mtime, atime) of the entry,
or of the newest file in a directory tree. Using atime as well as mtime means a
checkpoint ds4 only *reads* (a cache hit — bumps atime, not mtime) still counts
as recently used and is spared."""
def _t(p: str) -> float:
try:
st = os.lstat(p)
return max(st.st_mtime, st.st_atime)
except OSError:
return 0.0
newest = _t(path)
if os.path.isdir(path) and not os.path.islink(path):
for root, _dirs, files in os.walk(path):
for name in files:
m = _t(os.path.join(root, name))
if m > newest:
newest = m
return newest
_state_lock = threading.Lock() _state_lock = threading.Lock()
_state: Dict[str, Any] = { _state: Dict[str, Any] = {
"enabled": False, "enabled": False,
...@@ -47,13 +81,101 @@ def get_status() -> Dict[str, Any]: ...@@ -47,13 +81,101 @@ def get_status() -> Dict[str, Any]:
return dict(_state) return dict(_state)
def _ds4_request_active() -> bool:
"""True while ds4 is serving a request. Best-effort: on any error assume idle
(False) so the janitor still runs — the per-file in-use/age guards keep deletes
safe regardless of this coarse gate."""
try:
from codai.backends.ds4 import Ds4Backend
return Ds4Backend.any_request_active()
except Exception:
return False
def _ds4_open_paths(root: str) -> Set[str]:
"""Real paths under ``root`` currently held open — via an fd OR an mmap — by
any live ``ds4-server`` process. Linux ``/proc`` scan, best-effort: on any
error it returns what it has so the janitor errs toward KEEPING files."""
root_pref = root.rstrip("/") + "/"
open_paths: Set[str] = set()
try:
pids = [d for d in os.listdir("/proc") if d.isdigit()]
except OSError:
return open_paths
for pid in pids:
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
if b"ds4-server" not in f.read():
continue
except OSError:
continue
# Open file descriptors.
fddir = f"/proc/{pid}/fd"
try:
for fd in os.listdir(fddir):
try:
tgt = os.readlink(os.path.join(fddir, fd))
except OSError:
continue
if tgt.startswith(root_pref):
open_paths.add(os.path.realpath(tgt))
except OSError:
pass
# Memory-mapped files (ds4 mmaps KV checkpoints; these don't show as fds).
try:
with open(f"/proc/{pid}/maps", "r") as f:
for line in f:
parts = line.rstrip("\n").split(None, 5)
if len(parts) == 6 and parts[5].startswith(root_pref):
open_paths.add(os.path.realpath(parts[5]))
except OSError:
pass
return open_paths
def _sweep(kv_dir: str, max_age_seconds: float) -> tuple[int, int]:
"""Remove top-level entries older than the cutoff, but NEVER one ds4 currently
has open (fd/mmap). Returns (removed, freed_bytes)."""
now = time.time()
removed = 0
freed = 0
in_use = _ds4_open_paths(kv_dir)
try:
entries = os.listdir(kv_dir)
except OSError as e:
_log.debug("ds4 kv janitor: cannot list %s: %s", kv_dir, e)
return (0, 0)
for name in entries:
path = os.path.join(kv_dir, name)
rp = os.path.realpath(path)
# Skip if this entry (a file, or any file under a dir entry) is in use.
if rp in in_use or any(p == rp or p.startswith(rp + "/") for p in in_use):
continue
try:
if now - _entry_newest_activity(path) < max_age_seconds:
continue
size = _dir_size(path)
if os.path.isdir(path) and not os.path.islink(path):
shutil.rmtree(path, ignore_errors=True)
else:
os.remove(path)
removed += 1
freed += size
except OSError as e:
_log.debug("ds4 kv janitor: could not remove %s: %s", path, e)
return (removed, freed)
def _run(kv_dir: str, max_age_hours: float, interval_minutes: float) -> None: def _run(kv_dir: str, max_age_hours: float, interval_minutes: float) -> None:
max_age_seconds = max(0.0, max_age_hours) * 3600.0 max_age_seconds = max(0.0, max_age_hours) * 3600.0
interval = max(60.0, interval_minutes * 60.0) interval = max(60.0, interval_minutes * 60.0)
while True: while True:
try: try:
# The dir may not exist until ds4-server first starts — tolerate that. # The dir may not exist until ds4-server first starts — tolerate that.
if os.path.isdir(kv_dir): # Skip the whole sweep while ds4 is serving a request: deleting/scanning
# KV files mid-inference would contend for disk with the live expert
# streaming and KV load/store. Wait for the next interval instead.
if os.path.isdir(kv_dir) and not _ds4_request_active():
removed, freed = _sweep(kv_dir, max_age_seconds) removed, freed = _sweep(kv_dir, max_age_seconds)
with _state_lock: with _state_lock:
_state["last_run_ts"] = time.time() _state["last_run_ts"] = time.time()
......
...@@ -28,6 +28,28 @@ from codai.backends.base import ModelBackend ...@@ -28,6 +28,28 @@ from codai.backends.base import ModelBackend
class Ds4Backend(ModelBackend): class Ds4Backend(ModelBackend):
"""Proxy backend that routes generation to a managed ds4-server.""" """Proxy backend that routes generation to a managed ds4-server."""
# Process-wide count of in-flight ds4 requests (across all backend instances).
# The KV-cache janitor checks this so it never sweeps mid-request — see
# codai.api.ds4_kv_janitor. Incremented/decremented around every generate path.
_inflight = 0
_inflight_lock = threading.Lock()
@classmethod
def _enter_request(cls):
with cls._inflight_lock:
cls._inflight += 1
@classmethod
def _exit_request(cls):
with cls._inflight_lock:
cls._inflight = max(0, cls._inflight - 1)
@classmethod
def any_request_active(cls) -> bool:
"""True while any ds4 request is being served (prefill/decode in flight)."""
with cls._inflight_lock:
return cls._inflight > 0
def __init__(self, cfg=None): def __init__(self, cfg=None):
# cfg is a codai.config.Ds4Config. When omitted, resolve the active one. # cfg is a codai.config.Ds4Config. When omitted, resolve the active one.
if cfg is None: if cfg is None:
...@@ -207,22 +229,30 @@ class Ds4Backend(ModelBackend): ...@@ -207,22 +229,30 @@ class Ds4Backend(ModelBackend):
def generate_chat(self, messages: List[Dict], max_tokens=None, temperature=0.7, def generate_chat(self, messages: List[Dict], max_tokens=None, temperature=0.7,
top_p=1.0, stop=None, tools=None, response_format=None): top_p=1.0, stop=None, tools=None, response_format=None):
import requests import requests
payload = self._chat_payload(messages, max_tokens, temperature, top_p, stop, False) self._enter_request()
if response_format and response_format.get("type") == "json_object": try:
payload["response_format"] = {"type": "json_object"} payload = self._chat_payload(messages, max_tokens, temperature, top_p, stop, False)
r = requests.post(self._base() + "/v1/chat/completions", json=payload, timeout=3600) if response_format and response_format.get("type") == "json_object":
r.raise_for_status() payload["response_format"] = {"type": "json_object"}
data = r.json() r = requests.post(self._base() + "/v1/chat/completions", json=payload, timeout=3600)
self._store_usage(data.get("usage", {})) r.raise_for_status()
return data["choices"][0]["message"].get("content") or "" data = r.json()
self._store_usage(data.get("usage", {}))
return data["choices"][0]["message"].get("content") or ""
finally:
self._exit_request()
async def generate_chat_stream(self, messages: List[Dict], max_tokens=None, async def generate_chat_stream(self, messages: List[Dict], max_tokens=None,
temperature=0.7, top_p=1.0, stop=None, tools=None, temperature=0.7, top_p=1.0, stop=None, tools=None,
response_format=None) -> AsyncGenerator[str, None]: response_format=None) -> AsyncGenerator[str, None]:
payload = self._chat_payload(messages, max_tokens, temperature, top_p, stop, True) self._enter_request()
async for chunk in self._stream(self._base() + "/v1/chat/completions", payload, try:
delta_key="delta"): payload = self._chat_payload(messages, max_tokens, temperature, top_p, stop, True)
yield chunk async for chunk in self._stream(self._base() + "/v1/chat/completions", payload,
delta_key="delta"):
yield chunk
finally:
self._exit_request()
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# plain completion (fallback path) # plain completion (fallback path)
......
...@@ -820,6 +820,14 @@ class DeepSeekParser(BaseParser): ...@@ -820,6 +820,14 @@ class DeepSeekParser(BaseParser):
if results: if results:
return results return results
# Degraded plaintext <tool>name arg: value</tool> from heavy quants (e.g.
# the ds4 q2-imatrix), which can't reliably emit the exact DSML tokens.
if self.tools:
for name, args in parse_tool_tag_plaintext_calls(text, set(self.tools.keys())):
results.append(self._to_oa(name, args))
if results:
return results
# DeepSeek-V3 uses specialized JSON prompts # DeepSeek-V3 uses specialized JSON prompts
calls = re.findall(r'\{"name":\s*"(.*?)",\s*"parameters":\s*(\{.*?\})}', text) calls = re.findall(r'\{"name":\s*"(.*?)",\s*"parameters":\s*(\{.*?\})}', text)
for name, params in calls: for name, params in calls:
...@@ -1164,6 +1172,69 @@ def parse_tool_tag_json_calls(text: str, tool_names=None): ...@@ -1164,6 +1172,69 @@ def parse_tool_tag_json_calls(text: str, tool_names=None):
return out return out
def parse_tool_tag_plaintext_calls(text: str, tool_names=None):
"""Parse the degraded PLAINTEXT ``<tool>`` format some heavily-quantized models
emit instead of JSON/DSML, e.g.::
<tool>
read filePath: /path/to/file
</tool>
The block wraps a first token = tool name, then one or more ``key: value``
argument lines (the name and its first arg may share line 1; each value is split
on its FIRST colon, so paths/URLs in the value survive). Only blocks whose name
is a DECLARED tool are accepted (``tool_names`` is REQUIRED) and the inner text
must be plain (no ``<…>`` sub-tags, no leading ``{``) so JSON/XML ``<tool>``
forms are left to their own parsers and ordinary prose isn't misread. This is a
best-effort rescue for low-bit quants that can't reproduce the exact tool-call
tokens — higher-quant models emit proper formats and never reach here.
To avoid catching a ``<tool>`` *example* a model writes inside an explanatory
reply, the block(s) must be the message's trailing ACTION: after the first
``<tool>`` everything to end-of-text must be only ``<tool>…</tool>`` blocks and
whitespace. A reply with prose after/between the tags is treated as text, not a
call. Returns ``[(name, args), …]``."""
if not text or '<tool' not in text.lower() or not tool_names:
return []
blocks = list(re.finditer(r'<tool\s*>(.*?)</tool\s*>', text, re.DOTALL | re.IGNORECASE))
if not blocks:
return []
# Require the tag(s) to form the trailing run of the message: strip the matched
# blocks out of the tail (from the first block on) and demand only whitespace is
# left. Otherwise this is prose that merely mentions the <tool> syntax.
tail = text[blocks[0].start():]
for b in blocks:
tail = tail.replace(b.group(0), '', 1)
if tail.strip():
return []
out, seen = [], set()
for m in blocks:
inner = m.group(1).strip()
if not inner or inner.startswith('{') or '<' in inner:
continue
lines = [ln.strip() for ln in inner.splitlines() if ln.strip()]
if not lines:
continue
head = lines[0].split(None, 1)
name = head[0].strip().strip(':')
if name not in tool_names:
continue
arg_lines = ([head[1]] if len(head) > 1 else []) + lines[1:]
args = {}
for al in arg_lines:
if ':' in al:
k, v = al.split(':', 1)
k = k.strip()
if k:
args[k] = v.strip()
key = (name, json.dumps(args, sort_keys=True, default=str))
if key in seen:
continue
seen.add(key)
out.append((name, args))
return out
# 7. GEMMA PARSER # 7. GEMMA PARSER
# DeepSeek V4 (ds4) special-token bar | (U+FF5C); tolerate an ASCII | fallback. # DeepSeek V4 (ds4) special-token bar | (U+FF5C); tolerate an ASCII | fallback.
_DSML_INVOKE = re.compile( _DSML_INVOKE = re.compile(
...@@ -2288,6 +2359,11 @@ class ToolCallParser: ...@@ -2288,6 +2359,11 @@ class ToolCallParser:
"function": {"name": name, "arguments": json.dumps(args)}, "function": {"name": name, "arguments": json.dumps(args)},
} for name, args in dsml] } for name, args in dsml]
# NOTE: the degraded plaintext <tool>name arg: value</tool> form (heavy
# quants that can't emit DSML, e.g. ds4 q2-imatrix) is handled ONLY in
# DeepSeekParser — scoped to the DeepSeek family where it occurs — so it can
# never misread a <tool> example in some other model's prose reply here.
# For Qwen models, try Qwen-specific parsing first # For Qwen models, try Qwen-specific parsing first
if self._is_qwen_model(): if self._is_qwen_model():
qwen_tool_calls = self._parse_qwen_tool_calls(text) qwen_tool_calls = self._parse_qwen_tool_calls(text)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment