Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 43 additions & 3 deletions docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -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 <id>` flag
2. `default_model` from `~/.gaia/config.json`
3. The command's built-in default (`DEFAULT_MODEL_NAME`)

So `gaia config set default_model <id>` 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.

<Note>
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.
</Note>

---

## Model Management

### Download Command
Expand Down
104 changes: 104 additions & 0 deletions src/gaia/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand Down
115 changes: 100 additions & 15 deletions src/gaia/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
17 changes: 12 additions & 5 deletions src/gaia/installer/init_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -592,12 +592,19 @@ def run(self) -> int:

# Persist profile choice to ~/.gaia/config.json
try:
from gaia.config import GaiaConfig
from gaia.config import GaiaConfig, GaiaConfigError

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`. 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()
except Exception as e:
log.warning(f"Failed to save config: {e}")
Expand Down
Loading
Loading