Documentation v1.0.22

Providers

Provider implementations live in go-llm-router (v0.7.1), an external module extracted from Agenvoy. Agenvoy builds every agent through router.New(cfg) and calls one Agent.Send() surface — no per-vendor code remains in this repository.

Supported list

Eleven vendors. Since v1.0.22 the TUI /model add picker has three tabs (←/→ switches), and each auth route is its own row: OAuth — OpenAI Codex (codex), Grok (xAI) (grok-oauth), GitHub Copilot (copilot); API Key — OpenAI, Claude, Gemini, Grok, DeepSeek, Mistral, NVIDIA NIM, Ollama Cloud, OpenRouter; Custom — Cloudflare, Local/Custom (compat), plus any local Ollama / llama.cpp endpoint detected on its usual port. That is fourteen fixed rows. Before v1.0.22 it was one twelve-row list, where picking OpenAI or Grok opened a second method picker for API key vs. subscription. o on a row opens that provider's console (API-key page, or the plan page for subscriptions). GET /v1/providers flattens the same set into thirteen entries, giving codex and grok-oauth their own IDs so an API client can address one auth route directly; compat is returned alongside them as a fourteenth row. Each row carries logged_in, which is meaningful only for the three OAuth routes (codex, grok-oauth, copilot), and since v1.0.22 console, the console pages GET /v1/provider/:provider/console can redirect to.

Provider ID Auth Notes
OpenAI openai · codex API key or OAuth API key uses Chat Completions / Responses. The OAuth route runs on your ChatGPT / Codex subscription with no API key, over SSE
Anthropic Claude claude API key Messages API; parallel tool use on by default
Google Gemini gemini API key gemini-2.x / 3.x families
xAI Grok grok · grok-oauth API key or OAuth API key is pay-per-token; the OAuth route runs on your xAI subscription
GitHub Copilot copilot OAuth Device-code login flow, on your GitHub subscription
DeepSeek deepseek API key deepseek-chat (tool use) and deepseek-reasoner
Mistral mistral API key Mistral's own hosted models
NVIDIA NIM nvidia API key Nemotron, Llama, Mistral, and other hosted open-weight models; free tier needs no billing setup
Ollama Cloud ollama-cloud API key Ollama's hosted models; quota shown as a percentage
OpenRouter openrouter API key Aggregator — routes to models from many vendors through one key
Cloudflare cloudflare API token + account ID Workers AI; optional gateway ID

Anything OpenAI-compatible

Beyond those eleven, the compat entry (Local/Custom) is not a twelfth vendor — it is the escape hatch that makes the list open-ended. Point it at any OpenAI-compatible /v1 base URL, with an optional key, and it becomes a usable backend: Ollama, LM Studio, vLLM, LiteLLM, a self-hosted gateway, a vendor that shipped an OpenAI-shaped API last week. Register as many as you need — each one is its own named entry.

Two local endpoints are built in (configs/jsons/local_compat.json): Ollama Local at http://localhost:11434/v1 and Llama.cpp Local at http://localhost:8080/v1. /model add probes both with GET /models in parallel (500 ms budget) and lists the ones that answer at the top of the provider list, going straight to model selection with nothing written to config.json. Endpoint models are registered as <name>@<model> with the endpoint name lowercased (ollama@gemma3:4b); the older compat[NAME]@<model> form is still accepted and rewritten whenever config.json is loaded or saved.

Model lists are fetched live when a model is added (TUI /model → add, or GET /v1/provider/:provider/models); for a local or custom endpoint the list comes from the endpoint's own GET /models. There are no static model catalogs in the repository.

Configuration

Everything is managed from the TUI or the local HTTP API — there is no agen model CLI subcommand:

Task TUI HTTP
Add / remove a provider or model /model → add; d on a model row removes it POST /v1/models, DELETE /v1/models/*name
Reorder fallback priority — GET POST /v1/model/priority — {models}
Set a model's tier /model, t on a model row POST /v1/model/tier — {model, tier}
Pick the dispatcher model /model → dispatch GET POST /v1/model — {dispatcher}
Pick the summary model /model → summary GET POST /v1/model — {summary}
Pick the image generator /model → image GET POST /v1/model — {image}, a provider endpoint rather than a model name
Pick the speech-to-text model /model → stt GET POST /v1/model — {stt}, chosen from GET /v1/model/audio
Pick the text-to-speech model /model → tts GET POST /v1/model — {tts}, chosen from GET /v1/model/audio
Pick a session's model /model (or Shift+W / Shift+S to cycle auto and registered models) POST /v1/session/:id — {model, reasoning}
Store a credential /key POST /v1/provider/:provider/key
OAuth login /model GET /v1/provider/:provider/oauth (SSE device code)
Clear an OAuth login /model DELETE /v1/provider/:provider/oauth
Check quota / balance Shift+U GET /v1/providers/quota

Model routing is one object: GET / POST /v1/model reads and partially updates dispatcher, summary, image, stt, and tts together. A field left out is untouched, "" clears it. In config.json these land as dispatcher_model, summary_model, image_generator, stt_model, and tts_model.

Audio routing is separate from the model registry. stt and tts are not picked from the models you registered with /model add — they are queried live from whichever of OpenAI, Gemini and OpenRouter currently hold a credential, and only those three providers back audio today (OpenRouter was added in v1.0.17). Selecting off for tts also removes generate_audio from the tool set; selecting off for stt stops read_files from transcribing audio and video, and makes inbound Telegram / Discord voice messages refuse with a hint to pick a model.

Credentials (API keys, OAuth tokens) live in the OS keychain under service agenvoy, never in plain JSON. config.json keeps only the list of stored key names under keys.

The daemon watches config.json; a write reloads the agent registry (and reconnects Telegram / Discord) without a restart.

Since v1.0.13 a registry entry holds only the model name and resolves its provider config on every Send instead of building the client once at startup, so an OAuth token refreshed in the keychain takes effect on the next request rather than at the next registry reload.

Model priority and tiers

Registered models are stored as an ordered list under models in config.json. That order is the fallback priority: after the selected model fails, every other entry is tried top to bottom — pass models included, at their place in the order — and the last entry is the final line of defense. Fallback skips models on the failed model's provider and models whose context window cannot hold the input, retries the list up to three rounds, and is off for a session pinned to a model. Before v1.0.20 pass models were always moved to the end. POST /v1/model/priority moves the listed names to the front in the given order and keeps the rest after them.

Each model can carry a tier in model_tag ({"<provider>@<model>": "<tier>"}):

Tier Meaning
S Strongest — code and work that asks for depth or precision
A Default for most work, one step below the flagship
B Mainstream mid tier
C Fast and cheap; calls tools reliably as instructed
pass Never picked by auto routing or subagents; fallback still tries it at its place in the priority order; usable when set for a session

An untiered model follows the built-in naming rules: S = claude-fable, claude-opus, gpt-*-astra; A = gpt-*-sol, grok-4.5+, claude-sonnet, gpt-*-terra, gemini-*-pro, deepseek-pro, glm, kimi; B = claude-haiku, gpt-*-luna, gemini-*-flash, grok below 4.5, deepseek; C = *-mini, *-nano, gemini-*-flash-lite and open-weight models (gemma*, gpt-oss, qwen*, llama*). v1.0.19 moved gpt-*-sol and grok-4.5+ from S to A. Tiers are read per request, so a change applies without a restart.

Dispatcher model

The dispatcher LLM decides which worker model handles each task. It runs in exec.Start through ResolveAgent → SelectAgentNames, before Execute() enters its iteration loop, receiving the registered model list, every registered model grouped by its resolved tier (user-set tier first, then the naming rules), the work-kind table below, the user input, and a hint about any matched skill. Its routing call is issued at ReasoningNone with a 30-second timeout.

Routing is skipped when it cannot matter: a model named explicitly by the caller is used as-is (and fails if unregistered), a session bound to a model other than auto uses that model, and a registry with only one model returns it directly.

The dispatcher returns a comma-separated list of model names. The first name that is registered and not cooling down becomes the primary model. Since v1.0.20 the rest of that list no longer decides fallback — fallback follows the priority order above. It classifies the request into one of the five work kinds in the table under TypeSafe below and walks that kind's tier order. Since v1.0.21 the LLM dispatcher, the TypeSafe classifier and the subagent planner read one shared work-kind table (internal/session/config/tier.go) and one naming rule, so they rank the same way; before that the LLM dispatcher had no research / work split and sent everything outside code, chat and fetch to S > A > B > C. The dispatcher is told to leave pass models out unless the request names them. When the same base model is registered under several providers, the list prefers codex / grok-oauth, then copilot, then the direct API, then openrouter. If the dispatcher call fails, the next dispatcher candidate is chosen by a fixed provider ranking; if none answers, the priority order alone decides.

Set the dispatcher with /model → dispatch in the TUI, or POST /v1/model with a dispatcher field.

TypeSafe dispatcher (beta)

v1.0.18 added a second routing backend. With dispatcher_beta on, routing is a single classification call to TypeSafe (https://api.typesafe.ai/v1/systemone, model jev-latest) instead of a prompt to one of your own models, so no registered model is spent on routing. It needs a TYPESAFE_API_KEY in the keychain — the TUI asks for one when the toggle is turned on, and POST /v1/model returns 400 with missing_key when it is absent. Keys come from the TypeSafe Console (https://console.typesafe.ai/keys).

The call asks up to three questions about the request: what kind of work it is, whether it names a specific model, and — when the session has earlier turns — whether it continues the previous subject. The work answer picks the tier order:

Work Tier order Auto reasoning
code S > A > B > C xhigh
research S > A > B > C high
work (the default for anything else) A > S > B > C medium
chat B > C > A > S none
fetch C > B > A > S low

A model named in the request goes first; the session's previous model follows when the subject is unchanged, so its prompt cache is reused. The previous model is recorded after each successful reply and expires after 30 minutes for openai / codex, 60 minutes for gemini, and 5 minutes for other providers. The remaining candidates are sorted by tier, reading model_tag first and falling back to the naming rules above; within a tier, ties break by model family in the order the naming rules list them. pass models are left out of this ranking. The request context is the last 4 user and assistant turns, each cut at 2,048 characters (6 turns at 2,000 before v1.0.19). If the call fails, routing falls back to the LLM dispatcher.

Turn it on with /model → dispatch → TypeSafe/Jev(beta) in the TUI, or POST /v1/model {dispatcher_beta: true}. Picking an ordinary model as the dispatcher turns it back off.

Reasoning levels

go-llm-router normalizes reasoning onto one scale — none, low, medium (default), high, xhigh, max — and maps it per provider (Claude thinking budgets, Gemini thinking budgets, OpenAI effort, ...). Aliases minimal, extra, and ultra map to low, xhigh, and max. Levels outside a model's supported range are clamped rather than rejected.

The level is passed explicitly through each Send call; there is no global reasoning setting in config.json. Cycle it with Shift+A / Shift+D in the TUI.

Auto reasoning (auto_reasoning, added in v1.0.18) picks that level per request instead, from the same TypeSafe classification the beta dispatcher uses and by the same work-to-level mapping — so it needs TYPESAFE_API_KEY too, and it works whether or not dispatcher_beta is on (with only auto reasoning on, the call decides the level and your own dispatcher still picks the model). While it is on, Shift+A / Shift+D do nothing and the model tag on the TUI input border shows the model without a reasoning suffix. When the previous model is kept for a continuing subject, its reasoning level is kept too (v1.0.20), unless the request names a different model. Since v1.0.19 auto reasoning also applies when the caller names the model explicitly, for example the model field of POST /v1/send. A failed call leaves the level unset, falling back to the session's own. Toggle it with /model → reasoning in the TUI, or POST /v1/model {auto_reasoning: true}.

Fast mode

Shift+F toggles fast mode, which passes provider.ModeFast through the router so supported backends request a faster service tier. Support is model-specific (core.SupportFast) — for example recent OpenAI generations, Claude Opus 4.8 / Opus 5, most Grok models, and selected Gemini families. Unsupported models silently fall back to the default tier. Fast mode is process-local and not persisted.

Adding a custom OpenAI-compatible endpoint

Use Local/Custom (compat) and point it at any endpoint that accepts the OpenAI Chat Completions schema. URL convention follows Zed: enter the URL up to /v1 (e.g. http://192.168.1.10:4000/v1); the router appends /chat/completions. The built-in Ollama and llama.cpp ports need no entry.

Storage split (URL vs key)

What Where API
URL ~/.config/agenvoy/config.json compats[] — {provider, url}, provider name uppercased config.UpsertCompat / config.GetCompatURL (internal/session/config)
API key OS keychain keychain.Set("COMPAT_<NAME>_API_KEY", value)

GetCompatURL checks compats first, then the built-in local endpoints. There is no COMPAT_<NAME>_URL keychain key — it was removed after a bug where the TUI wrote the URL to config while the runtime read the keychain and always fell back to localhost.

Tested compat targets

Target Works Notes
Ollama Yes built in at http://localhost:11434/v1
llama.cpp server Yes built in at http://localhost:8080/v1
LM Studio Yes
vLLM Yes --enable-auto-tool-choice --tool-call-parser <name> for tool use
LiteLLM proxy Yes virtual key as Bearer token
Groq / Together / DeepInfra / Fireworks Yes
Azure OpenAI No needs an api-key header (not Bearer) plus ?api-version= — not supported
opencode Go (opencode.ai/zen/go/v1) No requires a per-conversation x-opencode-session header; missing it returns 400 MissingSessionID — not supported

What counts as compatible

A target qualifies when the request body and Authorization: Bearer <key> are the whole contract. Agenvoy carries no vendor-specific headers through the compat channel, so an endpoint that demands one is out of scope regardless of how OpenAI-shaped its body is. Only three kinds of backend are in scope: vendors that serve their own models, OpenAI-compatible NIM, and Cloudflare.

opencode Go is the working example of the boundary. Since its 2026-09-03 announcement the gateway rejects any request without x-opencode-session, a private header whose value has to change per conversation so the request lands on the node holding that conversation's cache. Chat Completions is stateless — the conversation lives in messages and the protocol has no session concept — so requiring the client to track and rotate a vendor header puts the endpoint outside the compatible set. Opening the compat channel to custom headers for one vendor would open it for every vendor, so it stays closed.

Send timeout and failure handling

Layer Value Catches
Provider HTTP client set inside go-llm-router per provider Transport-level stalls
AgentSendTimeoutSec limits.agent_send_timeout_seconds in config.json, default 600 Exec-layer ceiling via context.WithTimeout
Unresponsive watchdog probe every 30 s; after a failed probe, retry every 10 s; 3 failures switch model A stream that hangs without erroring
Health check 10 s Liveness probe of each fallback candidate

On failure the exec layer makes up to three attempts on the same model for a timeout (15 s apart), retries a rate limit on the same model after 5 / 10 / 15 s, and registers a 30-minute cooldown for rate-limit and quota errors; a quota error switches immediately. It then falls back to the next healthy model from a different provider. See the Execution Engine page for the full escalation table.

中文