Model providers
Every model backend Arcturn can drive, and how to authenticate with each.
On this page
One interface, every backend
Arcturn drives every model through a single LLMClient interface, so a provider change is a
--model flag rather than a code change. Adapters register themselves into a provider
registry, which is why adding a backend never touches dispatch code.
Nine providers are registered out of the box. The last column is the one worth reading first — it separates what has been driven against a live endpoint from what has only been built and unit-tested:
| Provider id | What it drives | Credentials | Verified live |
|---|---|---|---|
anthropic |
Claude, direct | ANTHROPIC_API_KEY |
✅ Claude Haiku 4.5 |
openai |
GPT, Chat Completions | OPENAI_API_KEY |
✅ GPT-5 nano |
openai-responses |
GPT, Responses API | OPENAI_API_KEY |
✅ GPT-5 nano |
google |
Gemini, direct | GOOGLE_API_KEY |
✅ Gemini 3.5 Flash Lite |
openai-compatible |
Any OpenAI-shaped endpoint | Per endpoint | ✅ Z.AI GLM, 170+ sessions |
anthropic-compatible |
Any Anthropic-Messages endpoint | Per endpoint | ✅ canonical Messages API |
bedrock |
Claude, Nova, Llama, Mistral, Titan on AWS | AWS provider chain | ⚠️ not yet |
vertex |
Gemini and Claude on Google Cloud | Application-default credentials | ⚠️ not yet |
azure |
GPT on Azure OpenAI | AZURE_OPENAI_API_KEY or Entra ID |
⚠️ not yet |
The two compatible adapters matter more than they look: most third-party inference services speak one of those two protocols, so Arcturn reaches them without a bespoke adapter each.
What "verified live" means, and why the column exists
A ✅ means a real request went to that provider's real endpoint and came back correct across four things: streaming text, a tool call whose result is fed back and answered from on a second turn, usage and cost accounting that matches the published rates, and — where the provider supports it — a thinking block.
The column exists because every one of those live runs found a bug that a green test suite
could not. Anthropic's found a --print that hung forever on an inherited stdin, which is
the exact shape CI uses. Google's found tool calling broken outright: Gemini signs the tool
call, not just the thinking that led to it, and rejected every second turn with a 400
until the signature was carried back. OpenAI's found the Responses adapter documented here
but impossible to select, because it shipped with no catalog entries.
None of those were reachable by unit tests, and two were in features these docs advertised
as working. So a ⚠️ is not a claim that an adapter is broken — the code is implemented,
reviewed and unit-tested, and bedrock and azure reuse stream translation that the ✅
rows exercise. It is a claim about what has been demonstrated, and given the hit rate
above, the honest distinction is worth more to you than a longer list of ticks.
One honest qualification on the two compatibility adapters. Each was verified against a
single implementation of its protocol — openai-compatible against Z.AI's GLM endpoints
across 170-odd real sessions, anthropic-compatible against a canonical Messages API. That
proves the adapter's own request shaping, streaming, tool assembly and accounting; it does
not prove any particular third-party service, which may deviate in its own way.
The three unverified cloud backends need an AWS account with model access, a GCP project with application-default credentials, or an Azure deployment respectively — which is why they are the ones still outstanding. If you run Arcturn against one, the result is worth reporting either way.
Cloud backends
AWS Bedrock authenticates through the standard AWS provider chain, so profiles, SSO, environment variables and IAM roles all work with no extra configuration. Claude models route through the Bedrock Anthropic SDK; everything else goes through ConverseStream.
export AWS_REGION=us-east-1
arcturn --model bedrock/us.anthropic.claude-sonnet-4-5-20250929-v1:0Google Vertex uses application-default credentials — gcloud auth application-default login, a service-account JSON in GOOGLE_APPLICATION_CREDENTIALS, or workload identity.
export GOOGLE_CLOUD_PROJECT=my-project
arcturn --model vertex/gemini-2.5-proAzure OpenAI addresses a deployment, not a model name — the deployment you created in your resource is what goes on the wire.
export AZURE_OPENAI_ENDPOINT=https://my-resource.openai.azure.com
export AZURE_OPENAI_API_KEY=...
arcturn --model azure/my-deploymentRegion, project and deployment live in ModelSpec.providerOptions, which addresses the
service. That is deliberately separate from LLMRequest.providerOptions, which is merged
into the request body — routing data must never leak into a payload.
Presets
A preset is a remembered { baseUrl, apiKeyEnv, protocol } triple for a well-known
endpoint, so you can name it instead of repeating a URL — presetSpec("groq", "llama-3.3-70b-versatile")
instead of hand-building an openaiCompatible() call. Presets are a convenience, not a
gate: they resolve lazily (getModel/presetModel pull in the whole table on first miss,
via wireExtendedPresets in packages/ai/src/catalog.ts), so nothing needs an explicit
registration call to be reachable by id. Run arcturn --list-providers to see which ones have
their key set. To check that a key actually works — not just that it is set — run
arcturn doctor: it probes every configured endpoint with a one-token completion and prints a
per-endpoint verdict, telling a rejected key apart from an empty balance or a key that belongs
on a sibling endpoint (a coding-plan key pointed at zai-api answers "insufficient balance";
doctor says to try zai).
All 35 presets, from packages/ai/src/presets.ts:
| Preset | Protocol | Base URL | Key env var |
|---|---|---|---|
groq |
openai | https://api.groq.com/openai/v1 |
GROQ_API_KEY |
deepseek |
openai | https://api.deepseek.com/v1 |
DEEPSEEK_API_KEY |
together |
openai | https://api.together.ai/v1 |
TOGETHER_API_KEY |
cerebras |
openai | https://api.cerebras.ai/v1 |
CEREBRAS_API_KEY |
nvidia |
openai | https://integrate.api.nvidia.com/v1 |
NVIDIA_API_KEY |
huggingface |
openai | https://router.huggingface.co/v1 |
HF_TOKEN |
baseten |
openai | https://inference.baseten.co/v1 |
BASETEN_API_KEY |
fireworks |
anthropic | https://api.fireworks.ai/inference |
FIREWORKS_API_KEY |
moonshot |
openai | https://api.moonshot.ai/v1 |
MOONSHOT_API_KEY |
moonshot-cn |
openai | https://api.moonshot.cn/v1 |
MOONSHOT_API_KEY |
zai (coding plan) |
openai | https://api.z.ai/api/coding/paas/v4 |
ZAI_API_KEY |
zai-api (general API) |
openai | https://api.z.ai/api/paas/v4 |
ZAI_API_KEY |
zai-cn (coding, China) |
openai | https://open.bigmodel.cn/api/coding/paas/v4 |
ZAI_CODING_CN_API_KEY |
qwen |
openai | https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1 |
QWEN_TOKEN_PLAN_API_KEY |
qwen-cn |
openai | https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1 |
QWEN_TOKEN_PLAN_CN_API_KEY |
qwen-individual |
openai | same base as qwen |
QWEN_TOKEN_PLAN_API_KEY |
xiaomi |
openai | https://api.xiaomimimo.com/v1 |
XIAOMI_API_KEY |
xiaomi-ams |
openai | https://token-plan-ams.xiaomimimo.com/v1 |
XIAOMI_TOKEN_PLAN_AMS_API_KEY |
xiaomi-cn |
openai | https://token-plan-cn.xiaomimimo.com/v1 |
XIAOMI_TOKEN_PLAN_CN_API_KEY |
xiaomi-sgp |
openai | https://token-plan-sgp.xiaomimimo.com/v1 |
XIAOMI_TOKEN_PLAN_SGP_API_KEY |
ant-ling |
openai | https://api.ant-ling.com/v1 |
ANT_LING_API_KEY |
minimax |
anthropic | https://api.minimax.io/anthropic |
MINIMAX_API_KEY |
minimax-cn |
anthropic | https://api.minimaxi.com/anthropic |
MINIMAX_CN_API_KEY |
openrouter |
openai | https://openrouter.ai/api/v1 |
OPENROUTER_API_KEY |
vercel-gateway |
anthropic | https://ai-gateway.vercel.sh |
AI_GATEWAY_API_KEY |
cloudflare-workers-ai |
openai | .../accounts/{account_id}/ai/v1 (unverified shape) |
CLOUDFLARE_API_KEY |
cloudflare-ai-gateway |
anthropic | .../v1/{account_id}/{gateway_id}/compat (unverified shape) |
CLOUDFLARE_API_KEY |
opencode (Zen) |
anthropic | https://opencode.ai/zen |
OPENCODE_API_KEY |
opencode-go (Go) |
openai | https://opencode.ai/zen/go/v1 |
OPENCODE_API_KEY |
kimi-coding |
anthropic | https://api.kimi.com/coding |
KIMI_API_KEY |
mistral |
openai | https://api.mistral.ai/v1 |
MISTRAL_API_KEY |
xai |
openai | https://api.x.ai/v1 |
XAI_API_KEY |
ollama |
openai | http://localhost:11434/v1 |
OLLAMA_API_KEY (local; any value works) |
lmstudio |
openai | http://localhost:1234/v1 (unverified — LM Studio convention) |
LMSTUDIO_API_KEY (unverified) |
vllm |
openai | http://localhost:8000/v1 (unverified — vLLM convention) |
VLLM_API_KEY (unverified) |
A few notes worth calling out because they are easy to get wrong:
- Z.AI is three presets, not one.
zaiis the coding-plan path;zai-apiis the general pay-as-you-go API at a different URL entirely;zai-cnis the coding plan on the China endpoint with its own key env var (ZAI_CODING_CN_API_KEY). Picking the wrong one 404s or 401s —zai-apiwas confirmed by observing a 401 (not a 404) fromapi.z.ai, meaning the path is right and only the key was missing. - DeepSeek needs
/v1. DeepSeek's own SDK omits it, but Arcturn drives OpenAI-compatible endpoints through the rawopenaiclient, which appends/chat/completionswith no version segment inserted — so the base URL must include/v1itself. - Cloudflare's two presets have templated base URLs. Both
cloudflare-workers-aiandcloudflare-ai-gatewayembed{account_id}(and, for the AI Gateway,{gateway_id}) — there is no single static base URL, so substitute your own before use, e.g. by overridingbaseUrlon theModelSpecpresetSpecreturns. lmstudioandvllmare marked unverified. Neither is a provider the reference implementation ships; their base URLs and key env var names are community conventions (LM Studio's default local port,vllm serve's default port), not values sourced from a first-party adapter.
Model ids pass through verbatim, so anything the endpoint serves works:
export ZAI_API_KEY=...
arcturn --model zai/glm-5.3-flash # coding plan, fast tier
arcturn --model zai/glm-5.3 # coding plan, flagship
arcturn --model zai-api/glm-5.2 # general APIZ.AI is split deliberately: zai is the coding-plan path and zai-api the general
pay-as-you-go API. They are different URLs, and the wrong one 404s.
The curated GLM lineup, verified against both endpoints on 2026-09-02:
| Model | Coding plan (zai, zai-cn) |
General API (zai-api) |
Context / max out |
|---|---|---|---|
glm-5.3-flash |
yes — 3x the plan quota of GLM-5.3 | $0.15 / $0.50 per MTok | 1M / 131,072 |
glm-5.3 |
yes | $1.40 / $4.40 | 1M / 131,072 |
glm-5.2, glm-5.1, glm-5 |
auto-routed to glm-5.3 |
$1.40 / $4.40 (glm-5: $1.00 / $3.20) |
1M / 131,072 |
glm-5-turbo |
yes | $1.20 / $4.00 | 200K / 131,072 |
glm-4.7 |
yes | $0.60 / $2.20 | 200K / 131,072 |
glm-4.7-flashx |
no — billed separately | $0.07 / $0.40 | 200K / 131,072 |
glm-4.7-flash |
yes | free | 200K / 131,072 |
glm-4.6 |
yes | $0.60 / $2.20 | 200K / 131,072 |
glm-4.6v (vision) |
yes | $0.30 / $0.90 | 128K / 32,768 |
glm-4.6v-flash (vision) |
yes | free | 128K / 32,768 |
Three things in that table are load-bearing:
- The coding plan carries no prices, on purpose. It is a subscription, so a per-token
figure for
zai/*is not a number that exists; cost surfaces say "your plan covers it" rather than$0.00, which would read as free. The side effect is that/model route --autonever fires for a coding-plan main — it only picks candidates that publish a cost. Name the cheap tier yourself:"cheap": "zai/glm-5.3-flash". glm-5.2,glm-5.1andglm-5are not distinct models on the coding endpoint. Asking for one returns a response stampedmodel: "glm-5.3". They are listed underzai-apionly, where they bill as themselves.- The vision pair caps output at 32,768 tokens, a quarter of the text models'. They are the only image-capable models a plan-only key can reach, but they are not a drop-in substitute for GLM-5.3 on long output.
Prices are USD per million tokens (input / output) from Z.AI's rate card and are recorded at list price — GLM-5.3-Flash runs at half these until 2026-09-09, and baking a promotion into the catalog would leave every install under-reporting spend the day it lapses.
Nothing here is a gate. Any endpoint works without a preset, from configuration or from code.
From configuration
A providers block in ~/.arcturn/config.json declares an endpoint of your own — an
enterprise gateway (LiteLLM, a vLLM cluster, an internal proxy), Ollama on a non-default
host. It is the same { baseUrl, apiKeyEnv, protocol } triple a preset is, and it builds
its specs through the same function, so ids are namespaced <name>/<model> exactly as
presets are:
{
"providers": {
"mycorp": {
"baseUrl": "https://llm.corp.internal/v1",
"apiKeyEnv": "MYCORP_LLM_KEY", // REQUIRED
"protocol": "openai", // "openai" | "anthropic", default "openai"
"label": "MyCorp Gateway",
"models": [ // optional; absent = ids pass through verbatim
{ "model": "llama-70b", "contextWindow": 128000, "maxOutputTokens": 8192,
"capabilities": { "tools": true }, "cost": { "input": 0, "output": 0 } }
]
}
}
}arcturn --model mycorp/llama-70bEvery rule below drops the offending entry with a warning; nothing throws, and a bad entry never takes its siblings with it.
-
The variable it names is the only credential it gets. A spec built from a
providersblock is registered key-exclusive:apiKeyEnvis resolved and nothing else — noOPENAI_API_KEY/ANTHROPIC_API_KEYprovider default, no per-provider alternate, no client-wide key. Name a variable you do not have set and the session refuses, naming the provider, the variable and the file that declared it. It never starts on a borrowed key, and that holds for both protocols and for your own file as much as for a project's. -
apiKeyEnvis required for a remote endpoint, so the credential choice is visible in the file and reviewable in a diff. A loopback endpoint may omit it, and is then contacted with no credential at all — that is how you declare Ollama, LM Studio or a local vLLM:jsonc{ "providers": { "ollama-host": { "baseUrl": "http://localhost:11434/v1" } } }A loopback entry that does name a variable is held to it: the endpoint expects a key, so an unset one is an error naming the variable rather than a silent substitute.
-
https:only, excepthttp://localhost(and127.0.0.1,::1) for a local runtime. An API key sent in the clear to a remote host is a leaked credential. -
The name may not collide with a preset or a registered provider id — no shadowing.
-
A base URL still carrying a
{placeholder}is dropped rather than dialled. -
A base URL containing a control character is refused (C0, DEL, C1), and what is stored is the normalized
new URL(...).href, not the string as typed.URLis not a filter here — it strips tab/LF/CR and percent-encodes ESC — and this value is printed into the terminal prompt that asks whether to trust the endpoint. -
A project file may not name a variable holding a credential you keep for someone else. The rule is the set, not a list: every first-party default and alternate, every one of the built-in provider presets'
apiKeyEnvvalues (derived from the preset table, so adding a preset extends the rule automatically), the ambient-credential variables, and anything beginningAWS_. Comparison is case-insensitive, becauseprocess.envis case-insensitive on Windows. Your own~/.arcturn/config.jsonmay name any of them — proxying Anthropic through LiteLLM is a real setup — but no repository you cloned has a reason to ask for a key you hold for somebody else. -
Declared
models[]entries are validated, not trusted.cost.input/cost.outputmust be finite and non-negative —--max-costsums them, and a negative rate would earn budget on every turn — and eachcapabilitiesfield is checked by name and type, with a bad or unknown one dropped and warned about rather than cast through. Besides the four booleans andthinkingStyle, an entry may declarethinkingOff("omit","disabled","betweenTools"or"lowEffort"— how "off" is sent),thinkingLevels(the effort levels the model accepts; a requested level it lacks is clamped down) andrefusalFallback. A gateway has no catalog id for the adapter to infer these from, so a gateway serving Sonnet 5.5 under its own name needs"thinkingOff": "betweenTools"or it will think by default.
A declaration in ~/.arcturn/config.json is your own file and is trusted. A declaration
in a project's .arcturn/config.json is inert until you approve it — see
Provider endpoints from a project config,
including what that gate is not: it stops a
repository that declares an endpoint in data, and is not a boundary against one that can
execute code, since project hooks and project extensions run with no gate today.
arcturn --list-providers shows every declared endpoint with its state; arcturn doctor
reports one you have not enabled as not enabled and never probes it.
From code
import { openaiCompatible } from "@arcturn/ai";
openaiCompatible("https://my-endpoint.example/v1", "my-model", {
apiKeyEnv: "MY_API_KEY",
register: true,
});An extension's registerModel runs after the config block is applied, so code can
override an entry a config file declared. Data never overrides code.
Resolving a model id
getModel(id) (from packages/ai/src/catalog.ts) accepts two shapes: a full catalog id
("anthropic/claude-opus-5-5") or a bare wire model name ("claude-opus-5-5") when that
name is unambiguous across every registered spec. A miss triggers the extended-preset
world to register itself once (lazily, so a fresh process never pays for the whole preset
table unless something actually needs it), then retries the lookup. requireModel(id) is
the same lookup but throws Unknown model: <id> instead of returning undefined — the CLI
uses it anywhere a missing model should stop the run rather than silently degrade.
registerModel(spec) overrides or extends the catalog at runtime — the mechanism
presetSpec({ register: true }) and refreshCatalog both use internally, and the same one
available to embedders who want to register a private endpoint.
Failover chains
A model string can be an array instead of a single id — ["anthropic/claude-sonnet-5-5", "openai/gpt-4o"] — which builds a fallback chain via createFailoverClient in
packages/ai/src/failover.ts. Each link is tried in order; an attempt that fails
before any content has streamed hands off to the next link, so the caller sees one
continuous stream even though a different model produced the answer.
The one rule that matters: failover only happens before output starts. The moment a
single textDelta, thinkingDelta, or tool-call event reaches the consumer, the turn is
committed to that model — a later error surfaces unchanged rather than triggering a
switch, because splicing two half-answers together would corrupt the message. Structural
events (start, usage, blockEnd) don't count as output and don't commit a turn; the
leading start is held back until an attempt actually commits, so exactly one start
event is ever emitted, naming whichever model actually answered.
By default (defaultShouldFailover), only transient errors trigger a switch: rateLimit,
overloaded, and network. auth, invalidRequest, aborted, and unknown never do —
a bad API key or a malformed request fails identically on every link, and a user abort is
the user's own intent, so burning through the whole chain on either would be pointless.
Override the policy, or observe every switch, with FailoverOptions:
import { createFailoverClient, requireModel } from "@arcturn/ai";
const failover = createFailoverClient(
[
{ client, model: requireModel("anthropic/claude-sonnet-5-5") },
{ client, model: requireModel("openai/gpt-4o") },
],
{ onFailover: (from, to, error) => console.warn(`failing over: ${error.kind}`) },
);Consensus: cross-checking models for disagreement
Where failover treats multiple providers as redundancy, createConsensusClient
(packages/ai/src/consensus.ts) treats them as an uncertainty signal: run the same turn
on two or three independently trained models and treat disagreement — especially over
which tool calls to make — as the place a human should look. Asking a model "are you sure?"
is close to worthless, since the same weights produce the confidence report as the answer;
running a second model is not.
The primary (links[0]) streams through to the consumer verbatim — same events, same
order, no added latency to the first token. Secondaries run concurrently, buffered to
completion, and the verdict is delivered out-of-band through onVerdict after the
primary's stream ends — never gating the turn on agreement. onVerdict fires at most once
per stream() call and never before the primary's terminal event.
import { createConsensusClient, requireModel } from "@arcturn/ai";
const panel = createConsensusClient(
[
{ client, model: requireModel("anthropic/claude-sonnet-5-5") },
{ client, model: requireModel("openai/gpt-4o") },
],
{
sampleRate: 0.2, // one turn in five; consensus costs 2x on those turns
similarityThreshold: 0.6, // DEFAULT_SIMILARITY_THRESHOLD
onVerdict: (v) => {
if (v.agreement === "divergent") console.warn(`models disagree: ${v.details.join("; ")}`);
},
},
);Config shape (ConsensusOptions):
| Field | Default | Meaning |
|---|---|---|
onVerdict |
none | Required for the panel to run at all — omitting it means secondaries are never called, since a verdict nobody reads isn't worth paying for. |
sampleRate |
1 (every turn) |
Fraction of turns that run the panel. 0.1 samples one turn in ten; sampled-out turns cost exactly one model and emit no verdict — absence of a verdict is not agreement. |
similarityThreshold |
0.6 |
Minimum word-overlap similarity for "full" agreement once tool calls already match. Kept low deliberately: two models answering correctly routinely share only 60–70% of their words. |
random |
Math.random |
Injectable for deterministic sampling in tests. |
agreement is "full" (tool calls match and text similarity clears the threshold),
"partial" (tool calls match but text diverges, or no secondary was available to compare
against), or "divergent" (the models chose different tool calls — the highest-signal
finding). Comparison happens once, on the final assembled message: toolCallsMatch diffs
names/order/canonicalized-JSON arguments (call ids and key order ignored); textSimilarity
is a cheap Jaccard word-overlap over concatenated text blocks (thinking blocks excluded).
usageByModel is the only way to price a panel correctly — only the primary's usage reaches
the consumer's normal usage stream events, so a host that turns consensus on must surface
secondary spend separately. This multiplies token spend by N on every sampled turn;
there is no way around that cost, only ways to bound it (sampleRate, or simply not
supplying onVerdict).
Per-role routing
createModelRouter (packages/cli/src/router.ts) lets four different call sites use four
different models instead of hard-coding one flagship everywhere:
RouteKind |
What it's for |
|---|---|
main |
The main conversation loop. |
subagent |
Delegated sub-agent work — often mechanical, so a cheaper model is fine. |
compaction |
Summarizing history when the context window fills — lossy by design already. Consumed by every agent's compaction call only when a compaction route is explicitly configured; otherwise every agent compacts with its own model, even while a route.main override stands. |
title |
Session-title suggestions — a few words, generated after a session's first completed run. |
All four kinds are live: subagent drives delegated work, compaction picks the
summarizer model, and title picks the model that names a session for /sessions. Config
shape (RouterConfig): each key is an optional model id string. main absent means
"use the fallback model" (typically whatever --model resolved to); subagent /
compaction / title absent means "use whatever main resolves to". Resolution is lazy
and cached per kind — nothing resolves until router.specFor(kind) is actually called — and
a bad id (unknown model, deregistered preset, typo) is caught rather than thrown: the kind
falls back to the router's fallback model and a warning is recorded via router.warnings(),
because a stale model id in a config file must never be the reason Arcturn fails to start.
router.rebind(newFallback) clears the cache and adopts a new fallback — call it after the
session's main model changes, or routes that defaulted to the old one keep resolving to it.
You don't have to write the config by hand: /model route prints the effective routes,
and /model route --auto finds the cheapest same-vendor (same id namespace before the
/), tool-capable model with published pricing that undercuts a priced main model, and
routes subagent and compaction to it — applied live and saved
to your user config in one step. See
Model routing.
Live model catalog
The curated catalog above is hand-maintained, which means it goes stale the moment a
provider ships a new generation. /model refresh complements it with a best-effort
query of each preset's own "list models" endpoint — for whichever presets already have
an API key set — and registers anything new it finds into the same runtime catalog,
without ever touching or overwriting a curated entry:
/model refreshResults are cached at ~/.arcturn/live-models.json for 24 hours, so this doesn't hit the
network on every invocation — a fresh cache entry is reused as-is. A model discovered
this way that isn't in the curated table gets conservative defaults (128k context
window, 8,192 max output tokens, tools: true, vision/thinking/caching: false)
until a curated entry supersedes it — except that the Anthropic Models API reports
max_input_tokens (the context window) and max_tokens (the output cap) for every model,
and when it does, those replace the defaults, so a brand-new Claude is sized by what
Anthropic says rather than a 128K guess. A model found this way is still unpriced: limits
are read from the listing, a price never is, and cost reads as unknown rather than $0.
Discovery speaks whichever protocol the preset uses: OpenAI-protocol presets are queried
at <baseUrl>/models with a bearer token; Anthropic-protocol presets are queried at
<baseUrl>/v1/models with x-api-key — but only when the preset's base URL is a bare
origin with no path segment of its own, since several Anthropic-protocol presets already
end in something like /anthropic or /coding for the Messages endpoint, and guessing a
model-listing path onto that would be just that — a guess. A preset whose refresh fails
falls back to its last cached result (with a warning) rather than dropping its models
for that round.
Claude models and what changed with the 5.5 generation
The curated Anthropic entries, newest first within each family (every one a 1M-token context window and 128K output; prices are per million tokens):
| Model | Input / output | Cache read | Notes |
|---|---|---|---|
anthropic/claude-fable-5-1 |
$10 / $50 | $0.25 | Server-side refusal fallback available. |
anthropic/claude-fable-5 |
$10 / $50 | $1 | |
anthropic/claude-opus-5-5 |
$4 / $20 | $0.20 | Server-side refusal fallback available. |
anthropic/claude-opus-5 |
$5 / $25 | $0.50 | Server-side refusal fallback available. |
anthropic/claude-opus-4-8, -4-7, -4-6 |
$5 / $25 | $0.50 | Opus 4.6 has no xhigh thinking level. |
anthropic/claude-sonnet-5-5 |
$2 / $10 | $0.20 | The default model. Server-side refusal fallback available. |
anthropic/claude-sonnet-5 |
$2 / $10 | $0.20 | |
anthropic/claude-sonnet-4-6 |
$3 / $15 | $0.30 | No xhigh. |
anthropic/claude-haiku-5-5 |
$0.10 / $0.50 | $0.01 |
Claude Haiku 5.5's rate is the one for prompts of 100K tokens or fewer; longer prompts bill
at $0.50 / $2.50. The catalog carries a single rate per model, so a cost estimate for a very
long Haiku prompt is a floor. arcturn --list-models prints the full catalog, including
older generations that remain selectable.
Bedrock has not caught up. The Claude 5.x and Opus 4.7+ models on AWS are served by
Bedrock's Messages-API (Mantle) endpoint, and Arcturn's Bedrock adapter still speaks the
older bedrock-runtime API, so no catalog entries exist for them — shipping ones that
would answer with a 400 seemed worse than shipping none. Moving the adapter is a planned
follow-up. Vertex lists the same Claude ids under their bare first-party names, but
unpriced: Google Cloud bills on its own rate card, which Arcturn has not verified, so spend
on those models reads as cost unknown rather than borrowing Anthropic's price.
Refusals and server-side fallbacks
A model can decline a request outright. Arcturn treats that as its own outcome rather than
an error or an empty reply: the assistant message ends with stopReason: "refusal" and a
stopDetails of { category, explanation? }. The turn ends cleanly — there is no "turn
returned nothing" nudge that would re-send the declined request, any tool calls in the
refused message are stripped and never run, a refused message is skipped when history is
replayed to the provider, and a compaction whose summary is refused throws instead of
installing a fragment as the summary.
Each provider's own wording maps onto it, and category carries the provider's word for why:
| Provider | Becomes a refusal | category |
|---|---|---|
| Anthropic | stop_reason: "refusal" |
the stop_details category (cyber, …), or null |
| OpenAI (Chat Completions and Responses) | content_filter |
content_filter |
SAFETY, PROHIBITED_CONTENT, BLOCKLIST, SPII, IMAGE_SAFETY |
the filter name | |
| Bedrock Converse | content_filtered, guardrail_intervened |
the filter name |
You see it as "The model declined this request (cyber): <explanation>" in the TUI, the
transcript and the web UI. See Programmatic events for the
event shape and CLI reference for what --print does.
For models the Claude API can re-run on a fallback after a decline, there is an opt-in:
{ "providers": { "anthropic": { "fallbacks": "default" } } }It is off by default. With it on, requests to a model that supports it (today Fable 5.1,
Opus 5.5, Opus 5 and Sonnet 5.5) go through the Anthropic beta surface with the
server-side-fallback-2026-07-01 beta and fallbacks: "default"; when the first model
declines, the API answers the turn with a fallback model and the message is stamped with
the model that actually answered. It is never sent to Claude Haiku 5.5, which has no
server-side fallback. Billing follows the answering model — see
Audit trail & cost accounting. From code, the same switch is
AnthropicProviderOptions.fallbacks.
Preserved thinking
On Claude Fable 5.1, Opus 5.5, Sonnet 5.5 and Haiku 5.5 a thinking block's signature is bound to the exact prefix that produced it — the system prompt, the tool set and every earlier message. Replay the block after any of those changed and the API treats it as invalid:
- Enforced accounts — those created on or after 2026-08-31 — get a 400.
- Other accounts go through. With the controls below on, the API still reports that an
enforced account would have been refused (
thinking_mismatch_allowed), which arcturn shows as a warning.
Arcturn's answer is not to catch the 400 but to never send a changed prefix: tool-result elision, compaction, tool-search activation and operator messages all append now. See Why history is append-only. The same property keeps the prompt cache warm on every model, which is why the machinery runs for all Claude models on adaptive thinking (4.6 and later) and not only the four enforced ones.
thinkingBinding
{ "providers": { "anthropic": { "thinkingBinding": "drop_block" } } }From code: AnthropicProviderOptions.thinkingBinding /
CreateClientOptions.thinkingBinding. First-party anthropic only — the compatible,
Bedrock and Vertex adapters ignore it. It sends the thinking-binding-controls-2026-08-01
beta and, on every request with thinking on,
thinking.block_binding.prefix_mismatch_behavior:
| Value | A mismatched block |
|---|---|
"error" |
The request fails. Use it in CI, where a history edit is a bug to find. |
"drop_block" |
The API drops the unusable block and the request continues. |
Setting it also makes contextEditing.mode default to auto, since asking for the
controls is asking for an append-only history. arcturn doctor lists the setting.
What you will see
What the API reports in input_transformations becomes a notice (inputTransformationNotices
in @arcturn/core), and AssistantMessage.inputTransformations keeps the non-empty list:
| Notice | Level | Meaning |
|---|---|---|
prefix_binding_mismatch (thinking_dropped) |
warn | The conversation before a thinking block changed after the block was produced. Arcturn keeps history append-only, so this is a bug in arcturn — the notice says so. Report it with the session. |
thinking_mismatch_allowed |
warn | The same mismatch on an account that is not enforced. The request went through; an enforced account would have been refused. |
model_binding_mismatch (thinking_dropped) |
info | You switched models (/model, a fallback) and the new model cannot read the old model's thinking. Expected. |
Separately, if the system prompt or tool set differs between two requests in one run —
an MCP server attached, a deferred-tool activation on a model without native tool search —
the agent says so once, and stops replaying the earlier thinking it just invalidated.
Not verified live: the streaming shape of input_transformations on message_delta, and
block_binding sent alongside the lowEffort adaptive spelling.
Mid-conversation system messages
The harness occasionally needs to say something to the model that is not you: a turn-budget
warning, a progress check, a nudge after a silent turn, a stale-todo reminder. These are
stored as role: "system" messages (SystemMessage) and rendered per provider at send
time, so a /model switch re-renders the same history correctly:
| Provider / model | Rendered as |
|---|---|
| Anthropic — Opus 4.8, 5, 5.5; Fable 5, 5.1; Sonnet 5.5 | A native role: "system" message. |
| Anthropic — Sonnet 5, Haiku 5.5 | Folded into the neighbouring user turn as a <system-reminder> block. |
| OpenAI and Azure — Chat Completions | Native system message. |
| OpenAI — Responses | A developer item. |
Folded into the user turn, text prefixed [operator]. |
|
| Bedrock | Folded, as for Sonnet 5. |
| OpenAI-compatible gateways | Folded: a <system-reminder>-shaped user message. |
Where a model takes the native role, Anthropic still only allows it after a user turn and last or before an assistant turn — never first — so a message that would land elsewhere is folded instead. The same history always renders the same bytes.
Support is inferred from the model id. From code, a ModelSpec whose
capabilities.systemMessages is set overrides that inference; it is not a
.arcturn/config.json capability key today, so a gateway serving one of these models
under a string arcturn does not recognise gets the folded form.
If an API refuses the role anyway, arcturn recognises the 400 by its wording, marks that model unsupported for the rest of the process, and retries the request once folded.
This channel carries operator authority, so it is for harness-authored text only. Anything
a tool, a file or another agent produced is data and goes in a tool result or user message.
In the SDK, agent.steer("text") stays a user message; agent.steer(text, { from: "operator" }) is the opt-in. See Agent options.
Not verified live: the exact wording of the 400 for an unsupported role, and Bedrock
accepting role: "system" (it is folded today, so nothing depends on it).
Server-side context management
compaction.shape: "server" and contextEditing.mode: "server" hand compaction and
tool-result clearing to the Claude API itself (compact_20260112 and
clear_tool_uses_20250919), so arcturn sends the history as it is and the provider shapes
its own view of it. compaction blocks the provider returns, and server-tool blocks such as
native tool search, stream in and are replayed verbatim. The adapter turns on whichever
beta headers a request needs (compact-2026-01-12, context-management-2025-06-27,
thinking-binding-controls-2026-08-01).
Bedrock and Vertex have no beta surface here: a request asking for server-side context
management is refused with a clear error before it is sent, and thinkingBinding is never
sent to them. Their local, append-only paths work as everywhere else. anthropic-compatible
gateways are sent the request but whether a given gateway forwards the beta is up to it.
Not verified live: the streaming shapes of compaction_delta and server_tool_use deltas.
Subscription sign-in is not supported
You cannot point Arcturn at a Claude, ChatGPT or GitHub Copilot subscription. Signing in with one requires an OAuth client id that each provider issues to its own product, and Arcturn has none. Use an API key.
An earlier release shipped arcturn auth login for those three providers. It never
completed a sign-in — the client ids belonged to other vendors' tools and no endpoint or
scope in it had been checked against a live provider — so it was removed rather than left
in the help text as something that might work.
MCP OAuth (arcturn mcp auth <name>) is a different mechanism and does work:
it discovers the authorization server and registers a client dynamically, so it needs no
hardcoded endpoint and no borrowed client id.
Choosing a model in code
import { createClient, getModel, presetSpec, bedrockModel } from "@arcturn/ai";
const llm = createClient();
const direct = getModel("anthropic/claude-sonnet-5-5");
const viaPreset = presetSpec("groq", "llama-3.3-70b-versatile");
const onAws = bedrockModel("us.anthropic.claude-sonnet-4-5-20250929-v1:0", {
providerOptions: { region: "us-east-1" },
});Costs are tracked per request when a model's pricing is known; where a price could not be
sourced confidently it is omitted rather than guessed, so costUsd is simply absent.
Related
- Model routing — the config-and-precedence reference behind the per-role routes above: what each route kind resolves to, and which layer wins.
- Configuration — where
model,routeand provider credentials sit among every other key, and how session, project and user layers combine. - Audit trail & cost accounting — what the per-request cost above adds
up to across a whole session, and the
--max-costceiling that stops it.