LLM model selection
July 14, 2026 ยท View on GitHub
Astrid routes every prompt to whichever LLM model the current principal has chosen. The choice is stored per-principal by the registry capsule, survives daemon restarts, and can be changed at any time without restarting the daemon or reinstalling a capsule.
This document covers four topics:
- Picking a model at runtime -- the
astrid modelscommands - Provider discovery -- where the model list comes from
- Install-time onboarding -- what
astrid init --distrowalks you through - When no model is selected -- the error you see and how to fix it
Running a local LLM? The SSRF airlock blocks runtime egress to loopback and private-network addresses by default. See Local LLM endpoints and the SSRF airlock for the operator config that lifts the block for specific endpoints.
Picking a model at runtime
The models verb is provided by the registry capsule. It is reachable through
two equivalent paths:
astrid models <subcommand> [args] # bare shorthand
astrid capsule models <subcommand> [args] # canonical capsule-verb form
Both invoke exactly the same handler over the capsule IPC bus. The bare form
resolves to the capsule verb because models is not a built-in astrid
top-level subcommand -- the external-subcommand catch-all in the CLI dispatches
any unrecognised word to the daemon's command registry.
List available models
astrid models list
astrid models list --json
Without --json, the output is a human-readable table with one line per
model. The active model for the current principal is marked with *:
* openai:gpt-5.5 OpenAI GPT-5.5
openai:gpt-5.4 OpenAI GPT-5.4
openai:o3 OpenAI o3
With --json, each entry is a full JSON object carrying capability metadata:
[
{
"id": "openai:gpt-5.5",
"description": "OpenAI GPT-5.5",
"request_topic": "llm.v1.request.generate.openai",
"stream_topic": "llm.v1.stream.openai",
"capabilities": ["text", "tools", "vision", "structured_output", "reasoning"],
"context_window": 1050000,
"max_output_tokens": 128000
}
]
Show the active model
astrid models current
astrid models current --json
Without --json: prints the canonical model id (e.g. openai:gpt-5.5), or
none if nothing is selected.
With --json: prints { "active": <full entry object> }, or
{ "active": null } when nothing is selected.
Set the active model
astrid models set <id>
<id> is resolved in order:
- Exact canonical match -- if your input exactly equals a canonical
<capsule>:<model>id (e.g.openai:gpt-5.5), it binds immediately. - Bare model name -- if your input uniquely matches the model portion of
exactly one entry, it binds (e.g.
gpt-5.5when only theopenaicapsule is installed). - Qualified pass -- if the bare pass is ambiguous and your input contains
a colon, it is split on the first colon into
<capsule>:<model>to disambiguate.
If the bare name matches more than one provider, the error tells you which qualified ids to choose from:
ambiguous model; candidates: openai:gpt-5.4, openai-compat:gpt-5.4
Pass the qualified form to disambiguate:
astrid models set openai:gpt-5.4
astrid models set openai-compat:gpt-5.4
Ollama note. Ollama model names embed a colon (e.g. llama3.3:70b). These
work as bare ids because the resolver splits only on the FIRST colon -- the bare
pass sees llama3.3:70b as a single bare model name when it is uniquely served
by one provider. If two providers expose the same Ollama model, use the fully
qualified form: ollama:llama3.3:70b.
On success the command prints:
active model set to openai:gpt-5.5
The selection is persisted immediately in the registry capsule's per-principal KV and takes effect on the next prompt.
Clear the active model
astrid models unset
Clears the selection. The next prompt fails with the no-model error (see
When no model is selected) until you run
astrid models set again.
Per-principal scope
Model selection is per-principal. Each principal's selection is stored
independently in the registry capsule's KV, scoped under that principal's
home directory. Changing the active model as default does not affect what
another principal (e.g. claude-code) is using, and vice versa.
If you connect as a non-default principal and no model has been selected for that principal yet, the registry runs a discovery pass and auto-selects a sensible default (the first-discovered capsule's default-hint model) so the first prompt is not blocked.
Machine-readable output
Any models subcommand accepts --json to emit JSON instead of human-readable
text. This is useful for scripts and dashboards:
# One-liner: print the currently selected model id
astrid models current --json | jq -r '.active.id // "none"'
# List all available models as a JSON array
astrid models list --json
Exit code is 0 on success, 1 on any error (unknown model, ambiguous input, etc.).
Provider discovery
The model list is not static. When astrid models list runs (or when the
daemon boots), the registry capsule publishes a llm.v1.request.describe
broadcast and drains responses from every installed LLM provider capsule for a
500 ms window. Each provider responds with a list of ProviderEntry objects.
One entry per model. Each provider emits one entry per model it can serve.
The entry's id is the bare model name (e.g. gpt-5.5); the registry stamps
the canonical form (openai:gpt-5.5) using the provider's authenticated
capsule id. The entry includes:
request_topic-- the IPC topic the provider subscribes to for generate requestscapabilities-- what the model supports (text,tools,vision,structured_output,reasoning)context_windowandmax_output_tokens
Anti-shadowing: a provider's entry is only accepted when its self-reported
request_topic suffix authenticates against the kernel-stamped source id
(UUIDv5 of the capsule package name). A capsule cannot emit entries for a
provider it does not own.
openai capsule
The astrid-capsule-openai capsule targets OpenAI's Responses API
(POST /v1/responses). It advertises models as follows:
- At describe time, it calls
GET {base_url}/v1/modelswith the configured bearer (theapi_keyenv field). - Each returned id is enriched from a hardcoded capability catalog: exact match
first, then longest-prefix match for dated snapshots
(e.g.
gpt-5.4-2026-03-05resolves to thegpt-5.4catalog row). Unknown ids get conservative defaults (context_window: 128000,max_output_tokens: 16384, no vision/reasoning/structured-output). - The configured
modelenv field is alwaysentry[0]in the response -- it is hoisted to the front (or prepended if the live list does not include it), so the registry's auto-select picks the operator's intended default. - If the live query fails (missing key, network error, non-200 response), the
capsule falls back to the full hardcoded catalog -- the same models you see
in
astrid models liston an offline install.
The default model env is gpt-5.5 (overridable via
astrid capsule config astrid-capsule-openai).
openai-compat capsule
The astrid-capsule-openai-compat capsule talks to any OpenAI-compatible HTTP
server (LM Studio, vLLM, llama.cpp, a remote OpenAI-compatible API) via
POST /v1/chat/completions. It uses the same discovery pattern:
- Calls
GET {base_url}/v1/modelswith the configured bearer at describe time. - Returns the live list enriched where possible. For local servers that expose
no capability info, entries get conservative defaults; the operator can
override
context_windowandmax_output_tokensviaastrid capsule config astrid-capsule-openai-compat.
Because the openai-compat capsule connects to an arbitrary endpoint, model ids
can be anything the server returns -- including names with embedded colons
(e.g. llama3.3:70b).
Local LLM endpoints and the SSRF airlock
The astrid:http host capability runs all capsule outbound HTTP through an SSRF
airlock. Before every request, the airlock resolves the target hostname and
rejects it if the resolved address falls within any of the following ranges:
- Loopback:
127.0.0.0/8,::1 - Private:
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 - Link-local:
169.254.0.0/16,fe80::/10
This is on by default and cannot be widened from inside a capsule.
Consequence for a local LLM server. If you point the openai-compat capsule
at a server running on the same machine or on a LAN box -- LM Studio on
127.0.0.1:1234, Ollama on 127.0.0.1:11434, llama.cpp, or a box at
192.168.x.x -- the airlock blocks both the describe call (GET /v1/models)
and every generate request at runtime. The capsule's model list comes back empty
and every prompt fails. A remote or public base_url (e.g. api.openai.com or
a cloud-hosted OpenAI-compatible endpoint) is unaffected.
Install-time picker vs. runtime. The onboarding step that fetches
/v1/models to build the model selection menu runs natively in the installer
process, not inside the WASM sandbox and not through the airlock. This means the
install-time model picker works fine against a local endpoint: you can select a
model, the install succeeds, and the config is written -- but every subsequent
runtime prompt fails because the capsule's HTTP is blocked. The gap between a
successful install and failing prompts is intentional (the installer needs to
reach local endpoints to enumerate models), but it can be confusing. If you see
an empty model list or prompt errors after configuring a local server, the
airlock is the most likely cause.
Operator exemption. To let the openai-compat capsule reach specific local
endpoints at runtime, an operator adds a [security.capsule_local_egress]
table to config.toml. This is an operator-only setting: a capsule's own
Capsule.toml cannot set it, and a project or workspace config layer cannot
widen it either.
[security.capsule_local_egress]
# host:port (or host:*) endpoints this capsule may reach even though they
# resolve to a local address.
"astrid-capsule-openai-compat" = ["127.0.0.1:1234", "192.168.1.50:11434"]
The exemption is scoped to the listed host:port pairs. It lifts the airlock
only for those entries -- it does not widen the capsule's net allowlist (which
is already * for openai-compat) or grant any other capability.
Wildcard port: "127.0.0.1:*" exempts all ports on that host. Prefer listing
exact ports to minimise exposure.
Install-time onboarding
astrid init --distro <source> (and astrid distro apply <source>) walks you
through LLM provider setup when the selected distro includes provider capsules.
Provider multi-select
LLM provider capsules declare group = "llm" in the Distro.toml. The
installer presents them as a multi-select list:
Which LLM providers do you want to set up?
[x] OpenAI (astrid-capsule-openai)
[ ] OpenAI-compatible (astrid-capsule-openai-compat)
Select as many as you want. Each selected provider is then onboarded in sequence.
Per-provider configuration
For each selected provider, the installer prompts for credentials and a default model:
This capsule requires configuration:
Enter the OpenAI API base URL [https://api.openai.com]:
Enter your OpenAI API key (secret, input hidden): sk-...
After credentials are collected, the installer fetches GET {base_url}/v1/models
with the bearer you just entered to build a live numbered menu of models:
Default model to select:
1: gpt-5.5
2: gpt-5.4
3: gpt-5.4-mini
...
Select [1-N]:
The configured default is pre-selected (item 1). If the endpoint cannot be reached during install, the installer falls back to a free-text entry prompt for the model id.
Local server users: the install-time fetch above runs natively and is not subject to the SSRF airlock, so onboarding succeeds even for a loopback or LAN endpoint. Runtime requests from the capsule are blocked by the airlock until you add an operator exemption. See Local LLM endpoints and the SSRF airlock.
The mechanism behind this is the options_from field in the capsule's
[env] manifest:
[env]
model = { type = "select", request = "Default model to select",
default = "gpt-5.5",
options_from = { http = "{base_url}/v1/models",
bearer = "{api_key}",
select = "data[].id",
after = ["base_url", "api_key"] } }
The installer fetches client-side, attaches the bearer only to the configured
base_url host, and caps the response at 5 MB.
What is configured
The install flow writes per-capsule env config to
~/.astrid/home/<principal>/.config/env/<capsule-id>.env.json with 0600
permissions. These values are read by the capsule at runtime via env::var.
api_key fields are written as secrets and never logged.
After install the registry picks up the new provider at the next
astrid.v1.capsules_loaded broadcast (or at the next astrid models list
call, which re-runs discovery).
When no model is selected
If no LLM provider is configured, or if you have run astrid models unset
and not re-selected a model, any prompt fails with:
No LLM model is selected. Run `astrid models` to choose one,
or install and configure an LLM provider.
The react loop generates this error when the active LLM topic resolves to nothing -- it never fabricates a default model to try. Fix it with:
# Show what is available (runs discovery)
astrid models list
# Pick one
astrid models set openai:gpt-5.5
# Or install and configure a provider if none is listed
astrid capsule install @example-org/capsule-openai
If astrid models list returns "No LLM models available", no provider capsule
is installed or reachable. Check astrid ps to confirm a provider capsule is
loaded and astrid capsule list to see what is installed.
See also
- Unified config schema --
config.tomlreference, including the capsule env config overlay mechanism. - Generating a gateway API client -- HTTP access to the agent prompt endpoint that drives LLM turns.
astrid capsule config <capsule-id>-- view or edit a provider's env configuration (API key, base URL, default model) without reinstalling.astrid doctor-- system health check that reports whether the installed capsule set can serve an agent chat turn.