The copilot reaches a model through a framework-free port. Two adapters ship; the composition root constructs them and hands the plugin a list.
| Package | Factory | Constructed with |
|---|---|---|
@orthacms/copilot-provider-anthropic | createAnthropicProvider | { apiKey, models, baseUrl?, effort?, maxRetries?, timeoutMs?, promptCaching? } |
@orthacms/copilot-provider-openai | createOpenAiProvider | { baseUrl, models, apiKey?, headers?, capabilities?, timeoutMs?, maxRetries?, maxTokensField? } |
- Both retry a refused connection,
408,429or5xx— twice by default — and only before anything has streamed. - Only the Claude adapter imports a vendor SDK. The OpenAI-wire adapter is a request and an event-stream reader.
modelsis a list: the first is that backend’s default; the rest are what a user can switch to mid-conversation.
Claude
ANTHROPIC_API_KEY=sk-ant-… # setting it registers the `claude` backend
COPILOT_ANTHROPIC_MODELS=<model-id>,<model-id>
ANTHROPIC_BASE_URL= # optional gateway / proxy (monorepo app only)- Empty key → no
claudein the picker at all, rather than one that fails on the first message. - The key stays on the server; the admin talks only to your API.
- In a generated app, set
baseUrlinapps/server/config/copilot-anthropic.ts. COPILOT_ANTHROPIC_MODELShas a built-in default list when unset — see the config file.
Options worth knowing:
createAnthropicProvider({
apiKey,
models,
effort: 'low', // 'low' | 'medium' | 'high' | 'xhigh' | 'max' — the lever for cheaper runs
promptCaching: false // default on; turn off only for a gateway that rejects cache markers
});Capabilities (context window, output cap, vision) are probed from the Models API once per model. A failed probe falls back to a conservative profile and is retried next time.
Anything speaking the OpenAI wire format
One adapter, one base URL: Ollama, vLLM, llama.cpp, LM Studio, LiteLLM, OpenRouter, Azure, OpenAI.
COPILOT_OPENAI_BASE_URL=http://localhost:11434/v1 # setting it registers the backend
COPILOT_OPENAI_MODELS=<model-id>
COPILOT_OPENAI_API_KEY= # empty for a local runtimeCOPILOT_OPENAI_BASE_URL=http://localhost:11434/v1
COPILOT_OPENAI_MODELS=<model-you-pulled>
COPILOT_OPENAI_API_KEY= COPILOT_OPENAI_BASE_URL=https://api.openai.com/v1
COPILOT_OPENAI_MODELS=<model-id>
COPILOT_OPENAI_API_KEY=sk-… - No default base URL. An endpoint nobody named can only time out.
- The base URL includes the version segment;
/chat/completionsis appended. - Registered as
openaiin a generated app,ollamain the monorepo’s app. - Timeout defaults to 120 s per request (
timeoutMs).
Declare what the model can do
The wire format has no capability discovery. The adapter assumes tool calling, streaming, no vision, a 32K context. Say otherwise in code:
// apps/server/config/copilot-openai.ts (generated app)
return {
baseUrl,
apiKey: readEnv('COPILOT_OPENAI_API_KEY') ?? '',
models: readList('COPILOT_OPENAI_MODELS', '<model-id>'),
// A small model that cannot call tools MUST say so, or runs fail confusingly
capabilities: { toolCalling: false, contextWindow: 8192 },
// Reasoning models on OpenAI want this instead of max_tokens
maxTokensField: 'max_completion_tokens',
// Extra headers for a gateway
headers: { 'X-Team': 'content' }
};The declaration covers every model on that endpoint. Two models that differ → register two providers.
Capabilities are a profile, per run
A capability profile is resolved for each run and decides what is offered to the model. It is enforced again at execution — a tool that was not offered cannot be called even if the model invents its name.
Registering backends
// apps/server/src/plugins.ts
import { CopilotPlugin, type ProviderRegistration } from '@orthacms/copilot-server';
import { createAnthropicProvider } from '@orthacms/copilot-provider-anthropic';
import { createOpenAiProvider } from '@orthacms/copilot-provider-openai';
export function copilotProviders(config: OrthaCmsConfig): ProviderRegistration[] {
const providers: ProviderRegistration[] = [];
const { claude, openai } = config.plugins.copilot.providers;
if (claude) providers.push({ name: 'claude', provider: createAnthropicProvider(claude) });
if (openai) providers.push({ name: 'openai', provider: createOpenAiProvider(openai) });
return providers;
}
// in buildPlugins():
CopilotPlugin({ providers: copilotProviders(config), config: config.plugins.copilot });The order is the setting
The first entry serves every run that names no provider — including API callers
and tests — and is what the picker opens on. That is why an unconfigured
backend is never registered: a keyless claude at the top would be the house
default and fail on the first message.
Adding a backend to a generated app later: install its package at the same
version as the rest, add a config module beside config/copilot.ts, and a line
in copilotProviders.
Your own adapter
Implement the ModelProvider port from @orthacms/copilot-domain, construct it
in plugins.ts and add it to the list. The copilot packages need no change —
see writing a provider.
providers.push({ name: 'inhouse', provider: createInhouseProvider({ baseUrl, models }) });Not a provider: the scripted fake
provider-fake is deterministic, needs no key or network, and is a private
test fixture used by apps/server-e2e. It is not published and not
registered. A clone with nothing configured has no copilot. See the
scripted backend.
Small local models need more room, and still may not cope
A tool loop asks a model to plan, call, read and plan again. A small model needs more round trips — raise all three run ceilings together — and will still pick tools worse. The port makes a local model possible, not equivalent.