From c05d61cf5f221ca151d77cbfd8c100cef7aa27ba Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Thu, 25 Jun 2026 13:58:11 -0700 Subject: [PATCH 1/4] feat(cli): persistent default_model via gaia config (#98) Users had to pass --model on every `gaia chat` / `gaia llm` / `gaia prompt` to override the built-in default. Now they can set it once in ~/.gaia/config.json and skip the flag entirely. Extends the existing GaiaConfig with a default_model field and a new `gaia config show|get|set` subcommand. Model resolution is highest-wins: explicit --model flag > config default_model > each command's built-in default. `gaia chat --device` still selects a device-specific model and keeps precedence over the config default. Per the no-silent-fallbacks rule, a missing config file falls back to built-in defaults (correct), but a corrupt/unreadable file now fails loudly with the file path and recovery steps instead of silently reverting. `gaia init` load-then-updates so a user-set default_model survives re-running setup. Config file location is overridable via GAIA_CONFIG_DIR / GAIA_CONFIG_FILE. --- docs/reference/cli.mdx | 46 +++- src/gaia/cli.py | 104 +++++++++ src/gaia/config.py | 115 ++++++++-- src/gaia/installer/init_command.py | 9 +- tests/unit/test_cli_config.py | 307 ++++++++++++++++++++++++++ tests/unit/test_npu_device_support.py | 12 +- 6 files changed, 568 insertions(+), 25 deletions(-) create mode 100644 tests/unit/test_cli_config.py diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index a514a349d..428ed5f06 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -621,7 +621,7 @@ gaia llm QUERY [OPTIONS] | Option | Type | Default | Description | |--------|------|---------|-------------| -| `--model` | string | Client default | Specify the model to use | +| `--model` | string | Client default | Specify the model to use. Overrides `default_model` from config (see [Configuration](#configuration)) | | `--max-tokens` | integer | 512 | Maximum tokens to generate | | `--no-stream` | flag | false | Disable streaming response | @@ -666,7 +666,7 @@ gaia chat [MESSAGE] [OPTIONS] | Option | Type | Default | Description | |--------|------|---------|-------------| | `--query, -q` | string | - | Single query to execute | -| `--model` | string | auto-selected by agent | Override the model used by `ChatAgent` (`None` means let the agent pick; see `ChatAgentConfig.model_id`) | +| `--model` | string | auto-selected by agent | Override the model used by `ChatAgent` (`None` means let the agent pick; see `ChatAgentConfig.model_id`). Falls back to `default_model` from config — see [Configuration](#configuration) | | `--index, -i` | path(s) | - | PDF document(s) to index for RAG | | `--watch, -w` | path(s) | - | Directories to monitor for new documents | | `--chunk-size` | integer | 500 | Document chunk size for RAG | @@ -799,7 +799,7 @@ gaia prompt "MESSAGE" [OPTIONS] | Option | Type | Default | Description | |--------|------|---------|-------------| -| `--model` | string | auto | Model ID to use (default: auto-selected by each agent) | +| `--model` | string | auto | Model ID to use (default: auto-selected by each agent). Falls back to `default_model` from config — see [Configuration](#configuration) | | `--max-tokens` | integer | 512 | Maximum tokens to generate | | `--stats` | flag | false | Show performance statistics | @@ -1482,6 +1482,46 @@ gaia connectors grants grant google builtin:chat --scopes gmail.readonly --- +## Configuration + +GAIA keeps a small persistent config at `~/.gaia/config.json` (override the +location with the `GAIA_CONFIG_DIR` / `GAIA_CONFIG_FILE` environment variables). +Use it to set a **default model** once instead of passing `--model` on every +command. + +```bash +gaia config show # print current config + file path +gaia config get default_model # read one value +gaia config set default_model Qwen3.5-35B-A3B-GGUF # persist a default model +``` + +| Key | Default | Purpose | +|-----|---------|---------| +| `default_model` | _(unset)_ | Default model ID for `gaia chat` / `gaia llm` / `gaia prompt` | +| `default_device` | `gpu` | Default inference device (`cpu` / `gpu` / `npu`) | +| `profile` | `chat` | Last `gaia init` profile | + +### Default model precedence + +For `gaia chat`, `gaia llm`, and `gaia prompt`, the model is resolved highest-wins: + +1. An explicit `--model ` flag +2. `default_model` from `~/.gaia/config.json` +3. The command's built-in default (`DEFAULT_MODEL_NAME`) + +So `gaia config set default_model ` lets you skip `--model` entirely, while +`--model` still overrides it for a single run. For `gaia chat`, passing an +explicit `--device` selects a device-specific model and takes precedence over +the config default. + + +A **missing** config file is fine — GAIA falls back to built-in defaults. A +**corrupt** config file (invalid JSON) fails loudly with the file path and how +to recover, rather than silently reverting to defaults. + + +--- + ## Model Management ### Download Command diff --git a/src/gaia/cli.py b/src/gaia/cli.py index b7d394234..2268b5cfb 100644 --- a/src/gaia/cli.py +++ b/src/gaia/cli.py @@ -2804,6 +2804,30 @@ def build_parser(): connectors_cli.add_subparser(subparsers) + # Persistent CLI config (~/.gaia/config.json) — e.g. a default model so + # users don't have to pass --model on every chat/llm/prompt (issue #98). + config_parser = subparsers.add_parser( + "config", + help="Manage persistent GAIA config (~/.gaia/config.json)", + ) + config_subparsers = config_parser.add_subparsers( + dest="config_action", help="Config action" + ) + config_subparsers.add_parser( + "show", help="Show current config and the config file path" + ) + config_get_parser = config_subparsers.add_parser( + "get", help="Get a config value, e.g. `gaia config get default_model`" + ) + config_get_parser.add_argument("key", help="Config key to read") + config_set_parser = config_subparsers.add_parser( + "set", + help="Set a config value, e.g. `gaia config set default_model Qwen3.5-35B-A3B-GGUF`", + ) + config_set_parser.add_argument("key", help="Config key to set") + config_set_parser.add_argument("value", help="Value to assign") + config_parser.set_defaults(action="config") + # Init command (one-stop GAIA setup) # Note: Does not use parent_parser to avoid showing irrelevant global options init_parser = subparsers.add_parser( @@ -2934,6 +2958,29 @@ def main(): if hasattr(args, "logging_level"): log_manager.set_level("gaia", getattr(logging, args.logging_level)) + # Apply the persistent default_model (issue #98) for model-bearing commands. + # Precedence: explicit --model flag > config default_model > built-in default. + # An explicit `chat --device` requests a device-specific model, so it keeps + # precedence over the config default there. + if ( + args.action in ("prompt", "chat", "llm") + and getattr(args, "model", None) is None + ): + from gaia.config import GaiaConfig, GaiaConfigError + + try: + _cfg = GaiaConfig.load() + except GaiaConfigError as e: + print(f"❌ {e}", file=sys.stderr) + sys.exit(1) + # builtin_default=None: leave args.model unset when no config default so + # each command keeps applying its own built-in default downstream. + if not (args.action == "chat" and getattr(args, "device", None)): + resolved = _cfg.resolve_model(args.model, None) + if resolved: + args.model = resolved + log.debug("Using default_model from config: %s", resolved) + # Handle chat --ui: launch Agent UI server (backward compat) if args.action == "chat" and getattr(args, "ui", False): max_files = getattr(args, "max_indexed_files", 0) @@ -4216,6 +4263,11 @@ def main(): handle_mcp_command(args) return + # Handle Config command + if args.action == "config": + handle_config_command(args) + return + # Handle Cache command if args.action == "cache": handle_cache_command(args) @@ -5216,6 +5268,58 @@ def handle_knowledge_command(args): client.close() +def handle_config_command(args): + """Handle `gaia config show|get|set` (persistent ~/.gaia/config.json).""" + from gaia.config import GAIA_CONFIG_FILE, GaiaConfig, GaiaConfigError + + action = getattr(args, "config_action", None) + if not action: + print( + "No config action specified. Use: gaia config show|get|set", + file=sys.stderr, + ) + sys.exit(1) + + try: + cfg = GaiaConfig.load() + except GaiaConfigError as e: + print(f"❌ {e}", file=sys.stderr) + sys.exit(1) + + if action == "show": + exists = GAIA_CONFIG_FILE.exists() + print(f"Config file: {GAIA_CONFIG_FILE}") + print( + " (file exists)" + if exists + else " (file not created yet — showing built-in defaults)" + ) + for key in cfg.field_names(): + value = cfg.get(key) + print(f" {key} = {value if value is not None else '(unset)'}") + return + + if action == "get": + try: + value = cfg.get(args.key) + except GaiaConfigError as e: + print(f"❌ {e}", file=sys.stderr) + sys.exit(1) + print(value if value is not None else "") + return + + if action == "set": + try: + cfg.set(args.key, args.value) + except GaiaConfigError as e: + print(f"❌ {e}", file=sys.stderr) + sys.exit(1) + cfg.save() + print(f"✅ Set {args.key} = {args.value}") + print(f" Saved to {GAIA_CONFIG_FILE}") + return + + def handle_cache_command(args): """Handle the cache management command. diff --git a/src/gaia/config.py b/src/gaia/config.py index 3e816e7be..5e8e978df 100644 --- a/src/gaia/config.py +++ b/src/gaia/config.py @@ -4,19 +4,36 @@ """ GAIA persistent configuration. -Written by ``gaia init``, read at runtime by LemonadeManager and Agent UI. +Written by ``gaia init`` and ``gaia config set``, read at runtime by +LemonadeManager, the CLI model resolver, and the Agent UI. Stored at ``~/.gaia/config.json``. """ import json import logging -from dataclasses import asdict, dataclass +import os +from dataclasses import dataclass, fields from pathlib import Path +from typing import Any, List, Optional log = logging.getLogger(__name__) -GAIA_CONFIG_DIR = Path.home() / ".gaia" -GAIA_CONFIG_FILE = GAIA_CONFIG_DIR / "config.json" +# Location is overridable for tests / non-standard installs. GAIA_CONFIG_FILE +# wins outright; otherwise GAIA_CONFIG_DIR sets the directory holding +# config.json; otherwise the default ~/.gaia. +GAIA_CONFIG_DIR = Path(os.getenv("GAIA_CONFIG_DIR", str(Path.home() / ".gaia"))) +GAIA_CONFIG_FILE = Path( + os.getenv("GAIA_CONFIG_FILE", str(GAIA_CONFIG_DIR / "config.json")) +) + + +class GaiaConfigError(Exception): + """Raised when the persistent config exists but cannot be used. + + A *missing* config file is not an error (defaults are used); a *present + but corrupt/unreadable* file is, so it surfaces loudly instead of being + silently swallowed into defaults. + """ @dataclass @@ -28,31 +45,99 @@ class GaiaConfig: default_device: Default inference device ('cpu', 'gpu', 'npu'). GPU is the default — it's the most broadly available accelerated path on AMD hardware. + default_model: Persistent default model ID for model-bearing commands + (``gaia chat`` / ``gaia llm`` / ``gaia prompt``). ``None`` means + "fall back to each command's built-in default". An explicit + ``--model`` flag always wins over this value. """ profile: str = "chat" default_device: str = "gpu" + default_model: Optional[str] = None + + @classmethod + def field_names(cls) -> List[str]: + """Return the configurable field names (drives the CLI ``config`` cmd).""" + return [f.name for f in fields(cls)] @classmethod def load(cls) -> "GaiaConfig": - """Load config from ~/.gaia/config.json, or return defaults.""" + """Load config from ~/.gaia/config.json. + + Returns defaults when the file does not exist (a fresh install is not + an error). Raises :class:`GaiaConfigError` when the file exists but is + unreadable or not valid JSON — a corrupt config must fail loudly with + an actionable message, not silently degrade to defaults. + """ try: - data = json.loads(GAIA_CONFIG_FILE.read_text(encoding="utf-8")) - return cls( - profile=data.get("profile", "chat"), - default_device=data.get("default_device", "gpu"), - ) + text = GAIA_CONFIG_FILE.read_text(encoding="utf-8") except FileNotFoundError: return cls() - except (json.JSONDecodeError, TypeError, OSError) as e: - log.warning(f"Failed to load {GAIA_CONFIG_FILE}, using defaults: {e}") - return cls() + except OSError as e: + raise GaiaConfigError( + f"Cannot read GAIA config at {GAIA_CONFIG_FILE}: {e}. " + f"Check file permissions, or delete it to reset to defaults." + ) from e + + try: + data = json.loads(text) + except json.JSONDecodeError as e: + raise GaiaConfigError( + f"GAIA config at {GAIA_CONFIG_FILE} is not valid JSON: {e}. " + f"Fix the file by hand, or delete it to reset to defaults " + f"(then re-apply with `gaia config set ...`)." + ) from e + + if not isinstance(data, dict): + raise GaiaConfigError( + f"GAIA config at {GAIA_CONFIG_FILE} must be a JSON object, " + f"got {type(data).__name__}. Delete it to reset to defaults." + ) + + known = set(cls.field_names()) + kwargs = {k: v for k, v in data.items() if k in known} + return cls(**kwargs) def save(self) -> None: """Write config to ~/.gaia/config.json.""" - GAIA_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + # Create the file's own parent — GAIA_CONFIG_FILE can be overridden + # independently of GAIA_CONFIG_DIR via env vars. + GAIA_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + payload = {f.name: getattr(self, f.name) for f in fields(self)} GAIA_CONFIG_FILE.write_text( - json.dumps(asdict(self), indent=2) + "\n", + json.dumps(payload, indent=2) + "\n", encoding="utf-8", ) log.info(f"Saved GAIA config to {GAIA_CONFIG_FILE}") + + def get(self, key: str) -> Any: + """Return the value of a config field, raising on an unknown key.""" + if key not in self.field_names(): + raise GaiaConfigError( + f"Unknown config key '{key}'. " + f"Valid keys: {', '.join(self.field_names())}." + ) + return getattr(self, key) + + def set(self, key: str, value: str) -> None: + """Set a config field, raising on an unknown key.""" + if key not in self.field_names(): + raise GaiaConfigError( + f"Unknown config key '{key}'. " + f"Valid keys: {', '.join(self.field_names())}." + ) + setattr(self, key, value) + + def resolve_model( + self, cli_value: Optional[str], builtin_default: Optional[str] + ) -> Optional[str]: + """Resolve the effective model with documented precedence. + + Highest wins: explicit ``--model`` flag > config ``default_model`` > + the command's built-in default. + """ + if cli_value: + return cli_value + if self.default_model: + return self.default_model + return builtin_default diff --git a/src/gaia/installer/init_command.py b/src/gaia/installer/init_command.py index c9cafa18a..1f4555b1b 100644 --- a/src/gaia/installer/init_command.py +++ b/src/gaia/installer/init_command.py @@ -594,10 +594,11 @@ def run(self) -> int: try: from gaia.config import GaiaConfig - config = GaiaConfig( - profile=self.profile, - default_device="npu" if self.profile == "npu" else "gpu", - ) + # Load-then-update so a user-set default_model (or any future + # field) survives re-running `gaia init`. + config = GaiaConfig.load() + config.profile = self.profile + config.default_device = "npu" if self.profile == "npu" else "gpu" config.save() except Exception as e: log.warning(f"Failed to save config: {e}") diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py new file mode 100644 index 000000000..e367457ff --- /dev/null +++ b/tests/unit/test_cli_config.py @@ -0,0 +1,307 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT + +"""Tests for the persistent CLI config and the default_model resolution (#98). + +Covers: + * GaiaConfig.default_model round-trip + generic get/set + * resolve_model precedence: --model flag > config default_model > built-in + * `gaia config show|get|set` CLI integration + * The CLI injection that feeds config default_model into model-bearing commands +""" + +import json +import sys + +import pytest + +# ── GaiaConfig.default_model + generic accessors ────────────────────────── + + +class TestDefaultModelConfig: + def test_default_model_round_trip(self, tmp_path, monkeypatch): + from gaia import config as config_mod + from gaia.config import GaiaConfig + + config_file = tmp_path / "config.json" + monkeypatch.setattr(config_mod, "GAIA_CONFIG_FILE", config_file) + monkeypatch.setattr(config_mod, "GAIA_CONFIG_DIR", tmp_path) + + cfg = GaiaConfig(default_model="Qwen3.5-35B-A3B-GGUF") + cfg.save() + + data = json.loads(config_file.read_text()) + assert data["default_model"] == "Qwen3.5-35B-A3B-GGUF" + + loaded = GaiaConfig.load() + assert loaded.default_model == "Qwen3.5-35B-A3B-GGUF" + + def test_unknown_keys_in_file_are_ignored(self, tmp_path, monkeypatch): + from gaia import config as config_mod + from gaia.config import GaiaConfig + + config_file = tmp_path / "config.json" + config_file.write_text(json.dumps({"profile": "npu", "bogus_key": 1})) + monkeypatch.setattr(config_mod, "GAIA_CONFIG_FILE", config_file) + + cfg = GaiaConfig.load() + assert cfg.profile == "npu" + assert not hasattr(cfg, "bogus_key") + + def test_get_set_generic(self): + from gaia.config import GaiaConfig + + cfg = GaiaConfig() + cfg.set("default_model", "Foo-GGUF") + assert cfg.get("default_model") == "Foo-GGUF" + assert "default_model" in cfg.field_names() + + def test_get_unknown_key_raises(self): + from gaia.config import GaiaConfig, GaiaConfigError + + with pytest.raises(GaiaConfigError): + GaiaConfig().get("nope") + + def test_set_unknown_key_raises(self): + from gaia.config import GaiaConfig, GaiaConfigError + + with pytest.raises(GaiaConfigError): + GaiaConfig().set("nope", "x") + + def test_load_non_object_raises(self, tmp_path, monkeypatch): + from gaia import config as config_mod + from gaia.config import GaiaConfig, GaiaConfigError + + config_file = tmp_path / "config.json" + config_file.write_text("[1, 2, 3]") + monkeypatch.setattr(config_mod, "GAIA_CONFIG_FILE", config_file) + + with pytest.raises(GaiaConfigError): + GaiaConfig.load() + + +# ── resolve_model precedence ────────────────────────────────────────────── + + +class TestResolveModel: + def test_flag_wins(self): + from gaia.config import GaiaConfig + + cfg = GaiaConfig(default_model="config-model") + assert cfg.resolve_model("flag-model", "builtin") == "flag-model" + + def test_config_wins_over_builtin(self): + from gaia.config import GaiaConfig + + cfg = GaiaConfig(default_model="config-model") + assert cfg.resolve_model(None, "builtin") == "config-model" + + def test_builtin_when_nothing_set(self): + from gaia.config import GaiaConfig + + cfg = GaiaConfig() + assert cfg.resolve_model(None, "builtin") == "builtin" + + +# ── `gaia config` CLI integration ───────────────────────────────────────── + + +def _run_main(argv, monkeypatch, tmp_path): + """Run gaia.cli.main() with config paths redirected to tmp_path.""" + from gaia import config as config_mod + + monkeypatch.setattr(config_mod, "GAIA_CONFIG_FILE", tmp_path / "config.json") + monkeypatch.setattr(config_mod, "GAIA_CONFIG_DIR", tmp_path) + monkeypatch.setattr(sys, "argv", ["gaia"] + argv) + + from gaia.cli import main + + main() + + +class TestConfigCLI: + def test_set_then_get(self, monkeypatch, tmp_path, capsys): + _run_main( + ["config", "set", "default_model", "Qwen3.5-35B-A3B-GGUF"], + monkeypatch, + tmp_path, + ) + out = capsys.readouterr().out + assert "default_model = Qwen3.5-35B-A3B-GGUF" in out + + # Persisted to disk + data = json.loads((tmp_path / "config.json").read_text()) + assert data["default_model"] == "Qwen3.5-35B-A3B-GGUF" + + _run_main(["config", "get", "default_model"], monkeypatch, tmp_path) + out = capsys.readouterr().out + assert "Qwen3.5-35B-A3B-GGUF" in out + + def test_show_includes_path_and_fields(self, monkeypatch, tmp_path, capsys): + _run_main(["config", "show"], monkeypatch, tmp_path) + out = capsys.readouterr().out + assert str(tmp_path / "config.json") in out + assert "default_model" in out + assert "default_device" in out + + def test_set_unknown_key_exits_nonzero(self, monkeypatch, tmp_path, capsys): + with pytest.raises(SystemExit) as exc: + _run_main(["config", "set", "bogus", "x"], monkeypatch, tmp_path) + assert exc.value.code != 0 + assert "Unknown config key" in capsys.readouterr().err + + +# ── CLI default_model injection into model-bearing commands ─────────────── + + +class TestModelInjection: + def _capture_run_cli(self, monkeypatch): + captured = {} + + def fake_run_cli(action, **kwargs): + captured["action"] = action + captured["kwargs"] = kwargs + return None + + monkeypatch.setattr("gaia.cli.run_cli", fake_run_cli) + return captured + + def test_prompt_uses_config_default_model(self, monkeypatch, tmp_path): + from gaia import config as config_mod + from gaia.config import GaiaConfig + + monkeypatch.setattr(config_mod, "GAIA_CONFIG_FILE", tmp_path / "config.json") + monkeypatch.setattr(config_mod, "GAIA_CONFIG_DIR", tmp_path) + GaiaConfig(default_model="Configured-GGUF").save() + + captured = self._capture_run_cli(monkeypatch) + monkeypatch.setattr(sys, "argv", ["gaia", "prompt", "hello"]) + from gaia.cli import main + + main() + assert captured["kwargs"].get("model") == "Configured-GGUF" + + def test_flag_overrides_config(self, monkeypatch, tmp_path): + from gaia import config as config_mod + from gaia.config import GaiaConfig + + monkeypatch.setattr(config_mod, "GAIA_CONFIG_FILE", tmp_path / "config.json") + monkeypatch.setattr(config_mod, "GAIA_CONFIG_DIR", tmp_path) + GaiaConfig(default_model="Configured-GGUF").save() + + captured = self._capture_run_cli(monkeypatch) + monkeypatch.setattr( + sys, "argv", ["gaia", "prompt", "hello", "--model", "Flag-GGUF"] + ) + from gaia.cli import main + + main() + assert captured["kwargs"].get("model") == "Flag-GGUF" + + def test_no_config_leaves_model_unset(self, monkeypatch, tmp_path): + # No config file → no injection; downstream uses its built-in default. + from gaia import config as config_mod + + monkeypatch.setattr(config_mod, "GAIA_CONFIG_FILE", tmp_path / "missing.json") + monkeypatch.setattr(config_mod, "GAIA_CONFIG_DIR", tmp_path) + + captured = self._capture_run_cli(monkeypatch) + monkeypatch.setattr(sys, "argv", ["gaia", "prompt", "hello"]) + from gaia.cli import main + + main() + # None is filtered out of kwargs, so the key is simply absent. + assert "model" not in captured["kwargs"] + + def test_chat_uses_config_default_model(self, monkeypatch, tmp_path): + from gaia import config as config_mod + from gaia.config import GaiaConfig + + monkeypatch.setattr(config_mod, "GAIA_CONFIG_FILE", tmp_path / "config.json") + monkeypatch.setattr(config_mod, "GAIA_CONFIG_DIR", tmp_path) + GaiaConfig(default_model="Configured-GGUF").save() + + captured = self._capture_run_cli(monkeypatch) + monkeypatch.setattr(sys, "argv", ["gaia", "chat", "--query", "hi"]) + from gaia.cli import main + + main() + assert captured["kwargs"].get("model") == "Configured-GGUF" + + def test_chat_explicit_device_skips_config_default(self, monkeypatch, tmp_path): + # An explicit `chat --device` selects a device-specific model and must + # take precedence over the config default (model stays unset here). + from gaia import config as config_mod + from gaia.config import GaiaConfig + + monkeypatch.setattr(config_mod, "GAIA_CONFIG_FILE", tmp_path / "config.json") + monkeypatch.setattr(config_mod, "GAIA_CONFIG_DIR", tmp_path) + GaiaConfig(default_model="Configured-GGUF").save() + + captured = self._capture_run_cli(monkeypatch) + monkeypatch.setattr( + sys, "argv", ["gaia", "chat", "--query", "hi", "--device", "npu"] + ) + from gaia.cli import main + + main() + assert "model" not in captured["kwargs"] + + def test_corrupt_config_fails_loudly_on_model_command( + self, monkeypatch, tmp_path, capsys + ): + # A corrupt config must abort a model-bearing command loudly, not + # silently fall through to the built-in default. + from gaia import config as config_mod + + bad = tmp_path / "config.json" + bad.write_text("not valid json{{{") + monkeypatch.setattr(config_mod, "GAIA_CONFIG_FILE", bad) + monkeypatch.setattr(config_mod, "GAIA_CONFIG_DIR", tmp_path) + + self._capture_run_cli(monkeypatch) + monkeypatch.setattr(sys, "argv", ["gaia", "prompt", "hello"]) + from gaia.cli import main + + with pytest.raises(SystemExit) as exc: + main() + assert exc.value.code != 0 + assert str(bad) in capsys.readouterr().err + + +# ── Config-file location is overridable via environment variables ───────── + + +class TestConfigPathEnvOverride: + def test_env_overrides_file_path(self, tmp_path, monkeypatch): + import importlib + + target = tmp_path / "custom" / "myconfig.json" + monkeypatch.setenv("GAIA_CONFIG_FILE", str(target)) + + from gaia import config as config_mod + + config_mod = importlib.reload(config_mod) + try: + assert config_mod.GAIA_CONFIG_FILE == target + config_mod.GaiaConfig(default_model="Env-GGUF").save() + assert target.exists() + assert config_mod.GaiaConfig.load().default_model == "Env-GGUF" + finally: + # Restore module-level constants for any later tests in the session. + monkeypatch.delenv("GAIA_CONFIG_FILE", raising=False) + importlib.reload(config_mod) + + def test_env_dir_override(self, tmp_path, monkeypatch): + import importlib + + monkeypatch.setenv("GAIA_CONFIG_DIR", str(tmp_path / "cfgdir")) + + from gaia import config as config_mod + + config_mod = importlib.reload(config_mod) + try: + assert config_mod.GAIA_CONFIG_FILE == tmp_path / "cfgdir" / "config.json" + finally: + monkeypatch.delenv("GAIA_CONFIG_DIR", raising=False) + importlib.reload(config_mod) diff --git a/tests/unit/test_npu_device_support.py b/tests/unit/test_npu_device_support.py index c6836c89a..7e5b7cc11 100644 --- a/tests/unit/test_npu_device_support.py +++ b/tests/unit/test_npu_device_support.py @@ -115,6 +115,7 @@ def test_defaults(self): cfg = GaiaConfig() assert cfg.profile == "chat" assert cfg.default_device == "gpu" + assert cfg.default_model is None def test_save_and_load(self, tmp_path): from gaia.config import GaiaConfig @@ -146,13 +147,18 @@ def test_load_missing_file(self, tmp_path): assert cfg.default_device == "gpu" def test_load_corrupt_file(self, tmp_path): - from gaia.config import GaiaConfig + # A corrupt config must fail loudly (no silent fallback to defaults). + import pytest + + from gaia.config import GaiaConfig, GaiaConfigError bad_file = tmp_path / "config.json" bad_file.write_text("not valid json{{{") with patch("gaia.config.GAIA_CONFIG_FILE", bad_file): - cfg = GaiaConfig.load() - assert cfg.profile == "chat" # falls back to defaults + with pytest.raises(GaiaConfigError) as exc: + GaiaConfig.load() + # Error names the file and how to recover. + assert str(bad_file) in str(exc.value) # ── LemonadeClient backend methods ─────────────────────────────────────── From 67e401db9a67a47d697e33a57cd96e943d7fe4be Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Thu, 25 Jun 2026 14:06:04 -0700 Subject: [PATCH 2/4] fix(init): repair a corrupt config instead of leaving it in place --- src/gaia/installer/init_command.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/gaia/installer/init_command.py b/src/gaia/installer/init_command.py index 1f4555b1b..cccc636a7 100644 --- a/src/gaia/installer/init_command.py +++ b/src/gaia/installer/init_command.py @@ -592,11 +592,17 @@ def run(self) -> int: # Persist profile choice to ~/.gaia/config.json try: - from gaia.config import GaiaConfig + from gaia.config import GaiaConfig, GaiaConfigError # Load-then-update so a user-set default_model (or any future - # field) survives re-running `gaia init`. - config = GaiaConfig.load() + # field) survives re-running `gaia init`. init is also the + # natural recovery path, so if the existing file is corrupt, + # reset to a fresh config rather than leaving the bad file. + try: + config = GaiaConfig.load() + except GaiaConfigError as e: + log.warning(f"Resetting corrupt config: {e}") + config = GaiaConfig() config.profile = self.profile config.default_device = "npu" if self.profile == "npu" else "gpu" config.save() From 8b6a45aa9c2cfd64f587a4ad00746a4ae0485bf7 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Thu, 25 Jun 2026 16:52:13 -0700 Subject: [PATCH 3/4] test(config): cover llm injection, get/show edge cases, unreadable file; doc custom config file --- docs/reference/cli.mdx | 13 ++++++ tests/unit/test_cli_config.py | 80 +++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 428ed5f06..c77d8c49e 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -1520,6 +1520,19 @@ A **missing** config file is fine — GAIA falls back to built-in defaults. A to recover, rather than silently reverting to defaults. +### Using a custom config file + +Point GAIA at a config file anywhere on disk with `GAIA_CONFIG_FILE` (or set +just the directory with `GAIA_CONFIG_DIR`). This is handy for per-project +configs or keeping work and personal defaults separate — every `gaia` command, +including `gaia config` itself, honors it: + +```bash +# Write to and read from a custom location +GAIA_CONFIG_FILE=./project.gaia.json gaia config set default_model Qwen3.5-35B-A3B-GGUF +GAIA_CONFIG_FILE=./project.gaia.json gaia llm "hello" # uses that file's default_model +``` + --- ## Model Management diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index e367457ff..5b154b26f 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -79,6 +79,36 @@ def test_load_non_object_raises(self, tmp_path, monkeypatch): with pytest.raises(GaiaConfigError): GaiaConfig.load() + def test_load_unreadable_raises(self, tmp_path, monkeypatch): + # A path that exists but can't be read as a file (here: a directory) + # is an OSError, which must surface as a loud GaiaConfigError. + from gaia import config as config_mod + from gaia.config import GaiaConfig, GaiaConfigError + + a_dir = tmp_path / "config.json" + a_dir.mkdir() + monkeypatch.setattr(config_mod, "GAIA_CONFIG_FILE", a_dir) + + with pytest.raises(GaiaConfigError) as exc: + GaiaConfig.load() + assert str(a_dir) in str(exc.value) + + def test_empty_default_model_resolves_to_builtin(self): + # An empty string is falsy and must not shadow the built-in default. + from gaia.config import GaiaConfig + + cfg = GaiaConfig(default_model="") + assert cfg.resolve_model(None, "builtin") == "builtin" + + def test_none_default_model_round_trips(self, tmp_path, monkeypatch): + from gaia import config as config_mod + from gaia.config import GaiaConfig + + monkeypatch.setattr(config_mod, "GAIA_CONFIG_FILE", tmp_path / "config.json") + monkeypatch.setattr(config_mod, "GAIA_CONFIG_DIR", tmp_path) + GaiaConfig(profile="npu").save() # default_model stays None + assert GaiaConfig.load().default_model is None + # ── resolve_model precedence ────────────────────────────────────────────── @@ -150,6 +180,30 @@ def test_set_unknown_key_exits_nonzero(self, monkeypatch, tmp_path, capsys): assert exc.value.code != 0 assert "Unknown config key" in capsys.readouterr().err + def test_get_unknown_key_exits_nonzero(self, monkeypatch, tmp_path, capsys): + with pytest.raises(SystemExit) as exc: + _run_main(["config", "get", "bogus"], monkeypatch, tmp_path) + assert exc.value.code != 0 + assert "Unknown config key" in capsys.readouterr().err + + def test_get_unset_value_prints_empty(self, monkeypatch, tmp_path, capsys): + # default_model is unset → `get` prints an empty line, not "None". + _run_main(["config", "get", "default_model"], monkeypatch, tmp_path) + out = capsys.readouterr().out + assert out.strip() == "" + assert "None" not in out + + def test_no_subaction_exits_nonzero(self, monkeypatch, tmp_path, capsys): + with pytest.raises(SystemExit) as exc: + _run_main(["config"], monkeypatch, tmp_path) + assert exc.value.code != 0 + assert "No config action" in capsys.readouterr().err + + def test_show_marks_unset_default_model(self, monkeypatch, tmp_path, capsys): + _run_main(["config", "show"], monkeypatch, tmp_path) + out = capsys.readouterr().out + assert "default_model = (unset)" in out + # ── CLI default_model injection into model-bearing commands ─────────────── @@ -268,6 +322,32 @@ def test_corrupt_config_fails_loudly_on_model_command( assert exc.value.code != 0 assert str(bad) in capsys.readouterr().err + def test_llm_uses_config_default_model(self, monkeypatch, tmp_path): + # `gaia llm` is dispatched separately from run_cli, so capture the + # model at the llm-app boundary instead. + from gaia import config as config_mod + from gaia.config import GaiaConfig + + monkeypatch.setattr(config_mod, "GAIA_CONFIG_FILE", tmp_path / "config.json") + monkeypatch.setattr(config_mod, "GAIA_CONFIG_DIR", tmp_path) + GaiaConfig(default_model="Configured-GGUF").save() + + monkeypatch.setattr( + "gaia.cli.initialize_lemonade_for_agent", lambda **kw: (True, None) + ) + captured = {} + + def fake_llm(**kwargs): + captured.update(kwargs) + return "ok" + + monkeypatch.setattr("gaia.apps.llm.app.main", fake_llm) + monkeypatch.setattr(sys, "argv", ["gaia", "llm", "hello"]) + from gaia.cli import main + + main() + assert captured.get("model") == "Configured-GGUF" + # ── Config-file location is overridable via environment variables ───────── From 2266db63d00a16c52752724c6697cffeb9132f16 Mon Sep 17 00:00:00 2001 From: Kalin Ovtcharov Date: Thu, 25 Jun 2026 17:06:31 -0700 Subject: [PATCH 4/4] feat(config): add --config flag to point chat/llm/prompt and gaia config at a custom file --- docs/reference/cli.mdx | 17 +++++--- src/gaia/cli.py | 48 +++++++++++++++----- src/gaia/config.py | 37 ++++++++++------ tests/unit/test_cli_config.py | 82 +++++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 31 deletions(-) diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index c77d8c49e..38f6a24f2 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -1522,17 +1522,22 @@ to recover, rather than silently reverting to defaults. ### Using a custom config file -Point GAIA at a config file anywhere on disk with `GAIA_CONFIG_FILE` (or set -just the directory with `GAIA_CONFIG_DIR`). This is handy for per-project -configs or keeping work and personal defaults separate — every `gaia` command, -including `gaia config` itself, honors it: +Point GAIA at a config file anywhere on disk — handy for per-project configs or +keeping work and personal defaults separate. Two equivalent ways: ```bash -# Write to and read from a custom location +# 1. The --config flag (on `gaia config`, `gaia chat`, `gaia llm`, `gaia prompt`) +gaia config set default_model Qwen3.5-35B-A3B-GGUF --config ./project.gaia.json +gaia llm "hello" --config ./project.gaia.json # uses that file's default_model + +# 2. The GAIA_CONFIG_FILE env var (or GAIA_CONFIG_DIR for just the directory) GAIA_CONFIG_FILE=./project.gaia.json gaia config set default_model Qwen3.5-35B-A3B-GGUF -GAIA_CONFIG_FILE=./project.gaia.json gaia llm "hello" # uses that file's default_model +GAIA_CONFIG_FILE=./project.gaia.json gaia llm "hello" ``` +When both are given, `--config` wins over `GAIA_CONFIG_FILE`, which wins over the +default `~/.gaia/config.json`. + --- ## Model Management diff --git a/src/gaia/cli.py b/src/gaia/cli.py index 2268b5cfb..45daba215 100644 --- a/src/gaia/cli.py +++ b/src/gaia/cli.py @@ -1242,6 +1242,17 @@ def build_parser(): default="INFO", help="Set the logging level (default: INFO)", ) + # Shared --config flag. Attached only to commands that read the persistent + # config (chat/llm/prompt + the `gaia config` subcommands) — NOT to + # parent_parser, since `gaia summarize` already defines its own --config. + config_path_parser = argparse.ArgumentParser(add_help=False) + config_path_parser.add_argument( + "--config", + default=None, + metavar="PATH", + help="Path to a GAIA config file (default: ~/.gaia/config.json or " + "$GAIA_CONFIG_FILE). Used to resolve default_model.", + ) # Generic LLM backend options (available to all agents) parent_parser.add_argument( @@ -1312,7 +1323,9 @@ def build_parser(): # Add prompt-specific options prompt_parser = subparsers.add_parser( - "prompt", help="Send a single prompt to Gaia", parents=[parent_parser] + "prompt", + help="Send a single prompt to Gaia", + parents=[parent_parser, config_path_parser], ) prompt_parser.add_argument( "message", @@ -1334,7 +1347,7 @@ def build_parser(): chat_parser = subparsers.add_parser( "chat", help="Interactive chat with RAG, file search, and shell execution", - parents=[parent_parser], + parents=[parent_parser, config_path_parser], ) chat_parser.add_argument( "--query", @@ -2115,7 +2128,7 @@ def build_parser(): llm_parser = subparsers.add_parser( "llm", help="Run simple LLM queries using LLMClient wrapper", - parents=[parent_parser], + parents=[parent_parser, config_path_parser], ) llm_parser.add_argument("query", help="The query/prompt to send to the LLM") llm_parser.add_argument( @@ -2814,15 +2827,20 @@ def build_parser(): dest="config_action", help="Config action" ) config_subparsers.add_parser( - "show", help="Show current config and the config file path" + "show", + help="Show current config and the config file path", + parents=[config_path_parser], ) config_get_parser = config_subparsers.add_parser( - "get", help="Get a config value, e.g. `gaia config get default_model`" + "get", + help="Get a config value, e.g. `gaia config get default_model`", + parents=[config_path_parser], ) config_get_parser.add_argument("key", help="Config key to read") config_set_parser = config_subparsers.add_parser( "set", help="Set a config value, e.g. `gaia config set default_model Qwen3.5-35B-A3B-GGUF`", + parents=[config_path_parser], ) config_set_parser.add_argument("key", help="Config key to set") config_set_parser.add_argument("value", help="Value to assign") @@ -2969,7 +2987,7 @@ def main(): from gaia.config import GaiaConfig, GaiaConfigError try: - _cfg = GaiaConfig.load() + _cfg = GaiaConfig.load(getattr(args, "config", None)) except GaiaConfigError as e: print(f"❌ {e}", file=sys.stderr) sys.exit(1) @@ -3095,6 +3113,9 @@ def main(): kwargs = { k: v for k, v in vars(args).items() if v is not None and k != "action" } + # --config is only an input to model resolution (already applied to + # args.model above); it's not a runtime parameter for the agents. + kwargs.pop("config", None) log.debug(f"Executing {args.action} with parameters: {kwargs}") try: result = run_cli(args.action, **kwargs) @@ -5270,7 +5291,10 @@ def handle_knowledge_command(args): def handle_config_command(args): """Handle `gaia config show|get|set` (persistent ~/.gaia/config.json).""" - from gaia.config import GAIA_CONFIG_FILE, GaiaConfig, GaiaConfigError + from gaia.config import GaiaConfig, GaiaConfigError + + path = getattr(args, "config", None) + config_file = GaiaConfig.config_path(path) action = getattr(args, "config_action", None) if not action: @@ -5281,14 +5305,14 @@ def handle_config_command(args): sys.exit(1) try: - cfg = GaiaConfig.load() + cfg = GaiaConfig.load(path) except GaiaConfigError as e: print(f"❌ {e}", file=sys.stderr) sys.exit(1) if action == "show": - exists = GAIA_CONFIG_FILE.exists() - print(f"Config file: {GAIA_CONFIG_FILE}") + exists = config_file.exists() + print(f"Config file: {config_file}") print( " (file exists)" if exists @@ -5314,9 +5338,9 @@ def handle_config_command(args): except GaiaConfigError as e: print(f"❌ {e}", file=sys.stderr) sys.exit(1) - cfg.save() + cfg.save(path) print(f"✅ Set {args.key} = {args.value}") - print(f" Saved to {GAIA_CONFIG_FILE}") + print(f" Saved to {config_file}") return diff --git a/src/gaia/config.py b/src/gaia/config.py index 5e8e978df..d909e7b79 100644 --- a/src/gaia/config.py +++ b/src/gaia/config.py @@ -60,22 +60,32 @@ def field_names(cls) -> List[str]: """Return the configurable field names (drives the CLI ``config`` cmd).""" return [f.name for f in fields(cls)] + @staticmethod + def config_path(path: Optional[Path] = None) -> Path: + """Resolve the config file path. + + An explicit ``path`` (e.g. from ``--config``) wins; otherwise the + module default (``GAIA_CONFIG_FILE``, itself env-overridable). + """ + return Path(path) if path else GAIA_CONFIG_FILE + @classmethod - def load(cls) -> "GaiaConfig": - """Load config from ~/.gaia/config.json. + def load(cls, path: Optional[Path] = None) -> "GaiaConfig": + """Load config from the resolved config file. Returns defaults when the file does not exist (a fresh install is not an error). Raises :class:`GaiaConfigError` when the file exists but is unreadable or not valid JSON — a corrupt config must fail loudly with an actionable message, not silently degrade to defaults. """ + config_file = cls.config_path(path) try: - text = GAIA_CONFIG_FILE.read_text(encoding="utf-8") + text = config_file.read_text(encoding="utf-8") except FileNotFoundError: return cls() except OSError as e: raise GaiaConfigError( - f"Cannot read GAIA config at {GAIA_CONFIG_FILE}: {e}. " + f"Cannot read GAIA config at {config_file}: {e}. " f"Check file permissions, or delete it to reset to defaults." ) from e @@ -83,14 +93,14 @@ def load(cls) -> "GaiaConfig": data = json.loads(text) except json.JSONDecodeError as e: raise GaiaConfigError( - f"GAIA config at {GAIA_CONFIG_FILE} is not valid JSON: {e}. " + f"GAIA config at {config_file} is not valid JSON: {e}. " f"Fix the file by hand, or delete it to reset to defaults " f"(then re-apply with `gaia config set ...`)." ) from e if not isinstance(data, dict): raise GaiaConfigError( - f"GAIA config at {GAIA_CONFIG_FILE} must be a JSON object, " + f"GAIA config at {config_file} must be a JSON object, " f"got {type(data).__name__}. Delete it to reset to defaults." ) @@ -98,17 +108,18 @@ def load(cls) -> "GaiaConfig": kwargs = {k: v for k, v in data.items() if k in known} return cls(**kwargs) - def save(self) -> None: - """Write config to ~/.gaia/config.json.""" - # Create the file's own parent — GAIA_CONFIG_FILE can be overridden - # independently of GAIA_CONFIG_DIR via env vars. - GAIA_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + def save(self, path: Optional[Path] = None) -> None: + """Write config to the resolved config file.""" + config_file = self.config_path(path) + # Create the file's own parent — the path can be overridden via the + # --config flag or GAIA_CONFIG_FILE, independent of GAIA_CONFIG_DIR. + config_file.parent.mkdir(parents=True, exist_ok=True) payload = {f.name: getattr(self, f.name) for f in fields(self)} - GAIA_CONFIG_FILE.write_text( + config_file.write_text( json.dumps(payload, indent=2) + "\n", encoding="utf-8", ) - log.info(f"Saved GAIA config to {GAIA_CONFIG_FILE}") + log.info(f"Saved GAIA config to {config_file}") def get(self, key: str) -> Any: """Return the value of a config field, raising on an unknown key.""" diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index 5b154b26f..324c7dd28 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -385,3 +385,85 @@ def test_env_dir_override(self, tmp_path, monkeypatch): finally: monkeypatch.delenv("GAIA_CONFIG_DIR", raising=False) importlib.reload(config_mod) + + +# ── `--config PATH` flag points at an explicit config file ──────────────── + + +class TestConfigFlag: + def test_set_get_show_use_custom_path(self, monkeypatch, tmp_path, capsys): + custom = tmp_path / "custom.json" + + _run_main( + [ + "config", + "set", + "default_model", + "Flag-Path-GGUF", + "--config", + str(custom), + ], + monkeypatch, + tmp_path, + ) + # Written to the --config path, NOT the default location. + assert json.loads(custom.read_text())["default_model"] == "Flag-Path-GGUF" + assert not (tmp_path / "config.json").exists() + + _run_main( + ["config", "get", "default_model", "--config", str(custom)], + monkeypatch, + tmp_path, + ) + assert "Flag-Path-GGUF" in capsys.readouterr().out + + _run_main(["config", "show", "--config", str(custom)], monkeypatch, tmp_path) + assert str(custom) in capsys.readouterr().out + + def test_flag_injects_model_for_prompt(self, monkeypatch, tmp_path): + from gaia import config as config_mod + from gaia.config import GaiaConfig + + # Custom file has the model; the *default* location is empty, so the + # injected model can only have come from --config. + custom = tmp_path / "custom.json" + GaiaConfig(default_model="Flag-Path-GGUF").save(custom) + monkeypatch.setattr(config_mod, "GAIA_CONFIG_FILE", tmp_path / "default.json") + monkeypatch.setattr(config_mod, "GAIA_CONFIG_DIR", tmp_path) + + captured = {} + + def fake_run_cli(action, **kwargs): + captured["kwargs"] = kwargs + + monkeypatch.setattr("gaia.cli.run_cli", fake_run_cli) + monkeypatch.setattr( + sys, "argv", ["gaia", "prompt", "hi", "--config", str(custom)] + ) + from gaia.cli import main + + main() + assert captured["kwargs"].get("model") == "Flag-Path-GGUF" + # --config is consumed for resolution, not forwarded as a runtime param. + assert "config" not in captured["kwargs"] + + def test_flag_overrides_env_and_default(self, monkeypatch, tmp_path, capsys): + # --config wins over GAIA_CONFIG_FILE for `gaia config` operations. + from gaia import config as config_mod + from gaia.config import GaiaConfig + + env_file = tmp_path / "env.json" + GaiaConfig(default_model="Env-GGUF").save(env_file) + custom = tmp_path / "flag.json" + GaiaConfig(default_model="Flag-GGUF").save(custom) + monkeypatch.setattr(config_mod, "GAIA_CONFIG_FILE", env_file) + + monkeypatch.setattr( + sys, + "argv", + ["gaia", "config", "get", "default_model", "--config", str(custom)], + ) + from gaia.cli import main + + main() + assert "Flag-GGUF" in capsys.readouterr().out