1. 24 Jun, 2026 16 commits
    • Stefy Lanza (nextime / spora )'s avatar
      engines: process-isolate GGUF from torch on NVIDIA (fix CUDA-context poisoning) · 2fae0beb
      Stefy Lanza (nextime / spora ) authored
      llama.cpp's CUDA backend and PyTorch sharing one process corrupt the CUDA context:
      after a GGUF model runs on an NVIDIA card, the next torch kernel dies with
      "CUDA error: invalid argument". Seen in a township match — a gemma GGUF served a
      chat completion on the nvidia engine, then the next Z-Image (diffusers) image gen
      crashed in its Qwen3 text-encoder's sdpa mask (padding_mask.all()). Every Z-Image
      run before llama.cpp touched CUDA succeeded; the first one after crashed. Evict+swap
      in one process can't fix it — torch holds the (now-corrupted) context for the
      process lifetime.
      
      Fix: when GPUs are auto-detected and server.isolate_gguf_engine is True (default),
      each NVIDIA torch engine gets a co-located sibling gguf engine on the SAME card
      (own process -> own CUDA context). The torch engine drops the `gguf` capability and
      serves transformers/diffusers; the sibling (backend=nvidia so GGUF takes the proven
      CUDA-llama path, capabilities={gguf}) serves llama.cpp. Routing is already
      capability-based, so GGUF goes to the gguf engine and HF/diffusers to the torch
      engine. Both are real engine subprocesses, so the front's routing, VRAM/eviction and
      thermal (cooperative pause + SIGSTOP on the process group) apply unchanged — and
      SIGSTOPping the gguf engine no longer freezes the torch engine.
      
      Ignored when engine_specs is set (declare the split yourself). Disable with
      server.isolate_gguf_engine=false. Design + trade-offs (extra torch context, no
      cross-engine eviction on a shared card) in docs/gguf-process-isolation.md.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      2fae0beb
    • Stefy Lanza (nextime / spora )'s avatar
      entrypoint: alias ~/.coderai to the mounted config dir (fix HOME-based lookups) · 1b9b1588
      Stefy Lanza (nextime / spora ) authored
      Trained LoRAs, characters, environments and voices are resolved from the legacy
      HOME-based dir ~/.coderai, not from --config. In the container the user's home
      (e.g. /home/ubuntu) isn't where config is mounted ($CONFIG_DIR/coderai), so those
      lookups missed the mounted data and image-gen failed with "LoRA '<name>' not found
      on server". Symlink ~/.coderai -> $CODERAI_CONFIG_DIR/coderai so HOME-based and
      --config lookups agree, removing the need for an extra per-deployment --map. Acts
      only when safe (a symlink, missing, or an empty dir) so a user-mounted volume or
      real data is never clobbered.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      1b9b1588
    • Stefy Lanza (nextime / spora )'s avatar
      admin: refresh cached-models scan on config save (fix stale models page) · 979a93d8
      Stefy Lanza (nextime / spora ) authored
      The models page reads /admin/api/cached-models (served by the system worker),
      whose _scan_caches() merges each model's saved config (precision/quant/ctx) into
      the cards. The scan freshness check _scan_signature() only fingerprinted the
      HF/GGUF cache directories, never models.json, and model-configure never
      invalidated the scan. A config save rewrites models.json but touches no cache
      file, so the signature stayed unchanged and the scan was served stale for the
      full TTL (600s) -- the engine reloaded but the front showed old config until a
      full restart.
      
      - _scan_signature(): fold models.json mtime into the signature so any process
        serving cached-models re-scans on the next read after a save.
      - system worker /internal/reload-config: call _invalidate_cache_scan() after
        rebinding config, so the front's reload-push refreshes the scan immediately.
      - engine /internal/reload-config: same invalidation for single-process /
        system-worker-down fallback.
      
      Also adds docs/dtype-auto-selection.md (planned design: model-native dtype
      default read from the checkpoint, precision as explicit override, FA2 fp32
      guard) and ignores build artifacts (venv_build.log, .build.pid).
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      979a93d8
    • Stefy Lanza (nextime / spora )'s avatar
      nvidia HF loader: parity with hf_loading (precision, flash key, buffers, RAM cap) · 8c1ea33d
      Stefy Lanza (nextime / spora ) authored
      Audit of the nvidia text loader (cuda.py) vs the shared hf_loading.py found real
      config drift — the same model config behaved differently on the text path:
      
      1. precision IGNORED — cuda.py hardcoded torch_dtype=float16. Now the manager
         passes the per-model  and cuda.py resolves it via resolve_dtype()
         (default fp16 on CUDA / fp32 on CPU when unset, preserving current behaviour).
      2. 4-bit compute dtype hardcoded float16 → now follows the resolved precision
         (_make_bnb_config takes a compute_dtype).
      3.  key was ignored — the manager only read . Now it
         honours flash_attn / flash_attention (per-model) and the global
         global_args.flash_attn (offload.flash_attention).
      4. offload_buffers was only set on the disk-spill path → now also on the GPU+CPU
         device_map ladder (with offload_folder), so CPU offload doesn't pin activation
         buffers on the GPU and OOM the forward pass.
      5. global max_ram_gb now clamps the CPU offload budget (central
         _get_gpu_memory_map_with_limit + the disk fallback), matching hf_loading.
      
      Diffusers-only items (component_quantization, GGUF components, sdcpp flash flags)
      are correctly N/A to the AutoModelForCausalLM text path.
      
      Bump version to 0.1.9.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      8c1ea33d
    • Stefy Lanza (nextime / spora )'s avatar
      hf: resolve relative offload_dir to the configured dir; forward HF_TOKEN · fe7da690
      Stefy Lanza (nextime / spora ) authored
      Loading an HF model (e.g. Qwen3.5-9B, 4-bit) failed with 'Permission denied:
      ./offload'. Cause: the model's per-model offload_dir was the relative './offload'
      (a stale auto-saved default), which _cfg_or_global lets win over the global
      config; './offload' resolves to the CWD = the READ-ONLY /opt/coderai/app tree in
      the image. The config WAS respected — a relative offload path is just meaningless
      where the CWD isn't writable.
      
      * hf_loading.resolve_offload_dir(): an absolute offload_dir is respected as-is; a
        relative/empty one INHERITS the configured GLOBAL offload directory
        (global_args.offload_dir) when absolute, then CODERAI_OFFLOAD_DIR, then the user
        cache — never the CWD. Applied in the manager (both load sites, always passed),
        hf_loading, and defensively in the cuda backend.
      * main.py + entrypoint: a container-writable CODERAI_OFFLOAD_DIR (=/cache/offload,
        created by the entrypoint) is used when the GLOBAL config is still the bare
        './offload' default; explicit config wins.
      * run_oci.sh: forward HF_TOKEN / HUGGING_FACE_HUB_TOKEN from the host env so the
        engines authenticate to the HF Hub (the 'unauthenticated requests' warning) for
        higher rate limits + gated models. HF_HOME/cache dir was already honoured
        (main.py from config.models.hf_cache_dir).
      
      Bump version to 0.1.8.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      fe7da690
    • Stefy Lanza (nextime / spora )'s avatar
      front: 'silent' wait-mode still keepalives (SSE comments), driven by the front · 595c3a9e
      Stefy Lanza (nextime / spora ) authored
      The point of the keepalive is to hold the client connection open while the engine
      is stuck loading — so 'silent' must NOT fall back to the dead legacy path (which
      sends nothing until the engine's first byte and lets short-timeout clients/proxies
      disconnect). Now ALL streaming inference goes through the front keepalive path:
      
        * silent    — SSE comment lines (': …') only: keeps the socket alive, emits no
                      chunk/content/status (invisible to event parsers).
        * invisible — empty-content chunk + x_queue_info (default).
        * visible   — visible status text.
        * thinking  — reasoning channel (when mode != silent).
      
      The front stays responsive even when the engine is GIL-blocked, so it can drive
      these regardless of engine state.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      595c3a9e
    • Stefy Lanza (nextime / spora )'s avatar
      front: wait-keepalive on the direct streaming path (queue/model-load) · 6583c394
      Stefy Lanza (nextime / spora ) authored
      Clients hitting the public API directly (e.g. township via nginx) disconnected
      during long waits: the direct proxy() path acquired the front queue slot silently
      and awaited the engine's first byte (model load) with no output. The broker path
      already kept alive; the direct path now does too.
      
      For a STREAMING inference request, commit to a 200 text/event-stream up front and
      emit keepalive while acquiring the queue slot and during the engine's model load /
      not-ready retries, then relay the real stream (token-counting for the Tasks page),
      ending cleanly if the engine dies mid-flight.
      
      Configurable mode (models.wait_status_mode, global default 'invisible'; per-model
      override via the models.json entry):
        * invisible — empty-content SSE chunk + x_queue_info (holds the connection; no
                      content pollution)
        * visible   — short visible status text (appears in the content)
        * silent    — nothing (legacy path)
      When thinking is enabled the keepalive goes on the reasoning channel instead
      (no pollution), unless mode is silent.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      6583c394
    • Stefy Lanza (nextime / spora )'s avatar
      vram: learn+persist stable offload split; front: graceful crash + task cancel routing · 223b46f7
      Stefy Lanza (nextime / spora ) authored
      manager (record_vram_delta): record the FULL footprint after a load — including
      offloaded loads (no longer skipped) — so the next load skips the OOM→move-to-RAM
      retry dance:
        * measured_vram_gb      — GPU VRAM delta + reserve (GPU portion; eviction uses it)
        * measured_ram_gb       — host-RAM delta (the CPU-offloaded layers)   [new]
        * measured_n_gpu_layers — the layer split llama.cpp settled on         [new]
        total need = vram + ram. All gated by force_vram_update (else only when
        used_vram_gb unset). New _learned_n_gpu_layers(): when n_gpu_layers is auto/-1,
        reuse the learned split so a reload jumps straight to the config that fit. Both
        load sites snapshot host RAM and read the backend's settled n_gpu_layers.
      
      frontproxy:
        * graceful streaming proxy: when an engine dies mid-stream (e.g. a CUDA
          ggml_abort SIGABRT on a VRAM-OOM decode), end the relayed stream cleanly
          instead of throwing an unhandled ASGI exception (noisy traceback).
        * task actions (cancel/interrupt/pause/resume/restart/DELETE) now route to the
          engine that OWNS the task — fan out to every live engine (each validates the
          same session cookie), first success wins — fixing 'Task not found' for tasks on
          a non-primary engine; front-only synthetic ids handled locally.
      
      Bump version to 0.1.7.
      223b46f7
    • Stefy Lanza (nextime / spora )'s avatar
      runner: pass per-tool args from coderai-docker; video editor --session on by default · b56ca99e
      Stefy Lanza (nextime / spora ) authored
      * coderai-docker (run_oci.sh): new --tool-arg TOOL VAL (one token) and
        --tool-args TOOL STR (whitespace string), repeatable, for video-editor |
        videogen | township | parler. They accumulate into CODERAI_<TOOL>_ARGS env vars
        that supervisord appends to each tool's command line. Documented in --help
        (plus the --map dir-or-file note and bring-your-config examples), and shown on
        the startup banner (tool-args:).
      * entrypoint: default+export the four CODERAI_*_ARGS (empty) so supervisord's
        %(ENV_...)s never fails on an undefined var; pre-create /cache/video_editor/sessions.
      * supervisord: append %(ENV_CODERAI_*_ARGS)s to all four tool launchers; the video
        editor now runs with --session --session-dir /cache/video_editor/sessions so
        editor-state recovery is ON by default and persists on the cache volume.
      * video_editor.py: new --session-dir to place session state/assets at a
        persistent/mapped path (defaults to ~/.cache/... when omitted).
      
      Bump version to 0.1.6.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      b56ca99e
    • Stefy Lanza (nextime / spora )'s avatar
      api: execute_api_request always routes through the front (no single-process path) · f160c1f0
      Stefy Lanza (nextime / spora ) authored
      coderai always runs as front + engines, so drop the single-process special-casing
      in execute_api_request: always hand the sub-request to the front (the single API),
      which routes it to the engine that owns the target model. Critically, remove the
      on-error fallback to in-process dispatch — that would silently run the model on the
      WRONG engine and hide a routing failure. If the front can't be located/reached it
      now returns a 502 the caller surfaces. Drops the now-unused os import.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      f160c1f0
    • Stefy Lanza (nextime / spora )'s avatar
      pipelines: route sub-steps through the front; tools use the public nginx port · 23ab750d
      Stefy Lanza (nextime / spora ) authored
      * pipelines.py: /v1/pipelines/* (image-to-video, story, video-dub, audio-dub)
        chained their steps by calling the image/video/text/TTS handlers DIRECTLY
        in-process, forcing every modality onto whichever engine received the pipeline
        request. In a multi-engine deployment each model may live on a different engine.
        Route every sub-step through the front via execute_api_request (same fix as
        characters/environments) so each lands on the engine that owns that model;
        in-process fallback for single-process mode. Drops the unused TestClient import.
      
      * supervisord.conf: the bundled tools (video_editor, videogen, township) now use
        --base-url http://127.0.0.1:8776 — the PUBLIC nginx port — so they talk to
        coderai exactly like any external client (nginx already proxies the full API
        with 4G bodies / 1h timeouts). The front's own bind stays CODERAI_PORT=18776.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      23ab750d
    • Stefy Lanza (nextime / spora )'s avatar
      api: route characters/environments reference-image gen through the front · 84f6cfbb
      Stefy Lanza (nextime / spora ) authored
      /v1/characters/generate and /v1/environments/generate produced their reference
      images by calling execute_internal_request(request.app, '/v1/images/generations')
      — an IN-PROCESS self-dispatch on whatever engine handled the request. Two bugs:
      
      1. It re-entered the engine's own _InternalAuthMiddleware without the internal
         token, so in multi-engine mode it 403'd ('engines are reachable only through
         the front proxy') — image gen failed while plain text worked (text never
         self-dispatches). Township (a normal API client) hit exactly this.
      2. Even with a token it would run the image model on the WRONG engine: the image
         model may be assigned to / pinned on a different engine than the one serving
         the characters/environments request.
      
      Add execute_api_request(): behind a front (CODERAI_INTERNAL_TOKEN set) it routes
      the sub-request THROUGH THE FRONT (the single public API) at 127.0.0.1:<front
      port>, so the front picks the engine that owns the image model and stamps the
      internal token itself; the caller's Authorization is forwarded so that engine
      authorises it like any client call. Single-process mode still dispatches
      in-process (middleware is a no-op with no token). The client never sends internal
      headers — it just talks to the one API endpoint.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      84f6cfbb
    • Stefy Lanza (nextime / spora )'s avatar
      video_editor: auto-load mapped config + --map accepts files · ac81c655
      Stefy Lanza (nextime / spora ) authored
      Mirror the township config fix for the bundled video editor:
      * supervisord: pass --config /cache/video_editor/video_editor.config.json so a
        bind-mounted (or cache-persisted) config auto-loads — previously it only
        auto-loaded video_editor.config.json from the baked cwd, which isn't mappable.
      * video_editor.py: a MISSING --config file is no longer fatal (was raise
        SystemExit) — start with defaults; the web UI Save creates it.
      * entrypoint: pre-create /cache/video_editor so the config dir exists for
        auto-load + web-UI Save (persists on the standard /cache volume by default).
      * run_oci.sh (coderai-docker): --map now accepts a single FILE, not just a dir,
        so a tool config that lives loose on the host can be linked directly, e.g.
        --map /host/video_editor.config.json:/cache/video_editor/video_editor.config.json
      
      Bump version to 0.1.5.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      ac81c655
    • Stefy Lanza (nextime / spora )'s avatar
      township: auto-load mapped township_config.json in the bundled launcher · fadc340d
      Stefy Lanza (nextime / spora ) authored
      The bundled township tool (supervisord [program:township]) launched without
      -c/--config, so a township_config.json bind-mounted via --map into
      /cache/township_output was never read — the web UI came up with blank API key /
      league upload credentials / options every restart.
      
      * supervisord: pass --config /cache/township_output/township_config.json so a
        mapped config auto-loads. Map the bare-metal dir with
          coderai-docker --map /path/to/township_output:/cache/township_output
        and the saved settings + credentials come up automatically.
      * gen_township_fighters.py: a MISSING --config file is no longer fatal (fresh
        install with no mapped dir) — fall back to defaults and start normally; a
        malformed config still errors. The web UI's Save already writes back to
        <out-dir>/township_config.json, so first-run saves persist into the mapped dir.
      
      Bump version to 0.1.4.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      fadc340d
    • Stefy Lanza (nextime / spora )'s avatar
      thermal: front-driven central supervisor with cooperative pause + SIGSTOP escalation · 707c89bb
      Stefy Lanza (nextime / spora ) authored
      The thermal guard was checkpoint-based and ran inside each engine, so it went
      blind during a single long native call (llama.cpp prefill / image encode): the
      engine can't reach a between-token checkpoint, and the [thermal][debug] heartbeat
      stops exactly when the GPU is hottest.
      
      Move the AUTHORITATIVE monitor to the front, which stays responsive regardless of
      what an engine is doing:
      
      * Front (engine_supervisor): a thermal thread reads per-card temps via gpu_stats()
        + CPU temp, maps each card to the owning engine by its CODERAI_ENGINE_GPUS
        selectors, and drives pause/resume with hysteresis (pause at *_high, resume only
        back at *_resume). A hot GPU pauses just its engine; a hot CPU pauses all.
      * Engines stop cooperatively, as before, but triggered remotely: the front POSTs
        /internal/thermal-pause; thermal.set_external_pause() makes wait_until_safe()/
        checkpoint() block at the next safe point (publishing cooldown state so the Tasks
        page shows it), until /internal/thermal-resume.
      * Escalation: if a paused engine keeps generating (inflight > 0) — stuck in a
        native call it can't interrupt — for stop_escalate_checks (default 3) consecutive
        checks, the front SIGSTOPs the engine's process group; SIGCONT on cooldown. Both
        signals target the session group so children freeze too.
      * stop_all()/restart_engine() SIGCONT a frozen engine first (a stopped process
        ignores SIGTERM until continued); _spawn() resets the thermal flags.
      * Config: thermal.supervisor_enabled (default on), thermal.stop_escalate_checks.
      * UI: per-engine temp + pause/frozen state in engines_list and the Tasks cooldown
        banner (covers a SIGSTOPped engine that can't report its own cooldown).
      
      Also: exclude coderai-runtime/*-runtime from the Docker build context (.dockerignore)
      — a root-owned runtime temp file was breaking the image build.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      707c89bb
    • Stefy Lanza (nextime / spora )'s avatar
      vram: measure multi-GPU load delta across all devices (fix split models stuck at 0) · 80b21cf5
      Stefy Lanza (nextime / spora ) authored
      record_vram_delta() measured free VRAM via _free_vram_snapshot(), which only
      read the default CUDA device (device 0). A gpu_split model (e.g. gemma on two
      cards) lands most weights on the other card, so the device-0 delta came out
      ~0, tripped the 'delta <= 0' guard, and never persisted measured_vram_gb. The
      estimate stayed 0 and every load OOM-retried from scratch even with
      force_vram_update set.
      
      Sum free VRAM across every visible CUDA device (scoped per engine via
      CUDA_VISIBLE_DEVICES), plus amdgpu sysfs when cross-pooling is on or no CUDA
      device is visible -- mirroring _get_free_vram_gb(). Before/after snapshots are
      now symmetric, so the delta captures the full multi-card footprint and the
      real value is persisted; subsequent loads size correctly and stop OOM-looping.
      
      Bump version to 0.1.3.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      80b21cf5
  2. 23 Jun, 2026 10 commits
    • Stefy Lanza (nextime / spora )'s avatar
      text: fix NameError — move 'import re as _re' to module top · 4e198169
      Stefy Lanza (nextime / spora ) authored
      The loop-guard change added a module-level _GEMMA_CALL_RE = _re.compile(...) at
      the top of the file, but 'import re as _re' sat far below (line 2266), so import
      crashed with NameError: name '_re' is not defined and every engine failed to
      start. Move the import up with the other stdlib imports (removing the late
      duplicate). Verified codai.api.text now imports cleanly.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      4e198169
    • Stefy Lanza (nextime / spora )'s avatar
      install.sh: show a progress bar while loading the image · 3af53d02
      Stefy Lanza (nextime / spora ) authored
      `docker load -i` shows no useful progress and the installer captured its output
      into a variable, so a ~12G load looked like a multi-minute hang. Stream the
      tarball through a meter into `docker load` instead — the file read tracks load
      progress closely. Prefer `pv` (bar + ETA), fall back to GNU `dd status=progress`
      (bytes + throughput), else plain load with a tip to install pv. The meter draws
      on stderr so the "Loaded image:" line is still captured. Pre-authenticate sudo
      first so its prompt doesn't collide with the bar.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      3af53d02
    • Stefy Lanza (nextime / spora )'s avatar
      text: fix streaming tool-call spill; gate native markup unconditionally; v0.1.2 · 41093571
      Stefy Lanza (nextime / spora ) authored
      The streaming content gate only ran when the request carried a tools array. But
      the AISBF relay can drop tools (as it does max_tokens), so a gemma model still
      emitting <|tool_call>call:NAME{…} from its system prompt had that markup streamed
      straight to the client as content — the spill that poisoned history and fed the
      tool-call loop.
      
      _gate_tool_content now:
        * ALWAYS withholds the unambiguous native special-token markers (<|tool_call>,
          <|tool_response>, DeepSeek DSML) and their cross-chunk partials, even with no
          tools declared;
        * gates the ambiguous <tool>…</tool> XML form only when tools are present;
        * gates the gemma-4 call:NAME{…} form by the per-model gemma_tool_parser mode
          (off: never; full: always; restricted: only declared tool names), so legit
          call:foo{…} prose/code is streamed instead of withheld+dropped.
      The gate is now invoked for every stream (not just tools-present), and the final
      flush likewise, with the mode/tool context resolved once per request.
      
      Bump version to 0.1.2.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      41093571
    • Stefy Lanza (nextime / spora )'s avatar
      parser: gemma tool-call heuristic gets a full|restricted|off mode (per-model) · c7105023
      Stefy Lanza (nextime / spora ) authored
      The gemma-4 native call:NAME{…} heuristic could eat legitimate text: the content
      stripper removed ANY call:/response:{…} span with no tool-name restriction, so a
      coding reply containing e.g. call:foo{…} in prose or a snippet got silently
      deleted; and with no tools declared the parser matched any call:word{.
      
      Add a 3-way mode, resolved per-model (models.json "gemma_tool_parser") over the
      global models.gemma_tool_parser (default "restricted"):
        * full       - parse & strip every call:/response: span (old behavior)
        * restricted - only when NAME is a declared tool; legit call:foo{…} prose/code
                       is preserved, real declared-tool calls still parse & strip
        * off        - disable the gemma heuristic entirely (bigger models that emit
                       standard structured tool calls)
      
      resolve_gemma_tool_mode() + a centralized strip_gemma_native() now gate both the
      parse (GemmaParser, via the dispatcher) and the strip (ModelParserAdapter and the
      streaming ToolCallParser, which now stashes the declared tool names from
      extract_tool_calls so 'restricted' works on the streaming finalizer too).
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      c7105023
    • Stefy Lanza (nextime / spora )'s avatar
      text: add cross-turn tool-call loop guard (per-model + global) · 79ea70d6
      Stefy Lanza (nextime / spora ) authored
      A model can get stuck re-issuing the same tool call with the same arguments when
      each attempt fails (e.g. memory replace old_text=X → "No entry matched" on the
      wrong memory tier) — or when the call spills as un-parsed call:NAME{…} markup
      into assistant content. Each brokered request carries the full history, so
      coderai can see the repetition the agent's own tool-runner didn't break.
      
      _detect_tool_loop scans the recent history for a (tool, arguments) signature
      repeated >= threshold times where those attempts failed (per the tool result) or
      spilled as markup, and injects one system reminder before generation telling the
      model to stop repeating the call. Covers both structured tool_calls and spilled
      gemma-style markup.
      
      Configurable on every model: per-model models.json tool_loop_guard /
      tool_loop_repeats override the global models.tool_loop_* (default on, repeats=3);
      repeats<=0 disables.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      79ea70d6
    • Stefy Lanza (nextime / spora )'s avatar
      text: stop flattening image content to a placeholder (fixes vision over the API) · 361cba6d
      Stefy Lanza (nextime / spora ) authored
      ChatMessage.convert_content_array_to_string ran at parse time (mode=before) and
      unconditionally joined any multipart content list into a string, replacing each
      image_url part with the literal text "[image_url content]". That destroyed the
      image before text.py's vision pipeline (_vision_ok / _normalize_vision_content,
      which the backend's mmproj/MTMDChatHandler consume) ever saw it — so a vision
      model (e.g. lisa = Gemma-4 + mmproj) received only text and answered "no image
      attached", and agents looped retrying. text.py's end-to-end vision handling was
      effectively dead code because content was always pre-stringified.
      
      Now flatten only TEXT-ONLY multipart arrays (the KiloCode case); preserve the
      list whenever it carries an image_url (or any non-text part) so the multimodal
      backend receives the image. Non-vision models still degrade to the placeholder
      downstream (text.py:1439), and text-only/plain-string paths are unchanged.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      361cba6d
    • Stefy Lanza (nextime / spora )'s avatar
      front/broker: publish tokens/s for brokered streams on the Tasks page · 758d948f
      Stefy Lanza (nextime / spora ) authored
      The broker streaming relay counted tokens into engine.active["step"] but never
      set ["rate"], so _merge_engine_tasks (which overlays both onto the running task)
      showed token progression with a frozen 0 speed. Compute tok/s from the first
      streamed token (so the model-load/queue wait doesn't drag the average down) and
      publish it as m["rate"], mirroring the engine-side text path.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      758d948f
    • Stefy Lanza (nextime / spora )'s avatar
      version: bump to 0.1.1 · bd5868be
      Stefy Lanza (nextime / spora ) authored
      Ships the broker load-status / max_tokens fixes, the run-as-invoking-user
      default (+ --root opt-out), nginx console silencing, and the expanded installer
      help. --versioned builds now tag coderai:full_all_0.1.1.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      bd5868be
    • Stefy Lanza (nextime / spora )'s avatar
      packaging: run as invoking user by default, silence nginx console, richer install help · 6c87c7cd
      Stefy Lanza (nextime / spora ) authored
      Three operational fixes for the distributable image:
      
      run_oci.sh: default the container to the invoking user (uid:gid, SUDO_UID-aware)
      instead of the image's root default, with a new --root opt-out. The image has no
      USER directive and supervisord sets no user=, so a run without --user created
      root-owned dirs (logs, coderai-tmp, hf cache) in the bind-mounted /cache; a later
      --user run then couldn't write them ("cannot write /cache/logs ... logging to
      stdout only"). Running as the user by default makes a fresh install safe
      regardless of run order. --root restores the old behaviour (and throwaway config
      copy) for shared root-managed data dirs.
      
      nginx.conf: error_log crit (was info) + access_log off. nginx's [notice]
      startup/worker lines and per-request access logs were piped to the container
      console by supervisord and buried coderai's own output on attach / docker logs.
      Real failures (crit/alert/emerg) still surface.
      
      install.sh: print an extensive post-install guide (quick start, --local + --map,
      where data lives, the run-as-you default, file logging, docker logs/stop, --help).
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      6c87c7cd
    • Stefy Lanza (nextime / spora )'s avatar
      broker/text: move model-load status out of the engine; gate by load_status_updates · bb8ebcf0
      Stefy Lanza (nextime / spora ) authored
      The engine-side "Waiting for model reply..." content injection ran inside the
      GIL-blocked engine handler during model load, so it never streamed live -- it
      only got concatenated into the final reply and polluted the output. Removed it.
      
      Load-status now lives in the responsive front-proxy/broker layer:
      - SSE: broker_execute_stream yields a NON-content chunk (empty delta.content +
        x_queue_info status) during each not-ready wait (engine down / starting /
        model loading), so a watching client sees progress without reply pollution.
      - out-of-band: the existing broker `pending` keepalives are kept.
      
      Both are gated by a single flag, resolved by _load_status_enabled(model):
      - global models.load_status_updates (default True)
      - per-model "load_status_updates" override in models.json (unset = inherit)
      The same gate drives the SSE side and, via client.status_gate, the out-of-band
      `pending` keepalives. Unknown model defaults to on so the relay deadline stays
      protected.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      bb8ebcf0
  3. 22 Jun, 2026 14 commits
    • Stefy Lanza (nextime / spora )'s avatar
      text: accept max_completion_tokens as an alias for max_tokens · 8dcf5844
      Stefy Lanza (nextime / spora ) authored
      Newer OpenAI clients send max_completion_tokens instead of the deprecated
      max_tokens. Our request model only read max_tokens, so such a request arrived with
      max_tokens=None and fell back to the default — ignoring the client's limit. Add a
      max_completion_tokens field and a model_validator that maps it onto max_tokens when
      max_tokens is omitted (max_tokens wins when both are present).
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      8dcf5844
    • Stefy Lanza (nextime / spora )'s avatar
      broker: log inbound max_tokens at the relay boundary (locate where it's dropped) · e410d4be
      Stefy Lanza (nextime / spora ) authored
      A remote client (Hermes) reportedly sends max_tokens=512, but requests arrive at
      the engine with no max_tokens (defaulting to 2048). Our broker client preserves
      whatever payload it's handed, so log the inbound max_tokens / max_completion_tokens
      and the body keys when a brokered request arrives — this shows definitively whether
      the value survives the aisbf relay into our payload or is dropped upstream
      (remote), so we fix the right layer. Diagnostic only.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      e410d4be
    • Stefy Lanza (nextime / spora )'s avatar
      broker/text: drop the "thinking" keepalive; never pass max_tokens=None (512 cut) · 62dde8c2
      Stefy Lanza (nextime / spora ) authored
      Two fixes from live debugging:
      
      1. broker_execute_stream: remove the "the model is thinking..." keepalive chunk.
         It leaked into the assistant reply (saved turns began with the placeholder) and
         isn't wanted — we just wait for the real response. The engine waits ~5min for a
         load and the broker sends protocol-level `pending` keepalives, so liveness holds
         without injecting content. Reverts the streaming body to the simple
         retry-on-connection/transient-5xx form (no queue/keepalive).
      
      2. _clamp_max_tokens: always resolve a concrete max_tokens, never None. The main
         stream/generate paths pass request.max_tokens bare; when the client omits it the
         GGUF backend's `max_tokens or 512` default truncated replies at ~512 tokens
         mid-sentence. Now: model-level cap is authority (client honored only if smaller);
         no cap configured -> keep client value, else default 2048.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      62dde8c2
    • Stefy Lanza (nextime / spora )'s avatar
      text: clamp reply max_tokens to a model-level cap (client honored only if smaller) · 3ea876db
      Stefy Lanza (nextime / spora ) authored
      Add a model-level max_tokens authority: per-model models.json "max_tokens" wins,
      else a new node-wide models.max_tokens (default None = no cap). A client's
      requested max_tokens is honored only when it is SMALLER than the model-level cap;
      a larger or absent request is clamped to the model-level value. Applied before
      auto-compaction so the window-fit trim works off the clamped value. When no cap is
      configured, behavior is unchanged (client value, or the 2048 fallback).
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      3ea876db
    • Stefy Lanza (nextime / spora )'s avatar
      broker: stream keepalive "thinking" chunks during slow load so reply is never empty · 9fb637c6
      Stefy Lanza (nextime / spora ) authored
      Even after the readiness retries, a brokered stream could still reach the client
      empty: when the model falls back to CPU offload after a VRAM OOM, the load + first
      prefill can take tens of seconds during which the engine sends no SSE bytes, so the
      relay/client times out and shows an empty/errored reply.
      
      Keep the stream alive end-to-end: await the engine response and read its stream via
      a background task, pulling with a timeout. On any gap (long load wait, or slow
      first token) emit a visible "the model is thinking..." chunk once - a reasoning_content
      delta when thinking is enabled (enable_thinking/thinking, or a known reasoning model,
      unless reasoning_effort==none), otherwise a plain content message - then cheap
      SSE-comment pings to hold the connection without further polluting the reply. Fast
      responses see no gap and thus no keepalive. Slowness is fine; an empty reply is not.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      9fb637c6
    • Stefy Lanza (nextime / spora )'s avatar
      broker: also retry on connection failure + transient 5xx, not just engine-None · cfe21d56
      Stefy Lanza (nextime / spora ) authored
      The first pass only retried when pick_engine returned no engine or the engine
      returned a non-200. But the real empty-reply case is a CONNECTION failure: during
      the engine startup/restart window the engine process isn't accepting yet, so
      self._long.send raises "All connection attempts failed" — caught by the outer
      except and relayed as a single empty SSE chunk (chunks=1) over the broker.
      
      Move the send inside the retry loop on both broker paths (streaming + buffered):
      retry on a connection exception and on not-ready statuses (425/429/500/502/503/504),
      attempt-bound (6 x 5s), and only surface the error once exhausted. A 4xx is a real
      client error and is still relayed as-is. Confirmed against debug.log: all failures
      were in the engine-startup window; requests after the engine came up streamed fine.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      cfe21d56
    • Stefy Lanza (nextime / spora )'s avatar
      broker: wait+retry for model readiness instead of relaying an empty reply · 7b220b6c
      Stefy Lanza (nextime / spora ) authored
      Brokered inference (both the streaming and buffered front executors) hard-failed
      in the window where no engine/model was ready yet — e.g. engine still warming up,
      or an OOM-triggered evict+reload in progress. Over the broker that not-ready reply
      lands at the client as a single empty SSE chunk (the "empty reply / looks like a
      dead session" symptom), even though a direct API request to the same engine just
      waits (the engine's own /v1/chat/completions handler retries ~5min for a load).
      
      Make the broker paths behave like the direct path: when pick_engine returns no
      ready engine (or the engine returns a non-200 not-ready status), wait and retry,
      attempt-bound (6 × 5s), and only surface an error once retries are exhausted. A
      non-200 means no tokens were generated, so re-sending the body is safe (no
      duplicate output).
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
      7b220b6c
    • Stefy Lanza (nextime / spora )'s avatar
      engine: render chat prompt with a plain (cached) Jinja env — fixes ~40s per request · 080ecff7
      Stefy Lanza (nextime / spora ) authored
      Root cause (from [timing] logs): chat "build" = 39.6s while prefill+first-token =
      0.31s. The ~40s is create_chat_completion rendering the chat template via
      llama-cpp-python's ImmutableSandboxedEnvironment, whose per-operation security
      checks make a heavy template (gemma-4's recursive / O(n^2) macros over a big
      Kilocode conversation) take tens of seconds — every request.
      
      Fix (generic for ALL gguf/llama.cpp models): render the prompt ourselves with a
      plain jinja2.Environment compiled ONCE from the model's tokenizer.chat_template
      (same trim/lstrip, IgnoreGenerationTags + loopcontrols extensions, tojson filter,
      same render vars), then tokenize with add_bos=False + special=True (matching
      llama-cpp's added_special handling — no double BOS) and generate via
      create_completion. Output is byte-identical to the sandboxed render (verified), but
      without the sandbox overhead. Plus a small per-message render cache and finer
      timing ([timing] chat render / tokenize / create_completion setup / first-token).
      Falls back to create_chat_completion if the model has no chat_template or rendering
      fails.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_011DDv7BchtZQWsnPG6Jm49m
      080ecff7
    • Stefy Lanza (nextime / spora )'s avatar
      broker: stream chat responses token-by-token instead of buffering · 6998bb25
      Stefy Lanza (nextime / spora ) authored
      The front's broker path buffered the entire SSE response and sent one envelope, so
      broker clients (lisa, and any OpenAI client via AISBF) got the whole reply at once.
      Now the front streams it:
      
      - streaming.py: stream_chunk_envelope uses event="chunk" with payload.chunk (the
        shape the AISBF relay consumes), finalize_stream uses event="done".
      - dispatcher.py: extracted resolve_broker_request() (op routing + body decode +
        validation) shared by the buffered dispatch and the new streaming path, so they
        can't diverge; added BrokerDispatchError.
      - app.py: broker_execute_stream() proxies to the engine with stream=True and yields
        each SSE chunk (sharing the per-model queue + in-flight tracking); start_broker
        wires client.stream_dispatcher.
      - client.py: handle_message, for stream=true requests, relays each yielded chunk as
        a `chunk` envelope and ends with a `done` (instead of the single buffered reply).
      
      Requires the matching AISBF change (send_request returns early for streaming and
      the relay drains the chunk queue) — committed in the aisbf repo.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_011DDv7BchtZQWsnPG6Jm49m
      6998bb25
    • Stefy Lanza (nextime / spora )'s avatar
      engine: time-split chat generation (build vs prefill) to locate pre-gen latency · e501bab0
      Stefy Lanza (nextime / spora ) authored
      Direct Kilocode requests showed ~40s before the first token even though prefill
      was a cache hit (14551 prefix-match, 1 token to eval) — so the delay is before
      decode. Added timing in vulkan generate_chat_stream: logs "[timing] chat build
      (template+tokenize)" around create_chat_completion (eager chat-template render +
      tokenize) and "[timing] chat lock+prefill+first-token" around the first streamed
      token. One request now reveals whether the 40s is the gemma chat-template Jinja
      render (huge template + many tool schemas, done in Python) or lock/prefill.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_011DDv7BchtZQWsnPG6Jm49m
      e501bab0
    • Stefy Lanza (nextime / spora )'s avatar
      perf: gate cache scans on a cheap dir-mtime signature (only re-scan on change) · 0bc9ba76
      Stefy Lanza (nextime / spora ) authored
      Capability detection is already name-based (microsecond-fast), so caching it
      wouldn't help — the real cost is huggingface_hub.scan_cache_dir() + the per-file
      size sums walking the whole cache. So instead of re-walking on a TTL, the
      cached-models/cache-stats layer now computes a cheap change-signature (the names +
      mtimes of the top-level model dirs/files — a handful of stat() calls) and only does
      the expensive scan when that signature changes. Unchanged caches return the last
      result instantly and never re-walk; a change refreshes in the background. A long
      600s backstop TTL still re-scans occasionally. Mutating ops reset the signature.
      
      Net: the models page is instant on every load and the background refresh only runs
      when models are actually added/removed/downloaded.
      
      Verified: unchanged signature → 0 rescans; changed signature → one background rescan.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_011DDv7BchtZQWsnPG6Jm49m
      0bc9ba76
    • Stefy Lanza (nextime / spora )'s avatar
      perf: cache the cache-scans + gpu-stats so the models page paints instantly · dc3800fe
      Stefy Lanza (nextime / spora ) authored
      The models page was slow because the work itself is slow, not because of routing:
      cached-models walks the HF/GGUF cache doing per-model capability detection, and
      cache-stats walks every file summing sizes — many seconds each on a large cache.
      
      - routes.py: cached-models + cache-stats are now stale-while-revalidate (TTL 25s).
        First call computes; afterwards they return the last result instantly and refresh
        in a background thread. Mutating ops (clear-cache, delete cached model, free-disk)
        force the next refresh. The coderai-system worker pre-warms both scans on startup,
        so even the first page load is fast.
      - gpu_detect.gpu_stats(): short 1.5s TTL cache — the Tasks page polls it ~every
        second and nvidia-smi is slow on a saturated GPU, so collapse repeats.
      
      Verified: _cached returns stale instantly and refreshes in the background
      (2 underlying calls across first+expire); all modules compile.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_011DDv7BchtZQWsnPG6Jm49m
      dc3800fe
    • Stefy Lanza (nextime / spora )'s avatar
      front: config authority — re-read config.json after a save (Phase 2) · 15e06777
      Stefy Lanza (nextime / spora ) authored
      The front is the authority for config READS (settings GET, status, queue capacity).
      A settings SAVE stays on the engine because it applies live runtime changes
      (thermal, RAM monitor, broker runtime) that only exist there and then persists
      config.json. The front now detects the config.json mtime change and re-reads it
      into self.config IN PLACE (so the supervisor/admin_data references see it), keeping
      everything it serves current without a restart. Triggered from status() and the
      settings GET handler (via an on_config_read callback) and lazily in the capacity
      helper.
      
      This is the pragmatic Phase 2: front owns/serves config; engine applies+persists;
      front stays consistent. (Fully relocating the 362-line apply logic to the front
      isn't sound — the live application must run on the engine.)
      
      Verified: front reflects an external config.json change (queue_max 6→11) with no
      restart.
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_011DDv7BchtZQWsnPG6Jm49m
      15e06777
    • Stefy Lanza (nextime / spora )'s avatar
      engine: remove dead archive handlers (front serves archive now) · fae9b6f9
      Stefy Lanza (nextime / spora ) authored
      The archive endpoints (list/get/delete/file/settings) are served by the front
      (codai/frontproxy/admin_data.py, from disk via archive_manager), so the engine's
      copies in admin/routes.py were dead. Removed.
      
      Note: the cache/download/HF handlers in routes.py are NOT removed — the
      coderai-system worker serves them through the same admin_router, so they're only
      unrouted on the engine, not dead code. Fully removing them from the engine would
      require splitting routes.py into engine vs system routers (future cleanup).
      Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
      Claude-Session: https://claude.ai/code/session_011DDv7BchtZQWsnPG6Jm49m
      fae9b6f9