11. Provider & Model System
1. Goal
PI-Desktop must support all major market model vendors and models that users commonly need, without hardcoding a tiny allowlist as product ceiling.
Strategy:
Universal provider coverage via models.dev model metadata + pi-ai transport adapters + OpenAI-compatible escape hatch.
We do not re-implement every vendor SDK ourselves.
We standardize on pi’s multi-provider layer and add product-level configuration, catalog, and UX.
2. Coverage principle
Must support
- First-party major vendors
- Popular aggregators / gateways
- Any OpenAI-compatible endpoint
- User-defined custom providers
- Continuous model catalog refresh
Product promise
- Users can connect practically any mainstream vendor/model available through:
- native pi provider integrations
- OpenAI-compatible APIs
- custom provider definitions
Explicit non-promise
- Guaranteeing every obscure vendor’s proprietary non-standard protocol without an adapter
- Shipping offline full world-model matrix forever without catalog updates
3. Architecture
Settings / UI
→ ProviderConfigStore (Rust host DB)
→ AgentRuntime (Node/pi)
├─ built-in vendor providers (via pi-ai)
├─ openai-compatible provider
└─ custom provider definitions
→ ModelCatalogService
├─ models.dev snapshot (sole model metadata source)
├─ runtime/provider discovery (IDs only for custom/dynamic models)
└─ Rust-owned provider cache4. Provider types
| type | description | examples |
|---|---|---|
native | first-class vendor integration via pi-ai | openai, anthropic, google, bedrock, mistral, etc. |
openai_compatible | any OpenAI Chat Completions/Responses compatible gateway | OpenRouter, Together, Groq, Fireworks, DeepSeek, local gateways, corporate proxies |
custom | user-defined provider based on known protocol profile | private deployments, regional gateways |
Protocol profiles (MVP):
openaianthropicgoogleopenai_compatiblebedrock(if enabled by runtime support)custom_http(advanced/experimental later)
OpenCode Go is exposed as a named opencode_go API-style preset. It remains inside the openai_compatible provider path: the preset fixes the endpoint to https://opencode.ai/zen/go/v1, uses Bearer API-key authentication, discovers models from /models, and sends chat turns through pi-ai's OpenAI Chat Completions adapter. It does not create a second transport or a closed model allowlist. Agent-runtime injects OpenCode routing headers on every LLM request (session turns, subagents, context-compaction summaries, prompt enhancement, and plugin one-shots): x-opencode-session is the durable conversation id (or a per-call UUID when the caller has no session), x-opencode-client is pi-desktop, and User-Agent is pi-desktop/<APP_VERSION> unless the row sets headers["User-Agent"]. A custom OpenAI-compatible row whose base URL host is opencode.ai receives the same headers. pi-ai is not relied on to emit x-opencode-session. Each provider row (AI service or OAuth account) may set optional headers; empty keeps adapter defaults. A fetch wrapper is the last writer so Codex and Anthropic cannot overwrite them.
When an OAuth vendor is rebuilt around a local provider-row id, runtime keeps the native pi-ai transport metadata instead of treating the row as a generic OpenAI endpoint. GitHub Copilot requests retain the pinned model's IDE identity headers, including Editor-Version, Editor-Plugin-Version, and Copilot-Integration-Id; agent-runtime adds the context-sensitive X-Initiator, Openai-Intent, and image-request header. The local row id still owns auth binding and transcript identity, and user-supplied provider headers remain the final override.
Zhipu / GLM and Z.AI are named OpenAI-compatible endpoint presets among a short models.dev-backed Service list of first-party vendors (including Xiaomi). The add-provider Service picker persists the matching models.dev vendorKey and uses the published endpoint without showing Name, Base URL, or API format on the named-service path. Chat turns still use the selected pi-ai adapter (chat_completions, responses, anthropic_messages, google_generative_ai, or opencode_go). Zhipu / Z.AI Completions requests use thinkingFormat: "zai" and zaiToolStream: true. DeepSeek-family Completions requests set requiresReasoningContentOnAssistantMessages: true when the row's vendorKey, base URL, model id, or catalog family identifies DeepSeek. pi-ai only auto-detects provider === "deepseek" or a deepseek.com URL, and PI-Desktop stores a UUID as model.provider, so aggregators and custom gateways would otherwise omit reasoning_content on assistant turns that produced no thinking. Non-official DeepSeek endpoints also set requiresNonEmptyReasoningReplay so missing reasoning is filled with a documented placeholder instead of "" (OpenCode / third-party relays reject empty echoes after compaction; see ADR 0256 / #296). Official deepseek.com rows keep empty-string fill (#223). The overlay does not change thinkingFormat.
5. Built-in vendor matrix (ship intent)
Model metadata follows the bundled/in-memory models.dev catalog. Provider adapters remain available through pi-ai, and the OpenAI-compatible path stays open for models that models.dev does not list.
Tier A — always exposed in UI
- OpenAI
- Anthropic
- Google Gemini
- OpenAI-Compatible (generic)
Tier B — expose when runtime supports / enable by default if present in pi-ai
- AWS Bedrock
- Azure OpenAI / OpenAI on Azure
- Mistral
- xAI
- DeepSeek
- Groq
- Together
- Fireworks
- Cohere
- Perplexity
- OpenRouter
- Moonshot / Kimi
- Zhipu / GLM
- MiniMax
- Baichuan
- Qwen / DashScope
- 01.AI / Yi
- SiliconFlow
- NVIDIA NIM
- Ollama (local)
- LM Studio (local OpenAI-compatible)
- vLLM / TGI / LocalAI / LiteLLM gateways (via OpenAI-compatible)
Tier C — user custom
Any vendor not listed but reachable by:
- OpenAI-compatible base URL
- custom headers
- custom auth scheme
6. Model support policy
6.1 No hard model allowlist ceiling
PI-Desktop must not permanently restrict users to a short fixed model list.
6.2 Catalog responsibilities
- models.dev (
https://models.dev/api.json) is the sole model metadata source. Electron main reads the checked-in release resource atapps/desktop/resources/models.dev/api.jsonin development and the packagedresources/models.dev/api.jsonpath in released builds. It never sends provider credentials to the catalog. - The checked-in snapshot is refreshed by
scripts/release.mjsbefore a release tag is created. At runtime, Settings → Model configuration may explicitly refetchhttps://models.dev/api.json; a successful response replaces only the in-memory catalog for the current process. A failed fetch keeps the last valid in-memory catalog and never writes user data. - Runtime/provider discovery and the Rust-owned cache supply model IDs for custom, local, or authenticated account-specific endpoints. Provider matching accepts the configured vendor key, normalized API URL, models.dev provider identity, and vendor-prefixed IDs such as
deepseek/deepseek-v4; a provider model ID without the catalog prefix is matched to the exact unprefixed suffix only when the provider identity is unambiguous. Native adapter keys may use a catalog alias — for example, pi-ai'sopenai-codexChatGPT subscription adapter resolves model metadata through theopenairecord — while the adapter keeps its own transport identity. They cannot invent or replace model metadata. A configured free-form ID remains selectable with the generic text-only, non-reasoning baseline when it is absent from models.dev. Settings still permits an explicit thinking-level override for an endpoint the catalog does not know yet. - The models.dev record maps
id,name,description,family,attachment,reasoning,reasoning_options,tool_call,structured_output,temperature,knowledge,release_date,last_updated,modalities.input/output,open_weights,limit.context/input/output,cost,interleaved,status,experimental, andproviderinto the shared model surfaces. - pi-ai remains only the request/OAuth implementation layer. Its bundled model catalog and model capability functions are not read for names, limits, pricing, modalities, reasoning, or other model configuration.
- Input and output modality arrays retain
text,image,audio,video, andpdf. The text agent picker exposes models that can handle text while preserving all raw records in the file for future surfaces. Image input is sent as a transient image content block only when the model accepts image input. PDF capability is surfaced and retained in model metadata; because pi-ai 0.86.1 has no native PDF content block, PDF attachments remain bounded file references rather than being incorrectly encoded as images. - User-edited
ModelBindingvalues remain explicit provider configuration: they control selected request limits, enabled thinking levels, the default thinking level applied to a new home draft and newly persisted session (clamped onto the enabled set; strongest-enabled only when the default is unset), and the attachment capability overrides.models.devsupplies published metadata and seeds the initial thinking selection for a newly added known model; it is not a runtime gate on a level the user explicitly enables for the endpoint. For compatibility, a binding that still contains the legacy generic128,000context seed follows a newly publishedlimit.context; a non-default Advanced value remains explicit. This keeps the sidecar and context inspector on the same effective window after a catalog refresh. - Settings renders the seven canonical thinking levels for every binding. Published levels begin selected for a known reasoning model. A non-reasoning or unknown model shows the same choices unselected, with a short manual override note.
defaultThinkingLevelis chosen fromomitplus the levels the binding enables, so a stored default is eitheromitor part of that explicit set. supportsImagesandsupportsDocumentsare three-state overrides. Absent ornullfollows the published models.dev modality, so a catalog correction still reaches a saved binding;trueorfalseis the user's explicit answer and survives catalog changes. Unlike thinking levels these overrides are not narrowed to the published capability, because a proxied or self-hosted endpoint routinely accepts input its catalog entry omits. Enabling image input turns on the transient image content block; enabling PDF input records the capability but does not change the encoding, since pi-ai 0.86.1 has no PDF content block and PDFs stay bounded file references.- The settings checkboxes show the effective answer against the published baseline, and setting one back to the published value stores "follow the catalog" rather than an equal-valued override. Agreeing with models.dev is therefore the reset, and no separate reset control or per-capability explanatory copy is required. 10a.
nativeWebSearchis a two-state opt-in (absent means off; there is no catalog baseline because models.dev publishes no hosted-tool capability). When enabled and the model's resolved wire API isanthropic-messages,openai-responses, orazure-openai-responses(stored apiStyleanthropic_messages/responses), the adapter attaches the provider's hosted web search tool (web_search_20250305/web_search), extracts the search activity intoUiMessage.hostedSearch(roundsfor display,replayfor convertMessages), and restores those raw blocks on later turns including after a restart (ADR 0297). The checkbox is disabled when the provider's API style is neither of those two. Gateways that do not support the tool surface the provider error; the remedy is unchecking. Search runs on the provider: there is no local fetch and no permission prompt. Compaction keeps its existing prefix/tail retention strategy. The summary request includes search replay data from the compacted prefix; the generated text summary is not a lossless copy of raw provider search blocks. ModelInfois the published record the settings surface compares against, so a stored binding must not shape its capabilities or reasoning fields. Effective limits, reasoning and thinking levels are resolved through the exact binding; the effective transport modality arrays additionally apply the explicit attachment overrides.- A model the user has configured keeps its published record even when live discovery no longer lists it, so its capabilities remain visible and editable. Only ids already present in the provider's
modelsare re-added, never the catalog at large, and only the rows discovery actually returned are written to the model cache.
6.3 Model families to cover
Catalog and custom model entry must support common capability classes:
- text chat / coding models
- reasoning / thinking models
- long-context models
- vision / multimodal input models
- tool-calling capable models
- JSON/structured output capable models (where provider supports)
Hosted-search message and budget contract
- Search content and progress events are declared adapter types, not disguised client tool calls. A search result or Responses search item does not require
nameorarguments. Existing replay records remain compatible: a stored record this app wrote without replay ids degrades to "no replay" for that message instead of failing the turn, so pre-upgrade history stays usable. - Replay and token estimation share the search-phase interpretation. Valid usage covers its prefix once; zero or invalidated usage triggers a complete estimate which includes search replay data. Display rounds and streaming scratch must not duplicate that data. Estimates are not billing guarantees. This applies to main-agent and native pi-session compaction as well as output budgets. When a target model is known, estimates follow that adapter's existing model-switch replay boundary. Compaction serialization includes search replay projections in the summary request, without treating them as client tool calls. The compacted prefix becomes a generated text summary; raw search in the retained tail follows the existing replay policy. No lossless summary is promised.
- Rebuilding an unchanged context must preserve system-prefix semantics. Actual instruction or tool-declaration changes remain visible to usage validation. System sections and tool additions/removals cannot be discarded.
- Search followed by a local tool, Task, a new user prompt, or restart recovery must exercise the same contract. Dependency upgrades must run the offline adapter and bundled-sidecar continuation regressions, not only UI tests.
7. Configuration schema
type ProviderAuthKind =
| "api_key"
| "api_key_and_base_url"
| "bearer"
| "azure_api_key"
| "aws_sdk_default"
| "custom_headers"
| "oauth" // vendor subscription account, credential owned by Electron main
| "none" // local no-auth
type ProviderConfig = {
id: string // uuid/ulid
name: string // display name
vendorKey: string // openai/anthropic/google/openrouter/custom/...
type: "native" | "openai_compatible" | "custom"
protocol: "openai" | "anthropic" | "google" | "openai_compatible" | "bedrock" | "custom_http"
enabled: boolean
baseUrl?: string
authKind: ProviderAuthKind
secretRef?: string // pointer into secret store
headers?: Record<string, string> // optional outbound headers; empty keeps adapter defaults
apiStyle?:
| "chat_completions"
| "opencode_go"
| "responses"
| "anthropic_messages"
| "google_generative_ai"
| "openai_codex_responses" // vendor account only
| "pi_messages" // vendor account only
| "auto"
compatibility?: {
supportsTools?: boolean
supportsVision?: boolean
supportsStreaming?: boolean
supportsReasoning?: boolean
supportedThinkingLevels?: ThinkingLevel[]
}
defaultModelId?: string
models: ModelBinding[] // selected models and per-model settings
createdAt: string
updatedAt: string
}
type UserModelConfig = {
id: string // provider-local model id/slug
displayName: string
providerId: string
contextWindow?: number
maxOutputTokens?: number
capabilities?: Array<"text" | "tools" | "vision" | "reasoning" | "json">
pricingHint?: string
hidden?: boolean
}
type ModelBinding = {
id: string
contextWindow: number
/** Where `contextWindow` came from; absent on records older than the marker,
* which then resolve through the historical rule (see
* `13-model-catalog-and-selection.md` §9.1). */
contextWindowSource?: "catalog" | "user"
maxTokens: number
thinkingLevels: ThinkingLevel[]
defaultThinkingLevel: SessionThinkingLevel | null
availableForSubagents?: boolean // opt-in for AI-driven delegation
}
type SelectedModelRef = {
providerId: string
modelId: string
}
type ThinkingLevel =
| "off"
| "minimal"
| "low"
| "medium"
| "high"
| "xhigh"
| "max"The compatibility fields above are retained as a persisted-schema compatibility surface for older clients. PI-Desktop no longer reads them as runtime model overrides. ModelInfo reasoning support and supported thinking levels describe the resolved models.dev record; effective provider/session capability comes from the exact ModelBinding. Unknown free-form ids start with the generic shape and no inferred reasoning capability, but an explicit binding may opt into levels.
The provider dialog persists one ModelBinding for every selected model. The first binding is the effective model for current conversations and legacy runtime consumers. Conversation-level model switching and routing across the array remain future work. A legacy provider with only defaultModelId is materialized as one fallback binding on host read and upgraded to models on the next provider write.
ModelBinding.availableForSubagents (boolean, default false): opt-in flag that makes the model available for AI-driven subagent delegation. When enabled, the model appears in the delegation catalog injected into the parent agent's system prompt. The parent agent can then select it via the Task tool's model parameter. Resolving a model for a definition pin does not imply this opt-in. The launch payload carries the permitted override keys separately as subagentModelKeys; definition-only bindings remain available solely through normal pin resolution, including when Task.model repeats that definition's own pin key. On-demand matching uses unique provider id/vendor/name lookup and must not overwrite a pin with another account's credentials. If vendor/model aliases collide across accounts, the opted-in account uses its exact provider ID as the override key. Selection priority remains Task.model → definition pin → session model (D278; ADR subagent-model-opt-in). The opt-in governs every entry point that lets the AI pick a model for delegated work, not only Task.model: a session/collaboration/spawn modelKey naming a model without it is refused with PERMISSION_DENIED, while omitting the key, or naming the default model's own key, still inherits.
8. Secrets
- API keys stored via secure storage (
SECRET_*APIs) - Provider config stores only
secretRef/ hasSecret boolean - Renderer never receives raw key in list APIs
- Optional key validation call:
providers.testConnection - A vendor-account row stores an OAuth grant instead of a key;
hasSecretcovers either credential andhasOauthdistinguishes them (§8a)
8a. Vendor-account (OAuth) providers
A provider row can be authenticated by a vendor subscription — Claude Pro/Max, ChatGPT Plus/Pro, Copilot and the rest of pi-ai's OAuth vendors — instead of a pasted key (ADR 0095, D237, D240). The offered vendors are derived from models.getProviders().filter(p => p.auth.oauth), so the list follows the pin rather than a hardcoded table, and registerBunOAuthFlows() runs once at startup because pi-ai loads flows through a dynamic import electron-vite cannot bundle.
Electron main owns the login conversation and the credential; the renderer sees only events and a non-secret account label. Every login creates a fresh provider row with authKind: "oauth"; the row id is the account identity even when several rows share the same vendorKey. Electron main creates one pi-ai model collection and one CredentialStore scope per row, mapping the vendor id to secret:provider:<rowId>:oauth. It then fills baseUrl, apiStyle and defaultModelId from that account's own catalog. A vendor catalog response contains every local row as an account, including disconnected/orphaned rows so the user can remove them explicitly.
Request auth is resolved per request, not at launch:
sidecar request
→ runtime provider binding (`resolveAuth` injected at launch)
→ host-proxy `provider.resolveAuth` { sessionId, providerId }
→ Electron main (answered locally, never forwarded to host-core)
· binding table check → PROVIDER_NOT_BOUND on a mismatch
· row-scoped pi-ai `models.getAuth(vendorKey)` → refresh under that row's lock only if expired
→ short-lived ModelAuth { apiKey?, headers?, baseUrl? }Two consequences worth stating: a vendor access token lives about an hour, so nothing may be cached in the payload or the runtime; and because the row's apiKey stays "" and the injected resolver is a function, runtime identity (matches()) is stable, so an OAuth session reuses its warm runtime across turns instead of rebuilding it. The sidecar therefore never holds the refresh token, and holds an access token only for the provider its session is bound to.
Model discovery for such a row reads the authenticated catalog (models.getAvailable, which applies the vendor's own filterModels) rather than probing /models, and the connection test proves the account by resolving auth. For static OAuth vendors such as ChatGPT Plus/Pro (openai-codex), that catalog is the pinned pi-ai model list rather than a live vendor /models probe, so a newly published account model such as gpt-6-astra appears only after the pin includes it. xAI (xai, the Grok/X subscription) is the exception: a successful GET /v1/models with the resolved account token is the list of conversation models the account may use, including an id the pinned pi-ai catalog does not know yet. Image and video generators in that payload are dropped. When the request fails, the pinned catalog remains the fallback. models.dev still supplies metadata once the ID is available, but it cannot add the ID to the authenticated list. A vendor may span wire APIs — Copilot serves Anthropic, Chat Completions and Responses models — so the row's apiStyle follows the selected model. Deleting a row calls the normal host providers.delete path, which removes its OAuth secret and metadata; it never logs out or deletes another row with the same vendor key.
Anthropic token endpoint rate limits
The pinned pi-ai 0.86.1 patch gives Anthropic authorization-code exchange and refresh a shared, bounded token-request policy: retry only an explicit HTTP 429, at most three total requests. Wait at least 1 s then 2 s, or longer when Retry-After gives delta seconds or an HTTP date. A server delay beyond the remaining budget ends the attempt; it is never shortened to fit. Malformed or missing hints use the bounded exponential fallback.
One 30 s helper deadline covers requests, response-body reads and waits, and all use the original caller signal. An earlier caller deadline wins; pi-ai's existing refresh operation has a 15 s limit inside the credential-store lock. Cancellation also stops pending waits. The patch does not move refresh outside that lock: failed attempts leave the stored credential unchanged, and a successful rotated grant is written once.
Network failures, interrupted bodies, 5xx and invalid_grant are not replayed: the result of a non-idempotent token request may be ambiguous. An explicit invalid_grant stops even if a response is labelled 429. HTTP/token-JSON failures expose a bounded recovery message rather than raw response bodies, URLs or embedded stacks. Login guidance tells the user to wait, close the failed dialog and start sign-in again; refresh guidance suggests waiting before retrying and signing in again if the problem continues. HTTP 429 alone does not prove whether a code was consumed, so no expiry claim is made.
This uses the existing repository dependency-patch mechanism; OAuth endpoints, PKCE, credential ownership, IPC and storage schemas are unchanged.
9. Model catalog service
interface ModelCatalogService {
listProviders(): Promise<ProviderDescriptor[]>
listModels(filter?: ModelQuery): Promise<ModelDescriptor[]>
refreshCatalog(options?: { providerId?: string }): Promise<RefreshResult>
resolveModel(ref: SelectedModelRef): Promise<ResolvedModel>
upsertUserModel(model: UserModelConfig): Promise<void>
}ModelDescriptor
type ModelDescriptor = {
providerId: string
vendorKey: string
modelId: string
displayName: string
source: "bundled" | "discovered" | "user"
catalogSource?: "models.dev"
capabilities: Array<"text" | "tools" | "vision" | "reasoning" | "json">
contextWindow?: number
maxOutputTokens?: number
deprecated?: boolean
tags?: string[]
supportedThinkingLevels?: ThinkingLevel[]
}10. UI requirements
Settings → Agent → Providers
- add built-in vendor quickly
- add OpenAI-compatible endpoint
- add custom provider
- edit base URL/headers
- set/replace/delete key
- set optional custom headers in Advanced (empty keeps adapter defaults); copy the same normalized JSON used for persistence
- sign in to / out of a vendor account, and see which account a row uses
- edit a vendor account's non-secret label, custom headers, and default model
- enable/disable provider
- test connection
- select multiple models and edit each binding's context window, output limit, and enabled thinking levels; catalog metadata supplies the initial values for both API providers and signed-in vendor accounts. The picker always exposes the seven canonical levels: published levels seed known models, while any explicit selection is retained for a proxy or newly released model. Both surfaces present this through the same picker, so a vendor account editor offers the same per-binding editing as an API provider editor
- keep model cards compact by default, expand metadata/configuration on demand, and keep dialog actions outside the independently scrollable content
- do not expose raw catalog compatibility internals or provider secrets
- Settings → Import can copy provider/model rows from Claude Code, Codex, OpenCode, Pi, and CC Switch. The scan is explicit. Stored API keys are copied into the host secret store; OAuth/subscription grants are not. An equivalent provider (normalized URL + API style + same credential) is skipped on re-import. Different credentials at one endpoint remain independent providers. No protocol or schema version bump (D342 / ADR 0179 / ADR 0188).
Model selector
- search all models across enabled providers
- group by provider/vendor
- show capability badges (tools/vision/reasoning)
- allow “refresh models”
- allow custom model id entry
Empty/error states
- no provider configured
- key missing
- model not found
- provider unauthorized
- catalog refresh failed (still allow manual model id)
11. Runtime resolution algorithm
When starting a turn with (providerId, modelId):
- load provider config from host
- if missing/disabled → fail (
MODEL_NOT_CONFIGURED; reserved detail:PROVIDER_DISABLED) - resolve the credential: for a keyed row read the secret via
secretRef(never log it; missing →PROVIDER_SECRET_MISSING); for anoauthrow skip this entirely and launch with an empty key, because auth is resolved per request (§8a) - resolve the models.dev record by matched provider key/API URL and exact model id
- copy its complete model configuration — name, description, family, attachment/reasoning/tool/structured-output/temperature flags, knowledge and release dates, input/output modalities, weights, status, interleaving, limits, cost data, and thinking options — into the runtime model snapshot; when absent, use the generic text-only, non-reasoning shape
- derive vision transport from models.dev
modalities.input; provider discovery/cache claims cannot promote an unresolved model, while an explicit attachment binding override can - clamp the session thinking level against the exact binding's enabled levels and build the runtime provider adapter by replacing only provider/model identity, selected API adapter, auth, and an explicitly configured endpoint URL. For
anthropic_messages, the runtime removes a trailing/v1from that URL before passing it to pi-ai because the Anthropic SDK appends/v1itself; configured roots with or without/v1therefore both reach the same/v1/messagesroute. Subagent providers resolved from a definition pin, the delegation model catalog, orTask.modeluse this same binding-aware model configuration before their thinking level is clamped; models.dev is only the baseline and cannot erase explicit binding levels. - execute stream with abort handle and separate answer/thinking events
- translate vendor errors into shared
AppErrorcodes (§15)
If the model is absent from models.dev, still allow it when the user explicitly enters a model id and the provider accepts unknown ids. Cached/provider capability fields do not promote that fallback into a known runtime model.
12. Compatibility tiers
| tier | meaning |
|---|---|
| full | tools + streaming + vision verified/expected |
| standard | chat streaming expected |
| limited | best-effort via compatible gateway |
| unknown | user custom, no guarantees |
UI may show tier hints, but must not hard-block unknown models by default.
13. Refresh & update policy
- Electron main reads the bundled release resource before serving model metadata:
apps/desktop/resources/models.dev/api.jsonin development andresources/models.dev/api.jsonin packaged builds. scripts/release.mjsfetcheshttps://models.dev/api.json, validates it, and atomically replaces the checked-in resource before creating a release tag.- Settings → Model configuration can force a remote refresh at any time; a successful response updates only the current process's in-memory catalog.
- Provider endpoint discovery supplies IDs only for models absent from the bundled/in-memory models.dev snapshot; unknown IDs use generic metadata.
- Refresh failure must not wipe the bundled file, Rust-owned provider cache, or configured bindings.
14. Local / offline model support
Supported via OpenAI-compatible local servers:
- Ollama (native if pi supports it, otherwise OpenAI-compatible proxy)
- LM Studio
- vLLM / TGI / LocalAI / LiteLLM proxies
- other local gateways
Requirements:
- custom base URL
- auth may be
none - manual model id entry always available
- catalog refresh may use
/v1/modelswhen available; otherwise user-defined models
15. Failure taxonomy (provider domain)
Canonical codes live in 08-error-codes; reserved detail codes map to a canonical parent until emitted (§3.7 there).
| code | status | meaning | user-facing guidance |
|---|---|---|---|
PROVIDER_UNAUTHORIZED | live | invalid/expired key or denied auth | re-enter secret / check account |
PROVIDER_RATE_LIMITED | live | 429 / quota | retry later / switch model |
PROVIDER_SECRET_MISSING | live | enabled provider without secret | complete setup |
MODEL_NOT_CONFIGURED | live | no selected model or provider rejects selected model with 404 | select or configure an available model |
PROVIDER_ERROR | live | other upstream provider failure | retry / inspect details |
NETWORK_ERROR | live | provider endpoint cannot be reached | check network and base URL |
STREAM_FAILED | live | stream dropped mid-turn | retry turn |
PROVIDER_BASE_URL_INVALID | reserved → PROVIDER_ERROR | malformed or unreachable base URL | fix endpoint |
PROVIDER_PROTOCOL_MISMATCH | reserved → PROVIDER_ERROR | wrong protocol for endpoint | switch protocol profile |
PROVIDER_MODEL_NOT_FOUND | reserved → MODEL_NOT_CONFIGURED | model id unknown for provider | refresh catalog or custom id |
PROVIDER_TIMEOUT | reserved → TIMEOUT | network or server timeout | retry / check network |
PROVIDER_UNSUPPORTED_CAPABILITY | reserved → PROVIDER_ERROR | tools/vision/reasoning unsupported | switch model or disable feature |
PROVIDER_DISABLED | reserved → MODEL_NOT_CONFIGURED | provider exists but disabled | enable provider |
16. OpenAI-compatible first-class path
Any vendor can be onboarded without a native SDK if it exposes OpenAI-compatible APIs.
Required fields:
baseUrl- auth (
api_key/bearer/none/ custom headers) - model id (catalog or free-form)
Optional:
apiStyle(chat_completions|opencode_go|responses|auto)- compatibility flags
headers(optional outbound HTTP headers; empty keeps adapter defaults)
For the OpenAI Chat Completions adapter, system instructions use the standard system role by default. This keeps arbitrary compatible gateways interoperable because some upstream routes reject the newer developer role, including reasoning-model routes. A resolved model record may explicitly set compat.supportsDeveloperRole: true when its endpoint is known to accept that role; this override is model-scoped and does not change other providers.
A catalog entry may additionally pin a model-level wire API (for example, api: "openai-responses"). When present it wins over the provider-wide apiStyle, so responses-only models under an opencode_go provider are sent through the Responses adapter instead of Chat Completions. Without a model-level pin the provider-wide style applies unchanged.
This is the universal escape hatch guaranteeing market coverage beyond native integrations.
16.1 Responses stream termination (pi-ai patch)
The OpenAI Responses adapter must treat response.completed (and response.incomplete) as the end of the stream: after finalizing the response, it stops consuming the stream instead of awaiting the server's TCP FIN. Upstream pi-ai keeps iterating until the server closes the connection, which hangs the turn behind reverse proxies that hold the idle connection open. Until the fix ships upstream, patches/ carries a pnpm patch on @earendil-works/[email protected] that breaks the event loop on the terminal event (the OpenAI SDK aborts the underlying request when the consumer stops iterating). Drop the patch once a pi-ai release includes the fix.
17. Multi-provider product rules
- Multiple providers of the same
vendorKeyare allowed and independent (for example, two OpenRouter accounts); each row has its own OAuth secret scope. - Provider
nameis user-editable and unique for API/custom services. OAuth rows may share the vendor display name; their stable identity isproviderIdand their non-secret account label is presentation metadata. - Default app model is a
(providerId, modelId)pair, not modelId alone. - Session stores its own
(providerId, modelId)binding. - Deleting a provider blocks new turns that reference it; historical sessions keep the ids for audit/display.
- Export settings never includes raw secrets.
- Import settings can recreate provider shells and prompt for secrets.
- Reasoning capability is model-specific unless the provider has an explicit compatibility override; provider defaults must not override a session's selected model during turn resolution.
18. Validation rules
namerequiredvendorKeyrequiredprotocolrequiredbaseUrlrequired for openai_compatible/custom when endpoint not implicit- secret required when
authKindneeds key - headers must not contain raw api keys (use secret store)
- model id non-empty
19. Acceptance criteria
- [ ] Add OpenAI / Anthropic / Google / OpenAI-Compatible providers from UI
- [ ] Add arbitrary OpenAI-compatible custom provider with base URL + key
- [ ] Select models via catalog search across providers
- [ ] Free-form model id accepted when catalog misses it
- [ ] Catalog refresh populates models for at least one native and one compatible provider, without destroying existing providers
- [ ] Connection test returns structured success/failure without secret leakage
- [ ] Two accounts from the same vendor can be signed into from Settings, used independently for turns, and removed one at a time; the sidecar never receives either refresh token
- [ ] Session can switch model between turns
- [ ] Known reasoning levels seed a binding, while all seven canonical levels remain explicitly selectable and the selected level reaches pi; bindings with no non-
offlevel resolve tooff - [ ] Missing key/model blocks run with stable, actionable error codes
- [ ] At least one local provider path (Ollama or LM Studio style) documented and testable
- [ ] No product hard-limit like “only 3 vendors / 10 models”
20. Non-goals (MVP)
- Building our own full provider SDK ecosystem
- Guaranteeing identical tool/vision quality across all vendors
- Marketplace of providers (not needed; config is local)
- Full multi-modal attachment studio beyond model capability flags
- Automatic paid-plan discovery for every vendor portal
- Proprietary non-HTTP SDKs without pi-ai support
- Cloud-synced provider profiles