fix(ds4+config): resolve bare model ids, don't over-estimate VRAM, robust config

- ds4: resolve a bare/aliased model id (e.g. "Foo-ds4-Q2_K", no path/extension) to
  its configured .gguf via a config/cache-aware resolver — fixes the 503 ("no local
  deepseek4 GGUF resolved") on chat requests (only "Load now" with a full path
  worked before). Ds4Backend reuses the same resolver.
- ds4: report a modest VRAM footprint for ds4 models (measured or ~12GB) instead of
  the 100GB+ GGUF size — ds4-server streams experts from SSD and manages its own
  memory, so the old estimate forced needless ~128GB eviction churn every request.
- ds4: route on-disk KV checkpoints into coderai's offload directory by default
  (--kv-disk-dir <offload>/ds4-kv) unless overridden in extra_args.
- config: tolerant load (_dc drops unknown keys) so a stale/newer config.json never
  crashes the whole load and silently resets ALL settings to defaults (the "had to
  reconfigure everything" bug). save_config + GET/POST settings carry the new ds4
  fields (model_path, auto_download, ssd_streaming).
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent 6a153c58
......@@ -196,6 +196,18 @@ def _health_ok(url: str) -> bool:
return False
def _coderai_offload_dir() -> str:
"""coderai's configured disk-offload directory (config.offload.directory), or ''."""
try:
from codai.admin.routes import config_manager
if config_manager is not None and config_manager.config is not None:
d = (getattr(config_manager.config.offload, "directory", "") or "").strip()
return os.path.expanduser(d) if d else ""
except Exception:
pass
return ""
def resolve_service_key(cfg, model_file: Optional[str] = None):
"""Decide which GGUF ds4-server should serve and the key to cache it under.
......@@ -273,6 +285,17 @@ def ensure_service(cfg, model_file: Optional[str] = None,
# 100GB+ model run on a small GPU + modest RAM (slow but works).
cmd += ["--ssd-streaming"]
extra = (getattr(cfg, "extra_args", "") or "").strip()
# Route ds4's on-disk KV checkpoints into coderai's offload directory by
# default (unless the user set --kv-disk-dir themselves in extra_args).
if "--kv-disk-dir" not in extra:
off = _coderai_offload_dir()
if off:
kvdir = os.path.join(off, "ds4-kv")
try:
os.makedirs(kvdir, exist_ok=True)
cmd += ["--kv-disk-dir", kvdir]
except OSError:
pass
if extra:
import shlex
cmd += shlex.split(extra)
......
......@@ -67,22 +67,18 @@ class Ds4Backend(ModelBackend):
@staticmethod
def _resolve_gguf(model_name: str):
"""Map a requested model name/path to a local .gguf path, if one exists."""
import os
if not model_name:
return None
cand = os.path.expanduser(model_name)
if cand.lower().endswith(".gguf") and os.path.isfile(cand):
return cand
# Bare filename / alias → look it up in the GGUF cache.
"""Map a requested model name/alias/path/basename to a local .gguf path.
Delegates to the manager's config/cache-aware resolver so a bare client id
(e.g. "Foo-ds4-Q2_K", no path/extension) resolves to the configured file.
"""
try:
from codai.models.cache import get_cached_model_path
p = get_cached_model_path(model_name)
if p and str(p).lower().endswith(".gguf") and os.path.isfile(p):
return str(p)
from codai.models.manager import _resolve_local_gguf
return _resolve_local_gguf(model_name)
except Exception:
pass
return None
import os
cand = os.path.expanduser(model_name or "")
return cand if cand.lower().endswith(".gguf") and os.path.isfile(cand) else None
def get_model_name(self) -> str:
return self._model_id
......
......@@ -416,22 +416,36 @@ class ConfigManager:
with open(self.config_path, 'r') as f:
config_data = json.load(f)
# Parse into Config dataclass
# Parse into Config dataclass. Use a tolerant constructor (_dc) that
# drops unknown keys: a stale or newer-version config.json must NEVER
# crash the whole load, which would silently reset ALL settings to
# defaults (the "had to reconfigure everything" bug).
import dataclasses as _dataclasses
def _dc(cls, data):
if not isinstance(data, dict):
return cls()
known = {f.name for f in _dataclasses.fields(cls)}
extra = [k for k in data if k not in known]
if extra:
print(f"[config] ignoring unknown {cls.__name__} keys: {extra}")
return cls(**{k: v for k, v in data.items() if k in known})
self.config = Config(
version=config_data.get("version", "1.0"),
server=ServerConfig(**config_data.get("server", {})),
backend=BackendConfig(**config_data.get("backend", {})),
models=ModelsConfig(**config_data.get("models", {})),
offload=OffloadConfig(**config_data.get("offload", {})),
vulkan=VulkanConfig(**config_data.get("vulkan", {})),
image=ImageConfig(**config_data.get("image", {})),
whisper=WhisperConfig(**config_data.get("whisper", {})),
archive=ArchiveConfig(**config_data.get("archive", {})),
thermal=ThermalConfig(**config_data.get("thermal", {})),
jobs=JobsConfig(**config_data.get("jobs", {})),
enhance=EnhanceConfig(**config_data.get("enhance", {})),
ds4=Ds4Config(**config_data.get("ds4", {})),
broker=BrokerConfig(**config_data.get("broker", {})),
server=_dc(ServerConfig, config_data.get("server", {})),
backend=_dc(BackendConfig, config_data.get("backend", {})),
models=_dc(ModelsConfig, config_data.get("models", {})),
offload=_dc(OffloadConfig, config_data.get("offload", {})),
vulkan=_dc(VulkanConfig, config_data.get("vulkan", {})),
image=_dc(ImageConfig, config_data.get("image", {})),
whisper=_dc(WhisperConfig, config_data.get("whisper", {})),
archive=_dc(ArchiveConfig, config_data.get("archive", {})),
thermal=_dc(ThermalConfig, config_data.get("thermal", {})),
jobs=_dc(JobsConfig, config_data.get("jobs", {})),
enhance=_dc(EnhanceConfig, config_data.get("enhance", {})),
ds4=_dc(Ds4Config, config_data.get("ds4", {})),
broker=_dc(BrokerConfig, config_data.get("broker", {})),
system_prompt=config_data.get("system_prompt"),
tools_closer_prompt=config_data.get("tools_closer_prompt", False),
grammar_guided=config_data.get("grammar_guided", False),
......
......@@ -51,16 +51,52 @@ _GGUF_ARCH_CACHE: Dict[tuple, str] = {}
def _resolve_local_gguf(model_name: str):
"""Map a model name/alias/path to a local .gguf file path, or None."""
"""Map a model name/alias/path/basename to a local .gguf file path, or None.
Handles the bare ids clients use (e.g. "Foo-ds4-Q2_K" with no path/extension)
by matching configured model entries and scanning the GGUF cache by basename.
"""
if not model_name:
return None
cand = os.path.expanduser(model_name)
if cand.lower().endswith(".gguf") and os.path.isfile(cand):
return cand
# The cache resolver (handles aliases / repo ids), with/without the extension.
for nm in (model_name, model_name + ".gguf"):
try:
p = get_cached_model_path(nm)
if p and str(p).lower().endswith(".gguf") and os.path.isfile(str(p)):
return str(p)
except Exception:
pass
base = os.path.basename(model_name)
base_noext = base[:-5] if base.lower().endswith(".gguf") else base
# Match a configured models.json entry by path / basename (with or without .gguf).
try:
p = get_cached_model_path(model_name)
if p and str(p).lower().endswith(".gguf") and os.path.isfile(str(p)):
return str(p)
from codai.admin.routes import config_manager as cfg_mgr
md = getattr(cfg_mgr, "models_data", None) if cfg_mgr else None
if isinstance(md, dict):
for cat, lst in md.items():
if not isinstance(lst, list):
continue
for m in lst:
key = m if isinstance(m, str) else (m.get("path") or m.get("id") or "") if isinstance(m, dict) else ""
if not key or not str(key).lower().endswith(".gguf"):
continue
kb = os.path.basename(key)
if (key == model_name or kb == base or kb[:-5] == base_noext) and os.path.isfile(os.path.expanduser(key)):
return os.path.expanduser(key)
except Exception:
pass
# Scan the GGUF cache dir for a basename match.
try:
cache_dir = get_model_cache_dir()
if cache_dir and os.path.isdir(cache_dir):
for f in os.listdir(cache_dir):
if f.lower().endswith(".gguf") and (f == base or f[:-5] == base_noext):
fp = os.path.join(cache_dir, f)
if os.path.isfile(fp):
return fp
except Exception:
pass
return None
......@@ -2748,6 +2784,19 @@ class MultiModelManager:
4. HuggingFace hub cache size (dense shards or largest GGUF), adjusted.
Returns 0 when the requirement cannot be determined.
"""
# ds4 (DeepSeek-V4) models are served by an external ds4-server that manages
# its own memory (SSD-streamed MoE experts), so the GGUF file size (100GB+)
# is NOT its VRAM footprint. Reporting that would make coderai try to evict
# ~128 GB on every request (needless churn) and mis-message CPU/disk offload.
# Use the measured value if we have one, else a modest fixed reserve.
try:
if ds4_should_handle(model_key) or (resolved_name and ds4_should_handle(resolved_name)):
measured = self._measured_vram_gb.get(model_key)
if not measured and resolved_name:
measured = self._measured_vram_gb.get(resolved_name)
return float(measured) if measured else 12.0
except Exception:
pass
# Resolve by basename/alias too — a model requested by basename would
# otherwise miss self.config (keyed by full path), return 0, and skip the
# eviction that makes room for it (→ OOM loading on top of a resident model).
......
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