The copilot reaches a model through a port — a framework-free interface with no vendor concepts in it. Two adapters ship, and the composition root constructs them.
Only the Claude adapter may import a vendor SDK. That constraint is what keeps the copilot itself model-agnostic.
The two shipped adapters
| Adapter | What it is |
|---|---|
provider-anthropic | Native Claude, over the official SDK. The default for tool-heavy work; it probes the Models API for live capabilities. |
provider-openai | The OpenAI wire format, with a configurable base URL. |
Claude
ANTHROPIC_API_KEY=sk-ant-…
COPILOT_ANTHROPIC_MODELS=claude-opus-5,claude-sonnet-5,claude-haiku-4-5
ANTHROPIC_BASE_URL= # optional: a gateway, proxy or regional endpointSetting the key is what registers the backend. The key never leaves the server — the admin talks to your API, and your API talks to the model.
Anything speaking the OpenAI wire format
COPILOT_OPENAI_BASE_URL=http://localhost:11434/v1
COPILOT_OPENAI_MODELS=llama3.1
COPILOT_OPENAI_API_KEY= # empty for a local runtime that wants no authOne adapter, one base URL, and the list of things it reaches is long: Ollama, vLLM, llama.cpp, LM Studio, LiteLLM, OpenRouter, Azure, OpenAI.
This is the point of the design. Running a model on your own hardware is a setting, not a fork — which matters rather a lot for a self-hosted CMS whose users chose it to keep their content on their own infrastructure.
There is deliberately no default base URL: an endpoint nobody named is a backend that can only time out.
The scripted one is not one of them
provider-fake is deterministic and needs no key and no network, and it is a
private test fixture — unpublished, and not registered by the composition
root. apps/server-e2e uses it to drive the whole tool loop without a network
call; a deployment cannot.
A clone with nothing configured therefore has an empty provider list and no
copilot, rather than a scripted one. See the scripted
backend for why that changed. To work offline for
real, point provider-openai at a local Ollama.
Capabilities are a profile, computed per run
Not every model supports every capability, and the copilot does not assume. A capability profile is resolved per run and decides what is offered to the model — which is enforced again at execution, so a tool that was not offered cannot be called even if the model invents its name.
The Claude adapter probes the Models API for live capabilities rather than carrying a hard-coded table that goes stale.
Switching models mid-conversation
Each provider declares several models, and a user can switch between them within a conversation from the model picker. An operator changes what is on offer by changing the configured list — no redeploy of the copilot packages.
The conversation remembers the model it is set to.
Adding your own adapter
Implement the port, construct it in the composition root, and add it to the provider list. The copilot packages need no change, and the plugin never learns which adapters exist — it takes a list of named, already-constructed providers.
CopilotPlugin({
providers: [
{ name: 'claude', provider: createAnthropicProvider({ … }) },
{ name: 'ollama', provider: createOpenAiProvider({ … }) }
],
config
});The order is the setting
The first entry serves any run that names no provider — including runs started
by an integration test or an API caller — and is what the model picker opens on.
A keyless claude at the top of the list would be the house default and would
fail on the first message, which is why an unconfigured backend is not
registered at all.
Small local models need more room, and still may not cope
A tool loop asks a model to plan, call, read a result, and plan again. A smaller local model needs more round trips than a frontier one to reach the same answer, so it hits the step ceiling sooner — raise all three run limits together, and expect worse tool selection regardless. The port makes a local model possible, not equivalent.