Model Providers
How an account tells its agents server which language-model backends to call, how provider credentials are stored encrypted, and how models and reasoning levels are chosen.

A model provider is a record, scoped to an account, that tells the Seed Agents server how to call an LLM backend. Provider credentials are stored separately and encrypted. The record holds only a reference to them.

Provider record

The record is stored in model_providers.config_cbor (see persistence) (agents/protocol/src/index.ts:718):

type ModelProviderConfig = { type: string modelDefaults?: Record<string, unknown> secretRefs?: Record<string, string> baseUrl?: string /** `api-key` (default) reads secretRefs.apiKey; `subscription` reads secretRefs.oauth. */ authMode?: 'api-key' | 'subscription' }

Typical OpenAI provider:

{ type: 'openai', secretRefs: {apiKey: 'openai-api-key'}, modelDefaults: {temperature: 0.2} }

Subscription provider:

{ type: 'openai', authMode: 'subscription', secretRefs: {oauth: 'openai-subscription-oauth'} }

API actions

These are signed API actions.

    ListModelProviders: redacted provider metadata.

    ListProviderModels: decrypts the API key server-side and queries the provider's model-list endpoint.

    SetModelProvider: upserts provider config.

    SetSecret: encrypts and upserts a secret value.

    DeleteModelProvider: removes a provider record and its API-key secret.

    StartProviderOAuth, SubmitProviderOAuthCode, GetProviderOAuthStatus, CancelProviderOAuth: the subscription sign-in flow.

Returned provider shape (protocol/src/index.ts:1078):

type RedactedModelProvider = { id: string name: string type: string hasSecrets: boolean authMode?: 'api-key' | 'subscription' /** Subscription health: `ok`, or `needs-login` when credentials are missing or a refresh failed. */ authStatus?: 'ok' | 'needs-login' createdAt: number updatedAt: number }

No provider API returns plaintext secrets. ListProviderModels (api-service.ts:989, fetchProviderModels at :6725) returns only {id, name}:

    subscription providers fetch the live Codex picker (#listSubscriptionModels): GET https://chatgpt.com/backend-api/codex/models?client_version=… with the stored sign-in as bearer auth. See Subscription auth below for the fallback and auth-failure behavior;

    openai strategy (openai, openrouter, deepseek, groq, xai, ollama, custom): GET {base}/models. The Authorization: Bearer header is added only when a key exists, so keyless Ollama and custom providers work. name is the id, and there is no display name;

    anthropic: GET {base}/v1/models with x-api-key and anthropic-version: 2023-06-01 instead of Bearer. name is display_name when present;

    google: GET {base}/models?key=… with the key in the query string. It drops models whose supportedGenerationMethods exists and lacks generateContent, strips the models/ id prefix, and prefers displayName.

Errors: a missing key on a requireApiKey provider fails with 400 before any fetch. An unknown provider name is 404. A non-OK upstream response is 502 "<label> request failed: HTTP <status>", and a malformed body is 502 "<label> response is invalid". joinUrlPath() (api-service.ts:6788) clears search and hash, so a custom base URL carrying a query string loses it on the model-list request.

Supported provider types

Provider behavior is driven by one code-owned registry, PROVIDER_SPECS (agents/src/api-service.ts:6603). Adding a provider is usually one entry there plus a matching PROVIDER_METADATA entry in frontend/packages/ui/src/agents/provider-registry.ts. Most providers are OpenAI-compatible. They use the same openai-completions execution and GET /models list path and differ only by base URL.

type

Pi API

default base URL

base URL editable

API key

model list

openai

openai-completions*

https://api.openai.com/v1

no

required

openai

anthropic

anthropic-messages

https://api.anthropic.com

no

required

anthropic

google

google-generative-ai

https://generativelanguage.googleapis.com/v1beta

no

required

google

openrouter

openai-completions

https://openrouter.ai/api/v1

no

required

openai

deepseek

openai-completions

https://api.deepseek.com

no

required

openai

groq

openai-completions

https://api.groq.com/openai/v1

no

required

openai

xai

openai-completions

https://api.x.ai/v1

no

required

openai

ollama

openai-completions

http://localhost:11434/v1

yes

optional

openai

custom

openai-completions

(user-supplied, no default)

yes

optional

openai

* openai switches to openai-responses whenever the resolved model is reasoning-flagged. See below.

custom is the generic OpenAI-compatible type. The user supplies the base URL, so it covers self-hosted servers (LM Studio, vLLM, llama.cpp, LocalAI) and any future OpenAI-compatible endpoint without a code change. It has no default base URL, so the user must supply one.

ollama and custom are the only two types with allowCustomBaseUrl: true and requireApiKey: false. The other seven are the inverse. resolveProviderBaseUrl() (api-service.ts:6718) honors a stored baseUrl only for ollama and custom. For pinned providers the spec default always wins, so a stored API key cannot be redirected to an arbitrary host. When debugging, keep in mind that SetModelProvider still validates and stores a baseUrl on a pinned provider (api-service.ts:6533). Execution ignores it, so the record can disagree with what runs. Because custom has an empty default, it is the only type that can hit the "Base URL is required for provider type: custom" 400. The trust rationale is in security.

Subscription auth ("Sign in with ChatGPT")

An OpenAI provider can authenticate with the user's ChatGPT plan instead of an API key.

    Gated by the operator. The flow is offered only when the server sets SEED_AGENTS_SUBSCRIPTION_AUTH (config.subscriptionAuth, agents/src/config.ts:20). It needs a client that can catch the provider's localhost redirect, which is the desktop app, or a user willing to paste the redirect URL. The desktop checks the server's health flag before offering the option.

    The flow lives in agents/src/provider-oauth.ts: PKCE against https://auth.openai.com/oauth/authorize and /oauth/token, client id app_EMoamEEZ73f0CkXaXp7hrann, redirect http://localhost:1455/auth/callback, scope openid profile email offline_access. One login per account runs at a time. parseAuthorizationInput() accepts either a bare code or the full pasted redirect URL. Credentials land in a stable per-account secret named <type>-subscription-oauth, so re-login overwrites in place.

    Execution re-points the provider entirely (api-service.ts:4246): the Pi provider id becomes openai-codex (SUBSCRIPTION_PI_PROVIDER_ID), the base URL becomes https://chatgpt.com/backend-api (SUBSCRIPTION_CODEX_BASE_URL), and the API becomes openai-codex-responses. Credentials live in AuthStorage rather than as a runtime API key, so Pi re-resolves them per request and auto-refreshes expired access tokens through the shared persisted backend. Rotated tokens are saved for future runs.

    Failure is explicit. The access token is resolved, and refreshed if needed, up front. If that fails, the secret is marked needs-reauth and the run fails with "Your OpenAI subscription sign-in has expired or was revoked. Open model provider settings and sign in with ChatGPT again." The user sees this message instead of a cryptic mid-stream 401 (api-service.ts:4262).

    Models come from the same ChatGPT backend endpoint the Codex CLI fills its picker from (fetchCodexSubscriptionModels): GET {SUBSCRIPTION_CODEX_BASE_URL}/codex/models?client_version=… with the access token as bearer auth and the workspace id in ChatGPT-Account-Id. The backend has no public docs for this; the response is {models: [{slug, display_name, visibility, priority, supported_reasoning_levels, context_window, …}]}. Entries with visibility: "hide" (internal review models) are dropped and the rest keep priority order.

      client_version (SUBSCRIPTION_CODEX_CLIENT_VERSION) is required and gates what the backend returns (each entry has a minimal_client_version). Bump it with the Codex CLI when a new generation stops showing up.

      A network or backend failure falls back to SUBSCRIPTION_CODEX_FALLBACK_MODELS, a snapshot of the picker, so the agent form never gets an empty list. Pi-ai's openai-codex catalog is not used for this: it lags a generation and the backend rejects its older ids ("model is not supported when using Codex with a ChatGPT account").

      A 401 from the endpoint flags the secret needs-reauth and fails the listing with the re-auth message above.

Model registration and reasoning

piModelForDefinition() (api-service.ts:6800) builds the single model entry registered per run.

For subscription runs it prefers Pi's openai-codex catalog entry (accurate context window, image support, cost). For unknown ids it synthesizes a default, and marks it as a reasoning model because all Codex models are reasoning models.

For everything else:

    reasoning is set when the agent selected a level or when the model needs an explicit "no reasoning" value. Pi only sends reasoning parameters for models flagged reasoning. OpenAI's newer chat models turn reasoning on by default server-side and reject function tools unless it is explicitly disabled. So the flag has to be on to send effort: 'none'.

    api is openai-responses for reasoning-flagged OpenAI models, and the spec's API otherwise. OpenAI's gpt-5.1+ models reject function tools on /v1/chat/completions unless reasoning is explicitly disabled, and reject tools entirely once an effort is set there. The Responses API is the supported path for tools plus reasoning.

    input is ['text', 'image'] when modelSupportsImageInput() says so. This decides whether image attachments reach the model as image parts or as metadata text (protocol/src/model-capabilities.ts).

    For non-subscription models, cost is zeroed and contextWindow/maxTokens are fixed defaults (128000/16384). Token counts are real, but dollar figures are not yet.

Reasoning levels

agents/protocol/src/reasoning.ts is shared by the server and every model picker. Levels are minimal, low, medium, high, xhigh, max. Providers gate levels per model generation, so the lists below were tested against live provider APIs instead of copied from provider docs:

family

levels

leaving it unset

OpenAI gpt-5 / gpt-5-mini

minimal, low, medium, high

provider default (can't disable)

OpenAI gpt-5.1

low, medium, high

off (sends effort: 'none')

OpenAI gpt-5.2+ (incl. 5.4, 5.6)

low, medium, high, xhigh

off (sends effort: 'none')

OpenAI gpt-6+ (e.g. gpt-6-astra)

low, medium, high, xhigh, max

provider default (rejects none, can't disable)

OpenAI o-series (o1/o3/o4)

low, medium, high

provider default

Anthropic claude-3-7 and later

minimal, low, medium, high

off

Google gemini-2.5+

minimal, low, medium, high

off, except -pro (default)

gpt-5-chat* variants expose no reasoning control. Anything else returns null, including every OpenAI-compatible passthrough type, and the shared ReasoningSelect picker renders nothing for it.

Each run creates an in-memory Pi session (#runPiAgent, api-service.ts:4298) with:

    AuthStorage: inMemory() with a runtime-only API key for api-key providers, or fromStorage() over the persisted OAuth backend for subscription providers;

    ModelRegistry.inMemory() plus a per-run provider/model registration;

    SessionManager.inMemory() so Pi persists no session JSONL of its own;

    SettingsManager.inMemory({compaction: {enabled: false}});

    a no-discovery ResourceLoader whose system prompt is the assembled agent prompt (see the prompt injection map);

    noTools: 'builtin' and an explicit tool list: the verbs, plus any promoted callables, plus return_result for typed children. delegate is included only when the turn has a run to park on and room in its delegation budget, and continue_session only for a foreground conversation.

The selected level rides on AgentDefinition.reasoningLevel and is passed to Pi as thinkingLevel at session creation (#runPiAgent in api-service.ts, defaulting to 'off'). applyReasoningEffort() then decides what the outgoing OpenAI Responses payload says about effort. The stored level wins over anything Pi produced, because Pi clamps levels for models its catalog does not know. With no level, the request sends none where the generation accepts it. Otherwise it omits the effort so the provider default applies. Pi writes none for every level-less reasoning model, and gpt-6+ rejects that.

The matrix is a starting guess, and the runtime corrects it. When a provider rejects the effort a run sent ("Unsupported value: 'none' is not supported with the 'gpt-6-astra' model. Supported values are: 'low', …"), learnReasoningEffortSupport() records the accepted list for that model in process memory. applyReasoningEffort() uses that list on every later request. A rejected none is dropped in favor of the provider default, and a rejected level moves to the nearest accepted one. So a model newer than this file costs one failed turn and then runs. Extend the matrix afterwards so the picker offers the right levels.

Every request logs {sessionId, agentId, provider, model, reasoningLevel, activeTools, payloadTools} before dispatch. Read that line first when a provider rejects a call.

Message context

Pi receives, in order:

    the assembled system prompt (agent definition + shared instructions + memory + user-actions + Space index + signing identities);

    durable Seed user and assistant messages converted to Pi messages, with user-actor tool events replayed as <user_action> blocks;

    durable tool_call events reconstructed as Pi assistant tool-call messages;

    durable tool_result events as Pi tool-result messages;

    ephemeral per-turn blocks: <background_work_update> when a park-resume ends on an assistant message, and the <plan_state> checklist last.

Historical tool events are rebuilt as paired assistant tool-call and tool-result messages. This way later turns replay valid provider history with no orphaned tool results.

Session titling

Untitled sessions get a title from one minimal model call with no tools (#generateSessionTitle, api-service.ts:2966). It is gated by SEED_AGENTS_SESSION_TITLE_GENERATION (config.titleGeneration). The server default is on. The Service option default is off, so mocked test providers never see surprise requests.

It resolves its model through piProviderRuntimeForTitle(), which uses the same #piProviderRuntime as an agent run. Subscription providers have no apiKey secret, and the old inline resolution silently gave up, so every subscription-provider session stayed untitled. The call runs with thinkingLevel: 'off', no tools, and the same modelDefaults and reasoning payload treatment as a normal run. The first line of the reply is stripped of quotes and stored. A title the user edited through UpdateSession is never overwritten.

Adding or changing provider execution

    Add the PROVIDER_SPECS entry (and the shared UI's PROVIDER_METADATA entry).

    If the model needs reasoning control, add its generation to reasoning.ts with a note on how the levels were verified. If it takes images, add it to model-capabilities.ts.

    Preserve session lifecycle and WebSocket partials.

    Map Pi assistant and tool events into ordered message, tool_call, and tool_result events.

    Add mocked network tests for success, streaming, text-before-tool ordering, tools, missing key, and provider errors.

    Confirm decrypted secrets stay in memory and are never written to Pi auth files.

    Update this page, signed API, desktop UI, and roadmap.

Open provider work

    Real-provider smoke coverage for Anthropic and Google through Pi, including model-list behavior.

    A provider test button.

    Secret rotation UI (providers can already be deleted).

    Real cost tables. cost is zeroed today, so usage is counted in tokens and never in money.

    Per-provider reasoning payload quirks (compat.thinkingFormat for deepseek and openrouter) are not wired up yet. Those types register as non-reasoning models.

Do you like what you are reading? Subscribe to receive updates.

Unsubscribe anytime