Skip to main content
Model provider plugins declare an inference backend — an OpenAI-compatible endpoint, an Anthropic Messages server, a Codex-style Responses API, or a Bedrock-native surface — that Mibyan can route AIAgent calls through. Every built-in provider (OpenRouter, Anthropic, GMI, DeepSeek, Nvidia, …) ships as one of these plugins. Third parties can add their own by dropping a directory under $mibyan_HOME/plugins/model-providers/ with zero changes to the repo.
Model provider plugins are the third kind of provider plugin. The others are Memory Provider Plugins (cross-session knowledge) and Context Engine Plugins (context compression strategies). All three follow the same “drop a directory, declare a profile, no repo edits” pattern.

How discovery works

providers/__init__.py._discover_providers() runs lazily the first time any code calls get_provider_profile() or list_providers(). Discovery order:
  1. Bundled plugins — <repo>/plugins/model-providers/<name>/ — ship with Mibyan
  2. User plugins — $mibyan_HOME/plugins/model-providers/<name>/ — drop in a directory; a running process picks it up on its next provider lookup (no restart)
  3. Installed plugins — $mibyan_HOME/plugins/<name>/ (where mibyan plugins install owner/repo clones) — imported only when plugin.yaml declares kind: model-provider; every other kind there belongs to the general PluginManager
  4. Legacy single-file — <repo>/providers/<name>.py — back-compat for out-of-tree editable installs
Steps 2 and 3 are per profile home: one process that serves several profiles (the multiplex gateway, the Desktop app’s mibyan serve) resolves the plugins of whichever profile’s $mibyan_HOME is bound at lookup time, and a plugin installed in one profile is not visible from another. Install the plugin in every profile that should use it (mibyan -p <profile> plugins install ...). User plugins override bundled plugins of the same name because register_provider() is last-writer-wins. Drop a $mibyan_HOME/plugins/model-providers/gmi/ directory to replace the built-in GMI profile without touching the repo.

Directory structure

The only required file is __init__.py. plugin.yaml is used by mibyan plugins for introspection and by the general PluginManager to route the plugin to the right loader; without it, the general loader falls back to a source-text heuristic.

Minimal example — a simple API-key provider

That’s it. After dropping these two files, the following auto-wire with no other edits:

ProviderProfile fields

Full definition in providers/base.py. The most useful ones:

Declaring model capabilities

Mibyan resolves per-model capabilities (supports_reasoning, supports_vision, supports_tools, context_window) from the models.dev catalog, which does not know an out-of-tree provider’s models. Declare them once on the profile:
Keys are exact model IDs. Values use the model_overrides schema from config.yaml — the three capability booleans, a positive context_window, an optional model_family — and omitted fields stay unknown (not False), so a partial entry patches catalog metadata without erasing it. One declaration feeds every consumer that reads the catalog through agent.models_dev: the /model picker’s reasoning badge, image routing (decide_image_input_mode goes native for a supports_vision: True model even when the profile-wide supports_vision is unset), context-window lookup, and the dashboard’s /api/model/info. Precedence: explicit user model_overrides.<provider>.<model> → plugin declaration → catalog → fill-gap _default. Models the plugin does not declare keep the catalog/heuristic path. Not covered: the picker’s fast badge (a model-name heuristic in mibyan_cli/models.py::model_supports_fast_mode), reasoning-effort vocabulary (agent/reasoning_effort.py), and transport request fields. Declarations do not add models to a picker — use fallback_models / fetch_models for that. The registry is discovered once per process: restart Mibyan after editing them.

Overridable hooks

Subclass ProviderProfile for non-trivial quirks:

Account usage

Model-provider plugins can provide account or plan usage to /usage by overriding ProviderProfile.fetch_account_usage. Import and return the shared agent.account_usage.AccountUsageSnapshot (with any AccountUsageWindow entries); do not format output in the plugin. Returning None, or raising an exception, leaves /usage empty just as it does for providers without usage data. Built-in usage fetchers always take precedence, so this hook cannot replace the account-usage behavior for a built-in provider. The hook runs under a shared 10 s deadline (agent.account_usage.PLUGIN_USAGE_HOOK_DEADLINE_S) on every surface; overrunning it renders nothing for that turn rather than stalling /usage, so give your own HTTP calls a shorter timeout. The bundled plugins/model-providers/opencode-zen/ profile implements this hook for the OpenCode Go plan windows; every /usage surface (CLI mibyan usage and /usage, the messaging gateway, the TUI/Desktop usage feed) renders the snapshot through the same core formatter.

External-process (ACP) providers

An agent CLI driven over stdio is not an HTTP endpoint. Set auth_type="external_process", describe how to launch the binary, and supply the client with create_client. No core edits are needed — mibyan -m <name>, /model, credential resolution, runtime resolution and the auxiliary client (compression, vision) all key on auth_type, not on the provider name. plugins/model-providers/copilot-acp/ is the in-tree example. The client your create_client returns receives command and args in client_kwargs. If it is already complete and async-safe, declare mibyan_SKIP_TRANSPORT_WRAP = True / mibyan_SKIP_ASYNC_WRAP = True as class attributes so the auxiliary client does not re-dispatch it through an HTTP wire adapter.

Picker rows for non-api-key plugins

Every registered profile joins CANONICAL_PROVIDERS by slug (a plugin re-declaring a built-in slug such as bedrock is deduped, never doubled), so external-process and OAuth plugins appear in mibyan model, /model and the Desktop model selector alongside copilot-acp. Visibility is gated by credentials, not by auth_type: The catalog cache is keyed on the profile’s process_command_env_vars / process_args_env_var values, so pointing mibyan_<X>_COMMAND at a different binary re-discovers models. Executable discovery is not a login check: an unauthenticated CLI still lists, and the subprocess reports the failure at first use. Selecting the row in mibyan model (and the setup wizard) runs one generic flow keyed by the profile’s auth_type: external-process profiles are launch-checked (resolve_external_process_provider_credentials), OAuth profiles need a live pool row (otherwise the flow prints mibyan auth add <name> and stops), then the merged catalog is offered and config.model is persisted with the profile’s base_url/api_mode. No _model_flow_* entry in core is needed.

Optional external-process hooks

External-process profiles may implement setup_status(**kwargs) returning {available, logged_in, plan, detail, login_command} and discover_models(**kwargs) returning [{id, label, note}]. The generic flow gates on logged_in (running login_command inline on a TTY, printing detail otherwise) and, when discover_models() returns rows, offers them merged with fallback_models; note renders as a dim per-row annotation (· usage credits) and never hides a model. Keep fetch_models() returning the same ids so /model and the Desktop picker agree with setup. Both hooks must be cheap and must never perform inference; return None to fall back to fallback_models. For interruptible non-HTTP requests, implement a class-declared cancel(self) method. Mibyan calls it from the interrupting thread after marking the request client unusable. It must return promptly and safely stop its own transport, including cancellation racing process startup; it must not close file descriptors owned by the request thread. The request owner still calls close() for cleanup. Clients without this method retain the existing socket-shutdown cancellation path. Declare model_aliases ({"sonnet": "claude-sonnet-5[1m]"}) for a catalog models.dev does not know: bare /model <alias> and /model <id-prefix> resolve inside the process provider first, and validate_requested_model accepts a declared id without probing process://. Explicit external-process delegation retains the selected provider and its protocol when resolving the child command; an executable override alone does not change an external-process provider into ACP. Native clients may persist private assistant replay in reasoning_details with a namespaced <provider>.native_assistant type. Declare the identical string in ProviderProfile.native_reasoning_details_type (default None). Chat Completions request sanitization forwards that carrier only to its declaring profile, including after fallback or model switching; it removes other private carriers even if their source plugin is no longer installed. Standard reasoning details such as OpenRouter’s reasoning.encrypted remain unchanged. Filtering is request-only: durable history remains intact for returning to the original provider. Providers may override get_model_context_length(model) with a qualified positive token bound, or return None for the existing lookup chain. Explicit configuration and endpoint-scoped overrides take precedence; the provider bound is consulted before generic caches and HTTP probes. Do not confuse a catalog maximum with an account entitlement. For a nonstandard cost surface, get_usage_cost(model, usage) may return an agent.usage_pricing.CostResult, or None for normal pricing. usage is a CanonicalUsage whose raw_usage retains response metadata when available. Classify native list-price totals as estimated, never actual or included; missing invoice information is not proof of zero charges. The default hooks return None, preserving existing providers.

Hook reference examples

Look at these bundled plugins for idioms:

User overrides — replace a built-in without editing the repo

Say you want to point gmi at your private staging endpoint for testing. Create ~/.mibyan/plugins/model-providers/gmi/__init__.py:
In a fresh Mibyan process, get_provider_profile("gmi").base_url returns the staging URL. No repo patch, no rebuild. Because user plugins are discovered after bundled ones, the user register_provider() call wins. The override also reaches the runtime. Built-in providers have a row in mibyan_cli.auth.PROVIDER_REGISTRY (the table resolve_runtime_provider() reads its endpoint and env vars from); a $mibyan_HOME plugin re-registering that name rewrites the row’s profile-derived fields, so inference goes to the staging URL, not the bundled one: Only a user plugin ($mibyan_HOME/plugins/model-providers/ or an installed kind: model-provider plugin) triggers this; a bundled profile never rewrites a built-in row, and copilot, kimi-coding, kimi-coding-cn and zai keep their bespoke credential resolution. A field the profile leaves empty keeps the built-in value. A *_BASE_URL env var still wins over both.

api_mode selection

Four built-in values are recognized (chat_completions, codex_responses, anthropic_messages, bedrock_converse), plus any mode a plugin registers itself. Mibyan picks one based on:
  1. User explicit override (config.yaml model.api_mode when set)
  2. OpenCode’s per-model dispatch (opencode_model_api_mode for Zen and Go)
  3. URL auto-detection — /anthropic suffix → anthropic_messages, api.openai.com → codex_responses, api.x.ai → codex_responses, /coding on Kimi domains → chat_completions
  4. Profile api_mode as a fallback when URL detection finds nothing
  5. Default chat_completions
Set profile.api_mode to match the default your provider ships — it acts as a hint. User URL overrides still win.

Shipping your own wire dialect

A plugin that speaks a protocol none of the built-in transports cover registers one and names it in the profile:
Every api_mode gate (determine_api_mode, runtime resolution, agent construction, delegation) accepts a mode iff the transport registry knows it; a profile naming a mode nobody registered still degrades to chat_completions.

Auth types

Every profile is mirrored into Mibyan’ auth registry under the auth_type it declares (two exclusions: an api_key profile with empty env_vars, and the aggregator/user-supplied slugs openrouter/custom plus the bespoke-refresh built-ins copilot/kimi-coding/zai), so mibyan auth, --provider <name> and runtime resolution accept it whatever its shape. What differs is who performs the login: api_key profiles get the built-in key prompt / env-var resolution; every other auth_type is provider-owned — the plugin ships the two hooks below, and a non-api-key profile without an auth_handler makes mibyan auth add <name> fail with a clear “ships no auth_handler” error instead of silently doing nothing.

Provider-owned auth (auth_handler, refresh_credential)

auth_type describes what kind of credential a provider needs; auth_handler is how the plugin acquires it — its own device-code / OIDC / IdC flow inside the existing mibyan auth command family (model-provider manifests are skipped by the generic command-plugin loader, so register(ctx) is not the way to add commands). refresh_credential is how the credential pool rotates a pooled token the plugin stored.
mibyan auth add|status|logout|refresh <provider> consults the handler first — before the built-in credential-pool flow. Registering the same name twice is last-writer-wins, so a user plugin can replace a bundled provider’s flow. Mibyan passes the parsed namespace, not provider-declared flags: ask for provider-specific values interactively (or read your own config/env). Rows the plugin stores in the pool are its own — extra keys survive load → save → load, and Mibyan passes no secrets beyond that pooled row to refresh_credential.

Declarative OAuth (PKCE) for plugins

A provider whose IdP speaks standard OAuth 2.0 Authorization Code + PKCE does not need to write the hooks above by hand: declare the endpoints in OAuthPKCEConfig and let the two factories build them.
Mibyan then owns the whole lifecycle: mibyan auth add example-pkce [--no-browser] opens the browser (or prints the URL, with the SSH-tunnel hint on a remote box), listens on http://127.0.0.1:<port>/callback, checks the CSRF state, exchanges the code with S256 PKCE and stores the grant as a pooled oauth credential (source: manual:loopback_pkce, expires_at_ms, refresh_token); auth status reports logged in / expired; auth logout removes the rows; auth refresh and the 401 recovery paths rotate via the refresh_token grant, re-reading auth.json under the auth lock first so a peer’s rotation is adopted instead of spending a single-use refresh token twice. Security boundary (enforced before any request, on login and refresh alike): both endpoints must be https:// (plain http:// is accepted only for a loopback-literal host — a local development IdP); the token_url host must be the authorize_url host or a subdomain of it (or listed in allowed_hosts); the listener binds the literal 127.0.0.1; tokens, state and the PKCE verifier are never logged. Optional fields: audience, extra_authorize_params, extra_token_params, redirect_path, timeout_seconds, label.

Recovery and error classification

A kind: model-provider plugin is loaded by provider discovery, not by the generic plugin manager, so the transform_api_error_classification plugin hook is not reachable from it without shipping a second plugin component. The profile carries the equivalent seam instead:
Recovery that remains name-keyed in core is behaviour with no safe generic shape (a provider-specific token store to re-sync, a plan-tier entitlement wall, a single-use refresh-token quarantine). A plugin that needs one of those owns it inside refresh_credential / classify_api_error.

Discovery timing

Provider discovery is lazy — triggered by the first get_provider_profile() or list_providers() call in the process. In practice this happens early at startup (auth.py module load extends PROVIDER_REGISTRY eagerly). If you need to verify your plugin loaded, run:
— a successful auth_type="api_key" profile appears under the Provider Connectivity section with a /models probe. For programmatic inspection:

Testing your plugin

Point mibyan_HOME at a temp directory so you don’t pollute your real config:

General PluginManager integration

The general PluginManager (the thing mibyan plugins operates on) sees model-provider plugins but does not import them — providers/__init__.py owns their lifecycle. The manager records the manifest for introspection and categorizes by kind: model-provider. When you drop an unlabeled user plugin into $mibyan_HOME/plugins/ that happens to call register_provider with a ProviderProfile, the manager auto-coerces it to kind: model-provider via a source-text heuristic — so the plugin still routes correctly even without plugin.yaml.

Distribute via pip

Model providers can ship as a pip package. Expose an entry point in the mibyan_agent.plugins group in your pyproject.toml:
The target may be either:
  • a callable (module:func) — invoked with no arguments; it should call register_provider(profile), or
  • a bare module (module) — imported for its module-level register_provider(...) side effect, mirroring the directory-plugin __init__.py contract.
providers/__init__.py discovers these entry points itself — the general PluginManager never invokes provider registration for pip packages (its entry-point path targets register(ctx)-style general plugins, gated by plugins.enabled), so the provider registry does its own scan. Two rules apply:
  • Opt-in required. The same plugins.enabled allow-list (and plugins.disabled deny-list) from config.yaml governs this scan. A pip package is never imported just because it is installed — users must add the entry-point name to plugins.enabled:
  • Lowest precedence. Entry-point plugins are discovered before filesystem plugins: because register_provider() is last-writer-wins, a bundled or $mibyan_HOME profile of the same name always overrides a pip-installed one. A pip package can add a genuinely new provider, but cannot silently hijack a first-party provider name.
Targets that require arguments (a general plugin’s register(ctx)) are skipped by the provider scan — they belong to the PluginManager. A broken entry point is isolated — it is logged at warning level and skipped, and never blocks discovery of the other providers. See Building a Mibyan Plugin for the full entry-points setup.