Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Model providers

Copilot plugin @orthacms/copilot-server@orthacms/copilot-admin

Two adapters behind one port, and why a local model is a setting rather than a fork.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

The copilot reaches a model through a framework-free port. Two adapters ship; the composition root constructs them and hands the plugin a list.

PackageFactoryConstructed with
@orthacms/copilot-provider-anthropiccreateAnthropicProvider{ apiKey, models, baseUrl?, effort?, maxRetries?, timeoutMs?, promptCaching? }
@orthacms/copilot-provider-openaicreateOpenAiProvider{ baseUrl, models, apiKey?, headers?, capabilities?, timeoutMs?, maxRetries?, maxTokensField? }
  • Both retry a refused connection, 408, 429 or 5xx — 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.
  • models is 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 claude in 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 baseUrl in apps/server/config/copilot-anthropic.ts.
  • COPILOT_ANTHROPIC_MODELS has 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 runtime
Example language
COPILOT_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/completions is appended.
  • Registered as openai in a generated app, ollama in 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.