Provider configuration: auto-discovery, live models, a searchable picker
Exarch declares almost nothing. A famous provider whose key is in the
environment auto-populates, and its model list is fetched live from the
provider; a hand-written config is needed only for unusual providers
(custom endpoints). The active model and request tuning are picker-driven
runtime state persisted to a local .exarch, not config. The earlier
flag surface (--provider/--model/--max-tokens, one positional per
knob) and the genesis needs — OpenAI OAuth, and tuning knobs that “keep
changing” — all dissolve into: detect what the environment already offers,
ask the provider what models it has, and let the user pick.
The design is DRY by construction: provider knowledge (key env var,
default endpoint, adapter) lives once in ProviderKind::info; model lists
come from genai’s all_model_names; wire protocols are genai’s
AdapterKind. Nothing about a provider or its models is restated in a
config file or a shell script.
Famous providers auto-populate
- A key in the environment is the whole declaration. For each known
provider (
ProviderKind), if its conventional key var is present (ANTHROPIC_API_KEY,OPENAI_API_KEY,OPENROUTER_API_KEY,DEEPSEEK_API_KEY), that provider is available — no config, no named profile, no restated var name. - Models come from the provider, not a file. The model list is fetched
via genai’s
client.all_model_names(adapter), lazily when the picker first opens, cached to the XDG cache dir with a TTL, and with manual entry as the fallback when listing fails or the wanted model is not yet listed. A hardcoded model table would go stale; the provider is the source of truth.
State lives per-project under XDG, not config
- The active model and tuning are runtime state.
state.jsonholds the selected model and the tuning overrides (temperature, reasoning effort, verbosity, max-tokens). The pickers write it; startup loads it; it overrides until changed — “set it once, it sticks.” It is not hand-authored config. - Per-project, keyed by a slug, under the XDG state home — reusing the
session-log directory. It lives at
$XDG_STATE_HOME/exarch/<project-slug(cwd)>/state.json, the same per-project directory the session logs already use (bootstrap::project_dir,project_slug) — so the memory is per-project without scattering a dotfile into the working tree, and exarch’s per-project artifacts are one findable place. Because it sits outside cwd, the sandboxed agent cannot reach it at all: the protection is structural, with no fs-deny-list entry needed (the agent writes only cwd and scratch). exarch’s own process — the picker, not a tool call — writes it.
Config is only for unusual providers
- A hand-written
config.raldescribes custom endpoints, nothing else. A self-hosted or non-famous endpoint is declared with its base URL, its key env var, and its wire protocol —completions(OpenAI v1),responses(OpenAI v2), oranthropic— which map 1:1 onto genai’sAdapterKind. Famous providers need no entry. - Authority stays at the trusted home. Because a redirected
endpointis an exfiltration channel (it points the agent’s own traffic at an arbitrary server), unusual-provider config lives in$XDG_CONFIG_HOME/exarch/config.ral— never the working tree. It is evaluated through the shared `evaluate_source` path under a no-authority grant (denyexec/net/fs-write), so a config can only compute a value, never cause an effect (two enforcers: the in-process gate suffices — withexecdenied there is no route to the network).
The pickers
/modelis a searchable strip, not a floating modal. The TUI is a flat stack of strips with no overlay layer; the picker honours that — a bordered pane over the prompt region listing the available models (live lists across every available provider), filtered as the user types, selected row inModifier::REVERSED, Enter to switch, Esc to dismiss. Modal in behaviour (an early-return guard inApp::key), flat in rendering. Search matters because a provider list can run to hundreds (OpenRouter).- Switching is cheap and sticky.
/modelwrites the selection to the per-projectstate.json, then rebuilds the provider over the same transcript — a switch rebuilds the transport, not the history — and the live model shows in the per-frame status bar. Tuning is its own surface, below.
The tuning picker
- Tuning is ordered data, so it is not the model list.
/modelis a nominal pick — interchangeable labels, searched and selected. The four knobs are not: temperature and max-tokens are quantitative, reasoning effort (None < Low < Medium < High < XHigh < Max) and verbosity (Low < Medium < High) are ordinal. By Bertin’s discipline — already the TUI’s idiom for level of measurement — ordered data wants an ordered visual variable, not a flat list. So/tuneis a small fixed stack of knob rows: each encodes its value by position, the quantitative knobs as a handle/fill bar scaled to the knob’s range, the ordinal knobs as a sequence of cells with the current one marked. There is no search box — one does not filter four fixed knobs, one steps them. - Stepper-only, no free entry.
←/→steps the focused knob,↑/↓moves between knobs,⏎applies,Escreverts. Quantitative knobs step by a fixed increment (temperature by tenths; max-tokens against the model’smax_output_tokens, which is the bar’s full extent); ordinal knobs walk their enum. Raw numeric entry is deliberately withheld — the stepper keeps the surface keyboard-trivial and the encoding honest, since a value reachable only by stepping is one the bar can always render. autois a first-class state, distinct from zero. A knob left unset sends nothing and genai applies the adapter default — not the same as sending0.0. The distinction is load-bearing: over-assertingtemperatureat a reasoning model can 400, so the unset state must be representable and visible (a ghost track), and stepping promotesauto → setwith a path back. This is the rendering counterpart of Reasoning is never stripped below — exarch declines to assert tuning it was not asked to assert.- Unsupported knobs are dimmed, not hidden. Which knobs apply is
model-specific — reasoning effort only where the model advertises it,
verbosity only for OpenAI Responses.
/tunebecomes the first consumer ofModelCaps::supported_parameters(scraped from the catalog today, read by nothing). An inapplicable knob renders dimmed with its reason rather than vanishing, so the pane teaches the model’s shape instead of concealing it. - One command, its own surface.
/tuneis the sole entry; the model picker stays a pure nominal pick and does not hand off to it. The picker writes the same per-projectstate.json— tuning fields added with#[serde(default)]so an older binary tolerates a newer file — startup loads it, and a compact tuning glyph joins the live model in the status bar. Structurally it mirrors/model: the same modal drive-loop and state-write path, diverging only where the level of measurement demands a different encoding and different keys.
Reasoning is never stripped
- genai owns the per-adapter reasoning policy; exarch stays out of it.
The Anthropic adapter drops reasoning, the OpenAI Responses adapter
ignores it on input, and the OpenAI chat-completions adapter echoes
reasoning_contentback — which current DeepSeek/Kimi reasoners require, or they 400 across a tool-use sequence. So the transcript keeps reasoning whole; a pre-strip would break DeepSeek/Kimi and buys nothing elsewhere. The cost (DeepSeek bills the re-sent reasoning) is accepted; Anthropic and OpenAI never receive it.
Credentials
- API keys auto-detected and scrubbed. Each available provider’s key is read from its conventional env var into an in-memory store, then those vars are scrubbed from the environment so no spawned child inherits a key. Resolution is eager (the scrub forecloses re-reading later).
- OAuth is a future seam. When it lands it is detected the same way —
a cached token present for a provider makes it available — reachable as a
loginsubcommand (headless) and inline from the model picker (interactive). No declaration; theCredentialtype hosts both arms.
Implementation slices
- Auto-discovery + live models + the
/modelsearchable picker +.exarchmodel persistence + startup load. Replaces the provider/model flag surface; no config file needed in this slice. - The
/tunetuning picker + tuning instate.json— stepper-only;auto/unset kept distinct from zero; unsupported knobs dimmed viaModelCaps::supported_parameters; new fields#[serde(default)]. - Unusual-provider
config.ral(XDG, no-authority eval, the three protocols). - OAuth (token cache, refresh at genai’s per-request
AuthResolver,loginsubcommand + picker entry).
See exarch-architecture, provider map, agent map, frontend map, policy map, grant, and xdg-resolver-consolidation.