From 0ba5c5aad313726c2f1ad3a653d9b6a738fe3ac3 Mon Sep 17 00:00:00 2001 From: Asher Feldman <59994+asher@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:53:50 -0700 Subject: [PATCH 1/4] chat: narrow toolbar drops sampling knobs before tail stats --- gmlx/chat.py | 35 +++++++++++++++++++++++++---------- tests/test_chat_e2e.py | 20 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/gmlx/chat.py b/gmlx/chat.py index 359c94a..6d32662 100644 --- a/gmlx/chat.py +++ b/gmlx/chat.py @@ -1570,25 +1570,40 @@ def get_completions(self, document, complete_event): def _toolbar(): s = state.sampling + # (part, droppable) pairs: on a narrow terminal the sampling knobs + # go first so the tail stats (ctx, staged, tok/s) survive whole. + # prompt_toolkit would otherwise clip the toolbar tail-first, which + # cuts exactly the live numbers a small pane is watched for. parts = [] if state.model_name: - parts.append(state.model_name) + parts.append((state.model_name, False)) parts += [ - f"temp={s['temp']:g}", - f"top-p={s['top_p']:g}", - f"max-tok={s['max_tokens'] or 'off'}", + (f"temp={s['temp']:g}", True), + (f"top-p={s['top_p']:g}", True), + (f"max-tok={s['max_tokens'] or 'off'}", True), ] if state.ctx_used and state.ctx_max: - parts.append(f"ctx {_fmt_k(state.ctx_used)}/{_fmt_k(state.ctx_max)}") + parts.append( + (f"ctx {_fmt_k(state.ctx_used)}/{_fmt_k(state.ctx_max)}", + False)) if state.staged: - parts.append(f"+{len(state.staged)} staged") + parts.append((f"+{len(state.staged)} staged", False)) if state.staged_images: - parts.append(f"+{len(state.staged_images)} img") + parts.append((f"+{len(state.staged_images)} img", False)) if state.staged_audio: - parts.append(f"+{len(state.staged_audio)} aud") + parts.append((f"+{len(state.staged_audio)} aud", False)) if state.last_tps: - parts.append(f"{state.last_tps:.1f} tok/s") - return " · ".join(parts) + parts.append((f"{state.last_tps:.1f} tok/s", False)) + try: + from prompt_toolkit.application import get_app + width = get_app().output.get_size().columns + except Exception: # noqa: BLE001 - no app yet: keep everything + width = None + if width: + while (len(" · ".join(p for p, _ in parts)) > width + and any(d for _, d in parts)): + parts.pop(next(i for i, (_, d) in enumerate(parts) if d)) + return " · ".join(p for p, _ in parts) state.ptk_session = PromptSession( history=_ToggleableFileHistory(hist_file), diff --git a/tests/test_chat_e2e.py b/tests/test_chat_e2e.py index d564a49..2db9431 100644 --- a/tests/test_chat_e2e.py +++ b/tests/test_chat_e2e.py @@ -882,6 +882,26 @@ def test_ptk_toolbar_reflects_live_state(monkeypatch, tmp_path): assert "12.3 tok/s" in text +def test_ptk_toolbar_narrow_drops_knobs_keeps_tail(monkeypatch, tmp_path): + # On a narrow terminal the sampling knobs are dropped left-to-right so + # the tail stats (the live tok/s a small pane is watched for) stay whole; + # prompt_toolkit's own clipping would cut the tail instead. + from types import SimpleNamespace + + import prompt_toolkit.application as ptk_app + + state = _ptk_state(monkeypatch, tmp_path) + with _session(state) as (session, _pipe): + toolbar = session.bottom_toolbar + state.model_name = "deepseek-v4-demo" + state.last_tps = 24.1 + fake_app = SimpleNamespace(output=SimpleNamespace( + get_size=lambda: SimpleNamespace(rows=20, columns=40))) + monkeypatch.setattr(ptk_app, "get_app", lambda: fake_app) + text = toolbar() + assert text == "deepseek-v4-demo · 24.1 tok/s" + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__, "-q"])) From 7beec50b51f714d584c43e52310c14308fa16763 Mon Sep 17 00:00:00 2001 From: Asher Feldman <59994+asher@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:54:04 -0700 Subject: [PATCH 2/4] changelog: narrow-toolbar fix --- CHANGELOG.md | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f85ad94..688850b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,10 @@ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and pacing stands down wherever it would not help (simultaneous bursts, cheap chunks, stuck queues). A numeric ratio pins the previous static behavior; GMLX_DECODE_PREFILL_AUTO=0 reverts on a live server. - - GMLX_SERVE_MEMSTATS=path.jsonl writes a per-tick serve memory trace: MLX counters, free-headroom estimate, and per-owner cache byte attribution with allocation shapes marked on change, for diagnosing serve memory growth under load. - - Serve admission is gated on projected memory headroom: a request whose measured KV and prefill-transient projection does not fit is kept queued and retried each tick instead of committing memory the box does @@ -26,7 +24,6 @@ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). admits, and a request deferred past GMLX_ADMIT_DEFER_MAX_S (default 60s) is admitted anyway with a loud log. GMLX_ADMIT_HEADROOM=0 disables. - - /v1/metrics reports residency budget vs resident bytes, live active/cache/headroom memory, and admission deferral counters. @@ -35,19 +32,15 @@ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - mlx-kquant floor raised to 0.3.11: MoE prefill gather runs 12-28% faster per call at chat-chunk widths, lifting serve prefill 20-43% shallow and 8-14% deep on many-expert models. - - Bench chart value axes clamp to the data range when a zero anchor would waste the panel height on empty space; nearby engine lines now read as visually distinct. - - benchmarks.md tracks builds and measured date per model, and merged results carry the newest contributing run date: one model rebenched on newer releases no longer implies the rest was remeasured. - - DeepSeek-V4-Flash IQ2_XXS rebenched on gmlx 0.2.2 + mlx-kquant 0.3.11 vs ds4-server b030961 (2026-08-05): prefill 1.11-1.86x and decode 1.05-1.59x across the full d512-500k ladder. - - DeepSeek-V4 single-token decode runs its hyper-connection glue as four native mlx-kquant ops instead of about 176 python kernel launches per step. `GMLX_HC_M1_FUSED=0` and `GMLX_HC_KQ=0` restore the previous @@ -65,6 +58,8 @@ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- Chat's bottom toolbar no longer clips the live tok/s readout on + narrow terminals; sampling knobs are dropped first instead. - The serve free-headroom estimate went negative on models whose load materializes weights into MLX-tracked memory (the same bytes counted twice); the loader now registers only the truly untracked mmap From e03d3e9cc38e62ac605fc05e6697782f49cae731 Mon Sep 17 00:00:00 2001 From: Asher Feldman <59994+asher@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:21:14 -0700 Subject: [PATCH 3/4] tests: allowlist toolbar middot glyph in chat e2e asserts --- tests/test_ascii_hygiene.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_ascii_hygiene.py b/tests/test_ascii_hygiene.py index 78b96ef..ea3b8e4 100644 --- a/tests/test_ascii_hygiene.py +++ b/tests/test_ascii_hygiene.py @@ -23,6 +23,7 @@ "gmlx/tts.py", # speech-text sanitizer dash/fraction glyphs "tests/e2e/checks.py", # U+FFFD degeneration detection "tests/test_chat.py", # asserts chat status-line separators + "tests/test_chat_e2e.py", # asserts toolbar middot tail "tests/test_e2e_checks.py", # U+FFFD fixtures "tests/test_menubar.py", # asserts menubar glyph labels "tests/test_reasoning.py", # asserts spinner/box output From 4b167e4f14bf11c7cfe29027734906293483b2bf Mon Sep 17 00:00:00 2001 From: Asher Feldman <59994+asher@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:23:40 -0700 Subject: [PATCH 4/4] chat: --server plain client mode, automatic when the config server is up --- CHANGELOG.md | 5 ++ docs/cli.md | 21 ++++- gmlx/chat.py | 161 ++++++++++++++++++++++++++++------- tests/conftest.py | 10 +++ tests/test_chat_assistant.py | 111 ++++++++++++++++++++++++ 5 files changed, 274 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 688850b..01cbbb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- gmlx chat --server: chat against a running server as a plain client, + without the assistant's tools and memory (no background requests). + Engages automatically when the config's server is already up and + serves the requested model; --local pins the in-process load. An + explicit GGUF path always loads the file on disk. - decode_prefill_ratio accepts "auto" and it is the new default: live streams keep at least half their decode rate while deep prompts admit, and pacing stands down wherever it would not help (simultaneous diff --git a/docs/cli.md b/docs/cli.md index b172d34..1cb5cef 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -275,6 +275,7 @@ decode tok/s, MTP acceptance when speculating, and context fill ```sh gmlx chat model.gguf --temp 0.7 --system-prompt "You are terse." gmlx chat --assistant # the tool-loop assistant on the managed server +gmlx chat --server # plain server client, no assistant extras ``` With `--assistant` the REPL loads nothing locally: turns run through the @@ -285,6 +286,18 @@ positional becomes a served model id (or is omitted for the server default). unchanged; local-load flags do not apply. Full contract: [assistant.md](assistant.md#text-chat-gmlx-chat---assistant). +`--server` is the same server-backed REPL minus the assistant extras: no +tools, no memory store, no config `assistant:` block, just plain streamed +turns against the served model. Use it when the terminal should be a thin +client and every server request should come from the conversation itself. + +When the config's server is already running, a bare `gmlx chat` (or one +naming a served model id) becomes a `--server` client automatically +instead of loading a second copy in-process; `--local` forces the local +load, and any local-load flag does the same. An explicit GGUF path always +loads the file on disk - if the running server serves that same file, a +note points at the served id. Chat never auto-starts a server this way. + `/exit` (or Ctrl-D) quits, `/reset` restarts the conversation, `/help` lists every command. The terminal is upgraded on top: @@ -399,10 +412,12 @@ every command. The terminal is upgraded on top: | Flag | Default | Meaning | |------|---------|---------| -| `gguf` (positional) | - | Path to the GGUF (sharded ok) or a config model id; with `--assistant`, a served model id (optional: server default). | +| `gguf` (positional) | - | Path to the GGUF (sharded ok) or a config model id; with `--assistant`/`--server`, a served model id (optional: server default). | | `--assistant` | - | Chat through the built-in tool-loop assistant on the managed server: MCP tools + long-term memory from the `assistant:` block ([assistant.md](assistant.md)). Local-load flags don't apply. | -| `--base-url URL` / `--host` / `--port` / `--api-key` | managed server | Assistant mode: target server (as in [`talk`](#gmlx-talk)). | -| `--no-start` / `--start-timeout S` | - / `180` | Assistant mode: never auto-start the server / auto-start wait. | +| `--server` | - | Plain server client: the `--assistant` REPL minus tools, memory, and the config `assistant:` block. Automatic when the config's server is up and serves the request. | +| `--local` | - | Load in-process even when the config's server is running (skips the automatic `--server` mode). | +| `--base-url URL` / `--host` / `--port` / `--api-key` | managed server | Server modes: target server (as in [`talk`](#gmlx-talk)). | +| `--no-start` / `--start-timeout S` | - / `180` | Server modes: never auto-start the server / auto-start wait. | | `--max-tokens N` | until EOS | Per-reply decode-token cap; default `0` = each reply runs until the model stops (diffusion models fall back to a bounded 2048-token canvas; in `--assistant` mode `0` defers to the server's own default). Pass N to cap (adjustable via `/max-tokens`; `0` removes the cap, and a note says when the cap ended a reply). | | `--temp` / `--top-p` / `--top-k` / `--min-p` | family default | Sampling; unset flags seed from the model's [family defaults](#family-defaults-intent-and---profile) (`0.0`/`0.95`/`0`/`0.05` under `--no-family-defaults`). All adjustable in-chat. | | `--xtc-probability` / `--xtc-threshold` | `0.0` | XTC sampling (text path, adjustable in-chat). | diff --git a/gmlx/chat.py b/gmlx/chat.py index 6d32662..10d7f3f 100644 --- a/gmlx/chat.py +++ b/gmlx/chat.py @@ -100,7 +100,7 @@ def _build_parser(prog: str = "gmlx chat") -> argparse.ArgumentParser: ) from .cli import add_condensed_help add_condensed_help(ap, ( - "gguf", "--assistant", "--config", "--profile", "--system-prompt", + "gguf", "--assistant", "--server", "--config", "--profile", "--system-prompt", "--reasoning", "--thinking", "--mmproj", "--max-tokens", "--temp", "--top-p", "--min-p", "--max-kv-size", "--stream-experts", "--resume", "--theme", "--verbose", @@ -108,7 +108,7 @@ def _build_parser(prog: str = "gmlx chat") -> argparse.ArgumentParser: ap.add_argument( "gguf", nargs="?", default=None, help="Path to the GGUF file (sharded ok), or a config model id. " - "Optional with --assistant (server default model).", + "Optional with --assistant/--server (server default model).", ) # Server-backed assistant mode (no local load; mirrors `gmlx talk`). @@ -119,6 +119,20 @@ def _build_parser(prog: str = "gmlx chat") -> argparse.ArgumentParser: "(auto-started) server: MCP tools + long-term memory from the " "config's assistant: block. The positional is a served model id.", ) + ap.add_argument( + "--server", + action="store_true", + help="Plain server client: chat on a running (auto-started) server " + "with no assistant extras (no tools, no memory). The positional is " + "a served model id. Engages automatically when the config's server " + "is already up and serves the requested model.", + ) + ap.add_argument( + "--local", + action="store_true", + help="Load in-process even when the config's server is running " + "(skips the automatic --server client mode).", + ) ap.add_argument( "--base-url", default=None, @@ -1963,11 +1977,12 @@ def _vlm_message( def _assistant_flag_gate(args, parser) -> int | None: - """Reject/noop the local-load flags under --assistant. Rejected flags - exit 2; server-owned ones print one [chat] note and are ignored.""" + """Reject/noop the local-load flags under --assistant/--server. Rejected + flags exit 2; server-owned ones print one [chat] note and are ignored.""" + mode = "--server" if getattr(args, "server", False) else "--assistant" for attr, flag in _ASSISTANT_REJECT: if getattr(args, attr, None) not in (None, False): - print(f"error: {flag} is not supported with --assistant " + print(f"error: {flag} is not supported with {mode} " "(the server owns the model and its template)", file=sys.stderr) return 2 @@ -1981,10 +1996,69 @@ def _assistant_flag_gate(args, parser) -> int | None: noop.append(flag) if noop: print(f"[chat] server owns {', '.join(noop)} - ignored with " - "--assistant") + f"{mode}") return None +def _auto_server(args, parser) -> bool: + """Whether a bare ``gmlx chat`` should become a --server client: the + managed/config server already answers and serves the requested id, and no + flag states local intent. Never starts anything; any probe failure means + the local load proceeds untouched. An explicit GGUF path always loads + locally (the file on disk is what was asked for - the server may hold + older bytes at the same path); a hint names the served id when the + config maps that file to one.""" + if args.local or args.assistant or args.server: + return False + if args.base_url or args.host or args.port or args.no_start: + return False # explicit targeting keeps today's contract + for attr, _flag in _ASSISTANT_REJECT + _ASSISTANT_NOOP: + if getattr(args, attr, None) not in (None, False): + return False # a local-load flag pins the local path + for attr in ("kv_group_size", "quantized_kv_start", + "prefill_feeder", "decode_feeder"): + if getattr(args, attr, None) != parser.get_default(attr): + return False + from . import launch as launch_mod + from . import lifecycle + try: + host, port = lifecycle.auto_target(None, None) + base = f"http://{host}:{port}/v1" + if not launch_mod._server_ready(base, args.api_key): + return False + if not args.gguf: + args.base_url = base # server default (or its served-ids error) + return True + from .talk_client import probe_capabilities + served = probe_capabilities(base, args.api_key).get("chat_ids") or [] + except Exception: # noqa: BLE001 - probe hiccup = stay local + return False + requested = args.gguf + if "@" in requested and not (requested.endswith(".gguf") + or os.path.exists(os.path.expanduser(requested))): + requested = requested.rpartition("@")[0] or requested + if requested in served: + args.base_url = base + return True + if requested.endswith(".gguf") or os.path.exists( + os.path.expanduser(requested)): + real = os.path.realpath(os.path.expanduser(requested)) + try: + from .launch import _discover_config + cfg, _path = _discover_config() + models = list(getattr(cfg, "models", None) or []) + except Exception: # noqa: BLE001 + return False + for m in models: + if (m.id in served and m.path + and os.path.realpath(os.path.expanduser(m.path)) == real): + print(f"[chat] note: the running server serves this file as " + f"'{m.id}' - `gmlx chat {m.id}` rides it without a " + "second load") + break + return False + + def _setup_assistant(args): """Resolve the server + served model and build the AssistantBrain from the config's shared ``assistant:`` block (the same tools + memory store @@ -2026,7 +2100,8 @@ def _setup_assistant(args): args.profile = args.profile or tail if requested and (requested.endswith(".gguf") or os.path.exists(os.path.expanduser(requested))): - print("error: --assistant chats through the server - pass a served " + mode = "--server" if getattr(args, "server", False) else "--assistant" + print(f"error: {mode} chats through the server - pass a served " "model id, not a file (add it to your config's models:)", file=sys.stderr) return 2 @@ -2045,31 +2120,36 @@ def _setup_assistant(args): return 2 model_request = f"{model}@{args.profile}" if args.profile else model + # --server: same plumbing, no assistant extras (config tools and the + # memory store stay off even where the config enables them). + plain = getattr(args, "server", False) + from .config import AssistantCfg a = AssistantCfg() - try: - if args.config: - from . import config as cfgmod - a = cfgmod.load_config(args.config).assistant - else: - from .launch import _discover_config - cfg, _path = _discover_config() - if cfg is not None: - a = cfg.assistant - except Exception as e: # noqa: BLE001 - degrade - print(f"[chat] config: {e} - assistant runs tool-less", - file=sys.stderr) + if not plain: + try: + if args.config: + from . import config as cfgmod + a = cfgmod.load_config(args.config).assistant + else: + from .launch import _discover_config + cfg, _path = _discover_config() + if cfg is not None: + a = cfg.assistant + except Exception as e: # noqa: BLE001 - degrade + print(f"[chat] config: {e} - assistant runs tool-less", + file=sys.stderr) from .assistant_brain import AssistantBrain from .talk_client import stream_chat as _stream_chat from .talk_mcp import connect_servers mcp_host, registry, warns = connect_servers( - a.mcp, call_timeout_s=a.tool_timeout_s) + () if plain else a.mcp, call_timeout_s=a.tool_timeout_s) for w in warns: print(f"[chat] {w}", file=sys.stderr) memory = None - if a.memory.enabled: # the same store talk uses + if a.memory.enabled and not plain: # the same store talk uses from .talk_memory import MemoryStore, make_extractor extractor = (make_extractor(base_url, model_request, api_key=api_key) if a.memory.extract else None) @@ -2665,6 +2745,19 @@ def cmd_chat(argv: list[str] | None = None, prog: str = "gmlx chat") -> int: set_stoch_accept(True) brain = None # --assistant: server-backed turn engine model_request = None + if args.local and (args.assistant or args.server): + parser.error("--local loads in-process and cannot combine with " + "--assistant/--server") + if args.server and args.assistant: + parser.error("--assistant and --server are mutually exclusive " + "(--server is the assistant path minus its extras)") + if _auto_server(args, parser): + args.server = True + args.no_start = True # the probe saw it up; never start one + print("[chat] config server is up - chatting through it " + "(--local loads in-process instead)") + if args.server: + args.assistant = True # same server path; extras off in setup if args.assistant: rc = _assistant_flag_gate(args, parser) if rc is not None: @@ -2690,15 +2783,17 @@ def cmd_chat(argv: list[str] | None = None, prog: str = "gmlx chat") -> int: speculative = False vlm_mtp = False else: - # The server-targeting flags only apply under --assistant; without it - # chat loads the model in-process. Accepting them silently would leave - # the user believing they are on the server while a second copy loads. + # The server-targeting flags only apply under --assistant/--server; + # without one chat loads the model in-process. Accepting them silently + # would leave the user believing they are on the server while a second + # copy loads. for attr, flag in (("base_url", "--base-url"), ("host", "--host"), ("port", "--port"), ("api_key", "--api-key"), ("no_start", "--no-start")): if getattr(args, attr, None) not in (None, False): parser.error(f"{flag} targets a server and needs --assistant " - f"(without it, chat loads the model in-process)") + f"or --server (without one, chat loads the model " + f"in-process)") if not args.gguf: # Not parser.error: that leads with the full usage dump, which is # exactly the wall of text a first-run user shouldn't wade through. @@ -2945,7 +3040,8 @@ def _cfg_get(key): state.ctx_max = None state.model_name = model_request[:24] state.model_info = {"path": f"{model_request} (via {base_url})", - "model_type": "assistant"} + "model_type": "server" if args.server + else "assistant"} else: model_key = os.path.abspath(args.gguf) try: @@ -3029,11 +3125,14 @@ def _finish_load(): else: print(f"[chat] MTP speculative decoding on ({kind} drafter)") if brain is not None: - tools = ", ".join(brain.tools.names()) or "(none)" - mem = (f" - memory: {brain.memory.count()} items" - if brain.memory is not None else "") - print(f"[chat] assistant mode: {model_request} via {base_url} - " - f"tools: {tools}{mem}") + if args.server: + print(f"[chat] server mode: {model_request} via {base_url}") + else: + tools = ", ".join(brain.tools.names()) or "(none)" + mem = (f" - memory: {brain.memory.count()} items" + if brain.memory is not None else "") + print(f"[chat] assistant mode: {model_request} via {base_url} - " + f"tools: {tools}{mem}") first_turn = True vlm_msgs: list = [] # rendered messages (media markers pinned per turn) vlm_images: list = [] # media paths, in marker order across all turns diff --git a/tests/conftest.py b/tests/conftest.py index aeca8e4..b1c5227 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -128,3 +128,13 @@ def _isolated_xdg_data(tmp_path_factory, monkeypatch): monkeypatch.setenv( "XDG_DATA_HOME", str(tmp_path_factory.mktemp("xdg-data")) ) + + +@pytest.fixture(autouse=True) +def _no_live_server(monkeypatch): + # chat's automatic --server gate probes the box's managed server; a + # live server on the dev machine must not flip test behavior. Tests + # that want the probe up re-patch it locally. + import gmlx.launch as _launch + monkeypatch.setattr(_launch, "_server_ready", + lambda base_url, api_key=None: False) diff --git a/tests/test_chat_assistant.py b/tests/test_chat_assistant.py index 1c52524..133d211 100644 --- a/tests/test_chat_assistant.py +++ b/tests/test_chat_assistant.py @@ -315,6 +315,117 @@ def test_no_model_no_default_rejected(monkeypatch, capsys): capsys.readouterr().err) +# ------------------------------ --server --------------------------------- + +def test_server_flag_builds_plain_brain(monkeypatch): + """--server rides the assistant plumbing with memory and tools off, + even though both default on in AssistantCfg.""" + import argparse + + _fake_server(monkeypatch, ["alpha"], default="alpha") + args = argparse.Namespace( + gguf=None, server=True, config=None, profile=None, + base_url=None, host=None, port=None, api_key=None, + no_start=True, start_timeout=1.0) + setup = chat._setup_assistant(args) + assert not isinstance(setup, int) + brain, model_request, _burl, _extra = setup + assert model_request == "alpha" + assert brain.memory is None + assert len(brain.tools) == 0 + + +def test_server_flag_file_arg_rejected(monkeypatch, capsys): + _fake_server(monkeypatch, ["served-model"]) + rc = chat.cmd_chat(["model.gguf", "--server"]) + assert rc == 2 + assert "--server chats through the server" in capsys.readouterr().err + + +def test_server_and_assistant_mutually_exclusive(capsys): + with pytest.raises(SystemExit): + chat.cmd_chat(["--assistant", "--server"]) + assert "mutually exclusive" in capsys.readouterr().err + + +def test_local_excludes_server_modes(capsys): + with pytest.raises(SystemExit): + chat.cmd_chat(["--local", "--server"]) + assert "--local" in capsys.readouterr().err + + +# --------------------------- automatic --server --------------------------- + +def _auto_env(monkeypatch, *, up=True, served=(), default=None): + """Fake a discoverable config server for _auto_server probes.""" + monkeypatch.setattr("gmlx.lifecycle.auto_target", + lambda host, port: ("127.0.0.1", 8080)) + monkeypatch.setattr("gmlx.launch._server_ready", + lambda base_url, api_key=None: up) + monkeypatch.setattr( + "gmlx.talk_client.probe_capabilities", + lambda base_url, api_key=None, timeout=5.0: { + "chat_ids": list(served), "default": default, + }) + + +def _auto_args(argv): + parser = chat._build_parser("gmlx chat") + return parser.parse_args(argv), parser + + +def test_auto_server_bare_connects(monkeypatch): + _auto_env(monkeypatch, served=["alpha"], default="alpha") + args, parser = _auto_args([]) + assert chat._auto_server(args, parser) is True + assert args.base_url == "http://127.0.0.1:8080/v1" + + +def test_auto_server_served_id_connects(monkeypatch): + _auto_env(monkeypatch, served=["alpha", "beta"]) + args, parser = _auto_args(["alpha"]) + assert chat._auto_server(args, parser) is True + + +def test_auto_server_unserved_id_stays_local(monkeypatch): + _auto_env(monkeypatch, served=["alpha"]) + args, parser = _auto_args(["gamma"]) + assert chat._auto_server(args, parser) is False + + +def test_auto_server_down_stays_local(monkeypatch): + _auto_env(monkeypatch, up=False) + args, parser = _auto_args([]) + assert chat._auto_server(args, parser) is False + + +def test_auto_server_local_intent_stays_local(monkeypatch): + _auto_env(monkeypatch, served=["alpha"], default="alpha") + for argv in (["--local"], ["alpha", "--max-kv-size", "4096"], + ["alpha", "--no-start"]): + args, parser = _auto_args(argv) + assert chat._auto_server(args, parser) is False, argv + + +def test_auto_server_gguf_path_stays_local_with_hint( + monkeypatch, tmp_path, capsys): + """An explicit file path pins the local load; a served mapping of the + same file only earns a pointer at the id.""" + from types import SimpleNamespace + + gguf = tmp_path / "alpha.gguf" + gguf.write_bytes(b"GGUF") + _auto_env(monkeypatch, served=["alpha"]) + cfg = SimpleNamespace(models=[ + SimpleNamespace(id="alpha", path=str(gguf))]) + monkeypatch.setattr("gmlx.launch._discover_config", + lambda: (cfg, str(tmp_path / "gmlx.yaml"))) + args, parser = _auto_args([str(gguf)]) + assert chat._auto_server(args, parser) is False + out = capsys.readouterr().out + assert "serves this file as 'alpha'" in out + + # ------------------------------- /memory --------------------------------- class _StubStore: