Goa Provider Configuration
Goa uses a config-driven provider architecture. Each provider variant is
described by a JSON profile that lives in
internal/agentic/provider/schema/variants/ (embedded defaults) or can be
overridden by user configuration.
Config cascade
Variant profiles are merged in the following order, with later sources overriding earlier ones:
- Embedded defaults shipped with Goa (
variants/*.json). - User configuration in
~/.goa/providers/*.json. - Project configuration in
./.goa/providers/*.json. - Local overrides in
./.goa/providers.local/*.json. - Environment variables referenced by URL templates (
{ENV_VAR}). - The
Model.VariantIDfield.
The merge is literal: every source that matches a model contributes its fields,
and a later source overrides only the fields it sets. A user profile that pins
one field for one model (say compat.thinking_format for a single gateway
model) therefore refines the provider profile instead of replacing it —
which is what max_tokens_field, the auth block, and things like
requires_reasoning_content_on_assistant_messages depend on. Two caveats:
- Within one source, only the most specific match applies.
model_id(score 8) beatsbase_url(4) beatsprovider(2) beatsapi(1), so a broad profile in the same directory is not layered under a narrow one. - Plain booleans carry with OR semantics. JSON cannot distinguish
falsefrom absent, so a merge can only switch a capability on, never off. Fields that genuinely need "explicitly off" are tri-state (*bool) in the schema:supports_store,supports_temperature,supports_tool_choice_none,supports_encrypted_content,tool_streaming.
The resolved profile is named after the sources that built it
(opencode-go+catjam-effort-only), which is what GOA_DEBUG_PROVIDER=1
prints.
Profile schema
A minimal profile looks like this:
{
"id": "my-provider",
"match": {
"api": "openai-completions",
"provider": "custom",
"base_url": "http://localhost:1234"
},
"defaults": {
"temperature": 0.7,
"max_tokens": 4096
},
"compat": {
"supports_store": false,
"max_tokens_field": "max_tokens",
"thinking_format": "none"
},
"auth": {
"method": "api_key",
"env_vars": ["MY_API_KEY"],
"header": "Authorization",
"prefix": "Bearer "
},
"cache_policy": {
"mode": "none",
"breakpoint_cap": 0
},
"tool_compat": {
"tool_call_id_rules": {
"max_length": 40,
"alphabet": "[a-zA-Z0-9_-]"
},
"schema_sanitizer": "openai"
},
"error_rules": {
"retryable_statuses": [429, 500, 502, 503, 504]
}
}
Request-side thinking body (compat.thinking_format)
compat.thinking_format selects the field(s) a request uses to control
reasoning. It is a request vocabulary, not the response-side
Model.ThinkingFormat (which names the field the reasoning text streams back
in):
| value | request fields | used by |
|---|---|---|
openai (default for OpenAI-compatible surfaces) |
reasoning_effort |
opencode / opencode-go (zen), kimi-code, vercel, generic OpenAI-compatible endpoints |
deepseek |
thinking: {type: enabled} + reasoning_effort |
DeepSeek's own API, string-thinking endpoints |
zai |
thinking: {type: enabled, clear_thinking: false} |
z.ai GLM |
together |
reasoning: {enabled: true} + reasoning_effort |
Together |
openrouter |
reasoning: {effort: …} |
OpenRouter |
qwen* |
thinking: true |
Qwen chat-template endpoints |
none / unset |
nothing is sent | endpoints that reject every reasoning parameter |
Rules worth knowing before editing a profile:
- Never send a field an upstream does not know. Proxying gateways forward
unknown arguments to the upstream instead of dropping them, and strict
upstreams reject them: the OpenCode Zen Go gateway answered
thinking: {type: …}withHTTP 400 [unsupported_parameter] Unrecognized request argument supplied: thinking.(exports 20261013-220109, 20261013-222008) even though the model reasoned fine without it. - Match the reference client. The zen surfaces are declared on models.dev
with npm
@ai-sdk/openai-compatible, which the OpenCode client maps to itsopenaiChatprotocol —reasoning_effortonly (seepackages/core/src/variant.tsupstream). That is whyopencode/opencode-gouseopenaihere. - Level values are per model. models.dev publishes the accepted
reasoning_optionsvalues per model; where a model accepts a restricted set, pin it with athinking_level_mapinmodel_overrides.yaml(see MiMo) or per model withthinking_level_native_mapinconfig.yaml. - The object form cannot be half-sent. A
thinkingobject is either accepted by an upstream or fatal; there is no "disabled" spelling that a strict upstream tolerates (thinking: {type: "disabled"}400s exactly like the enabled one on the same upstreams). - A model that cannot reason gets no reasoning field at all. The request
carries a reasoning field only when the model is reasoning-capable, the
caller set an explicit level (
thinking_level, which covers a catalog entry that lags the model), or the profile declares an always-on mode (defaults.thinking, e.g. kimi-code/moonshot).
Per-model reasoning metadata from the catalog
models.dev states more than the reasoning flag, and Goa imports it whenever the registry entry has no curated override:
reasoning_options: [{type: effort, values: [...]}]becomes a thinking-level map with identity entries, one per canonical level the model declares. The effect is clamping rather than translation: a configured level the model does not list resolves to the nearest one it does, instead of reaching an endpoint that answers400 reasoning_effort must be low, high, xhigh, max.toggle(on/off, no levels) andbudget_tokens(the separatethinking_budgetsdomain) are deliberately ignored.interleaved: {field: reasoning_content}becomesModel.ThinkingFormat, the field reasoning is replayed in on assistant messages. Only names Goa has semantics for are mapped:reasoning_details(OpenRouter-style) has no replay path here and is deliberately left unset rather than guessed.- Curated
model_overrides.yamlentries are applied on top and replace both, so hand-verified provider semantics always win over catalog data.
Rejected parameters are dropped and retried once
When an endpoint answers 400/422 naming a request field as an unsupported
parameter ({"error":{"param":"thinking","message":"…[unsupported_parameter] Unrecognized request argument supplied: thinking."}}, or the same complaint in
free text), the agent drops that one field from the request and retries instead
of failing the turn — the field is Goa's to remove, and a strict upstream is not
a permanent outage. The behavior is bounded: one field per attempt, never the
same field twice, at most three fields, and a suppressed field is not carried
into later requests of the session. The user sees it as a progress line
(Provider rejected request parameter "thinking" — retrying without it.) and a
warning in the agent log.
Provider credentials: API keys, env vars and /login
Every provider Goa can select can also be given a credential. The credential chain is resolved in this order, first hit wins:
api_keyon the provider inconfig.yaml(the explicit override).- The auth store
~/.goa/tokens.json— what/loginwrites. - The provider's catalog environment variable (models.dev
envnames).
Storing a key
/login:<provider>:apikey
works for any catalog provider, curated or not:
/login:vercel:apikey # Vercel AI Gateway
/login:openrouter:apikey # OpenRouter
/login:tensorx:apikey # any other catalog provider
/login with no arguments lists the stored credentials, the curated OAuth
entries (copilot/github/openai-codex/anthropic/kimi — OAuth and device-code),
and every remaining catalog provider addressable with :apikey. The argument
completions show the env var each provider reads.
Catalog environment variables
These are the variables the catalog declares for common providers. goa reads
them when neither config nor the auth store holds a key — which is how
headless/CI runs authenticate:
| Provider | Env var(s) |
|---|---|
| vercel (Vercel AI Gateway) | AI_GATEWAY_API_KEY |
| openai | OPENAI_API_KEY |
| openai-codex | OPENAI_API_KEY |
| anthropic | ANTHROPIC_OAUTH_TOKEN, ANTHROPIC_API_KEY |
| openrouter | OPENROUTER_API_KEY |
| deepseek | DEEPSEEK_API_KEY |
| kimi (Moonshot) | MOONSHOT_API_KEY, KIMI_API_KEY |
| kimi-code | KIMI_CODE_API_KEY, MOONSHOT_API_KEY |
| zai / zai-api | ZAI_API_KEY |
GEMINI_API_KEY, GOOGLE_API_KEY, GOOGLE_GENAI_API_KEY |
|
| xai | XAI_API_KEY |
| groq | GROQ_API_KEY |
| together | TOGETHER_API_KEY |
| fireworks | FIREWORKS_API_KEY |
| perplexity | PERPLEXITY_API_KEY |
| github (Copilot) | COPILOT_GITHUB_TOKEN, GITHUB_TOKEN |
| opencode / opencode-go | OPENCODE_API_KEY |
| poolside | POOLSIDE_API_KEY |
| mistral | MISTRAL_API_KEY |
Providers without a curated entry fall back to {PROVIDER_UPPER_SNAKE}_API_KEY
(e.g. TENSORX_API_KEY); the full list is whatever models/api.json declares,
surfaced by /login and by the argument completions.
When no credential exists
A request that cannot be authenticated fails before it is sent, naming the provider and the three ways to add a key:
no API key found for provider "vercel": checked options.api_key, AI_GATEWAY_API_KEY
— add one with /login:vercel:apikey, set api_key on the provider in config,
or provide it in the environment (AI_GATEWAY_API_KEY)
Catalog gateways and default endpoints
Some catalog providers are gateways: their models.dev entry carries only
npm/env, no base URL, and they address models with a vendor-namespaced id
(e.g. stealth/pixel-canary on the Vercel AI Gateway). For those, the base URL
comes from the provider catalog:
| Provider | Default base URL |
|---|---|
| vercel | https://ai-gateway.vercel.sh/v1 |
A model resolved for such a provider gets {base}/chat/completions with the id
passed through unchanged — Goa never rewrites stealth/pixel-canary. An empty
endpoint no longer falls back to api.openai.com: a non-OpenAI provider with
no endpoint (and no catalog base URL) fails with an actionable
provider "X" has no endpoint error instead of silently calling another
vendor's API.
Adding a custom provider without code changes
- Create a profile file in
~/.goa/providers/my-provider.jsonmatching the schema above. - Reference it in your model definition using
BaseURLorVariantID. - The generic runtime resolves the profile and builds the request automatically.
URL templates
Any {ENV_VAR} placeholder in BaseURL is resolved at request time from the
environment. This is useful for API keys in query strings or dynamic endpoints.
Expression support
Profiles may contain field_mappings entries using a small template syntax:
variables are referenced with $name or dot-paths, and environment variables
use {ENV_VAR}.
Debug tools
go test ./internal/agentic/provider/...runs the full provider test suite.- Set
GOA_DEBUG_PROVIDER=1to print the resolved profile for each request.