> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mibyanai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Model Provider Plugins

> How to build a model provider (inference backend) plugin for Mibyan

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.

<Tip>
  Model provider plugins are the third kind of **provider plugin**. The others are [Memory Provider Plugins](/desktop/developer-guide/memory-provider-plugin) (cross-session knowledge) and [Context Engine Plugins](/desktop/developer-guide/context-engine-plugin) (context compression strategies). All three follow the same "drop a directory, declare a profile, no repo edits" pattern.
</Tip>

## 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

```
plugins/model-providers/my-provider/
├── __init__.py       # Calls register_provider(profile) at module-level
├── plugin.yaml       # kind: model-provider + metadata (optional but recommended)
└── README.md         # Setup instructions (optional)
```

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

```python theme={null}
# plugins/model-providers/acme-inference/__init__.py
from providers import register_provider
from providers.base import ProviderProfile

acme = ProviderProfile(
    name="acme-inference",
    aliases=("acme",),
    display_name="Acme Inference",
    description="Acme — OpenAI-compatible direct API",
    signup_url="https://acme.example.com/keys",
    env_vars=("ACME_API_KEY", "ACME_BASE_URL"),
    base_url="https://api.acme.example.com/v1",
    auth_type="api_key",
    default_aux_model="acme-small-fast",
    fallback_models=(
        "acme-large-v3",
        "acme-medium-v3",
        "acme-small-fast",
    ),
)

register_provider(acme)
```

```yaml theme={null}
# plugins/model-providers/acme-inference/plugin.yaml
name: acme-inference
kind: model-provider
version: 1.0.0
description: Acme Inference — OpenAI-compatible direct API
author: Your Name
```

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

| Integration | Where | What it gets |
| - | - | - |
| Credential resolution | `mibyan_cli/auth.py` | `PROVIDER_REGISTRY["acme-inference"]` populated from profile |
| `--provider` CLI flag | `mibyan_cli/main.py` | Accepts `acme-inference` |
| `/model --provider`, model picker switch | `mibyan_cli/providers.py::resolve_provider_full` | Resolves `acme-inference` and every alias to the profile (switch lands on `name`, so `acme` persists as `acme-inference`); user `providers:` / `custom_providers:` blocks keep precedence. A profile with an empty `base_url` (endpoint minted at runtime) resolves too, on the last rung |
| `mibyan model` picker | `mibyan_cli/models.py` | Appears in `CANONICAL_PROVIDERS`, model list fetched from `{base_url}/models` |
| `mibyan doctor` | `mibyan_cli/doctor.py` | Health check for `ACME_API_KEY` + `{base_url}/models` probe |
| `mibyan setup` | `mibyan_cli/config.py` | `ACME_API_KEY` appears in `OPTIONAL_ENV_VARS` and the setup wizard |
| URL reverse-mapping | `agent/model_metadata.py` | Hostname → provider name for auto-detection |
| Auxiliary model | `agent/auxiliary_client.py` | Uses `default_aux_model` for compression / summarization |
| Runtime resolution | `mibyan_cli/runtime_provider.py` | Returns correct `base_url`, `api_key`, `api_mode` |
| Transport | `agent/transports/chat_completions.py` | Profile path generates kwargs via `prepare_messages` / `build_extra_body` / `build_api_kwargs_extras` |

## ProviderProfile fields

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

| Field | Type | Purpose |
| - | - | - |
| `name` | str | Canonical id — matches `model.provider` in `config.yaml` and the `--provider` flag |
| `aliases` | `tuple[str, ...]` | Alternative names resolved by `get_provider_profile()` (e.g. `grok` → `xai`) |
| `api_mode` | str | `chat_completions` \| `codex_responses` \| `anthropic_messages` \| `bedrock_converse` |
| `display_name` | str | Human label shown in `mibyan model` picker |
| `description` | str | Picker subtitle |
| `signup_url` | str | Shown during first-run setup ("get an API key here") |
| `env_vars` | `tuple[str, ...]` | API-key env vars in priority order; a final `*_BASE_URL` entry is used as the user base-URL override |
| `base_url` | str | Default inference endpoint |
| `models_url` | str | Explicit catalog URL (falls back to `{base_url}/models`) |
| `auth_type` | str | `api_key` \| `oauth_device_code` \| `oauth_external` \| `copilot` \| `aws_sdk` \| `external_process` |
| `auth_handler` | `Callable \| None` | Provider-owned `mibyan auth add/status/logout/refresh <name>` — see [Provider-owned auth](#provider-owned-auth-auth_handler-refresh_credential) |
| `refresh_credential` | `Callable \| None` | Provider-owned rotation of a pooled OAuth row — same section |
| `classify_api_error` | `Callable \| None` | Provider-scoped error-classification override — see [Recovery and error classification](#recovery-and-error-classification) |
| `fallback_models` | `tuple[str, ...]` | Curated list shown when live catalog fetch fails — in the `/model` picker AND the first-time `mibyan setup` / `mibyan model` API-key flow, which resolve the catalog the same way (`fetch_models()` merged curated-first with `fallback_models`; `fallback_models` alone when the fetch returns `None` or raises) |
| `supports_vision` | bool | Declares the provider's API accepts image content inside **tool-result** messages (a provider-wide wire capability). Per-model user-image routing comes from `model_capabilities` / models.dev, not from this flag |
| `model_capabilities` | `dict[str, dict[str, Any]]` | Per-model capability declarations in the `model_overrides` schema — see [Declaring model capabilities](#declaring-model-capabilities) |
| `default_headers` | `dict[str, str]` | Sent on every request (e.g. Copilot's `Editor-Version`); also forwarded by the default `fetch_models()` catalog request |
| `fixed_temperature` | Any | `None` = use caller's value; `OMIT_TEMPERATURE` sentinel = don't send temperature at all (Kimi) |
| `default_max_tokens` | `int \| None` | Provider-level max\_tokens cap (Nvidia: 16384) |
| `unsupported_response_formats` | `tuple` | `response_format` types the API rejects outright; auxiliary requests omit them instead of paying a guaranteed 400 (DeepSeek: `("json_schema",)`) |
| `default_aux_model` | str | Cheap model for auxiliary tasks (compression, vision, summarization) |

## 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:

```python theme={null}
register_provider(ProviderProfile(
    name="acme",
    auth_type="api_key",
    env_vars=("ACME_API_KEY",),
    base_url="https://api.acme.example/v1",
    fallback_models=("acme-large-high", "acme-small"),
    model_capabilities={
        "acme-large-high": {
            "supports_reasoning": False,   # reasoning tier is fixed by the model id
            "supports_vision": True,
            "supports_tools": True,
            "context_window": 64000,
            "model_family": "acme",
        },
    },
))
```

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:

```python theme={null}
from typing import Any
from providers.base import ProviderProfile

class AcmeProfile(ProviderProfile):
    def prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
        """Provider-specific message preprocessing. Runs after codex
        sanitization, before developer-role swap. Default: pass-through."""
        # Example: Qwen normalizes plain-text content to a list-of-parts
        # array and injects cache_control; Kimi rewrites tool-call JSON
        return messages

    def build_extra_body(self, *, session_id=None, **context) -> dict:
        """Provider-specific extra_body fields merged into the API call.
        Context includes: session_id, provider_preferences, model, base_url,
        reasoning_config. Default: empty dict."""
        # Example: OpenRouter's provider-preferences block,
        # Gemini's thinking_config translation.
        return {}

    def build_api_kwargs_extras(self, *, reasoning_config=None, **context):
        """Returns (extra_body_additions, top_level_kwargs). Needed when some
        fields go top-level (Kimi's reasoning_effort, OpenRouter's verbosity for
        adaptive Anthropic models) and some go in extra_body (OpenRouter's
        reasoning dict). Default: ({}, {})."""
        return {}, {}

    def fetch_models(self, *, api_key=None, base_url=None, timeout=8.0) -> list[str] | None:
        """Live catalog fetch. Default hits {models_url or base_url}/models with
        Bearer auth. Override for: custom auth (Anthropic), no REST endpoint
        (Bedrock → None), or public/unauthenticated catalogs (OpenRouter)."""
        return super().fetch_models(api_key=api_key, base_url=base_url, timeout=timeout)

    def fetch_account_usage(self, *, api_key=None, base_url=None):
        """Return AccountUsageSnapshot for /usage, or None when unavailable.

        The hook runs only when the provider has no built-in usage fetcher.
        It may raise: core catches failures and keeps /usage empty for that turn.
        """
        return None

    def create_client(self, **client_kwargs):
        """Supply your own client object instead of the shared openai.OpenAI.
        Default returns None (= use the standard client). Override when the
        wire protocol is not OpenAI-over-HTTP — e.g. an ACP subprocess shim.
        client_kwargs is what the core would have passed to openai.OpenAI
        (api_key, base_url, command, args, timeouts, headers…); accept **kwargs
        and pick what you need. A raise is logged and falls back to the
        standard client."""
        return None
```

## 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.

```python theme={null}
from datetime import datetime, timezone

from agent.account_usage import AccountUsageSnapshot, AccountUsageWindow

def fetch_account_usage(self, *, api_key=None, base_url=None):
    return AccountUsageSnapshot(
        provider=self.name,
        source="my_provider_api",
        fetched_at=datetime.now(timezone.utc),
        windows=(AccountUsageWindow(label="Monthly", used_percent=25),),
    )
```

## 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.

| Field | Purpose |
| - | - |
| `process_command` | Default binary, e.g. `"copilot"` |
| `process_args` | Default argv tail, e.g. `("--acp", "--stdio")` |
| `process_command_env_vars` | Env vars that override the binary, checked in order |
| `process_args_env_var` | Env var that overrides argv (shlex-split) |

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`:

| `auth_type` | Row is listed / `authenticated` when | Model list |
| - | - | - |
| `external_process` | the binary resolves (`process_command` or one of `process_command_env_vars` is on `PATH`), or `base_url` is `acp+tcp://…` — the same structural gate `mibyan auth status` reports. A resolving binary is also the sign-in evidence (`auth_verified`) the Desktop model selector's explicit-only filter uses, so the row shows there like the bundled ACP provider does | `fetch_models()` (your subprocess probe), else `fallback_models` |
| `oauth_external` / `oauth_device_code` | the credential pool holds a row for the slug with a live (non-expired) token — `mibyan auth status <name>` and `list_available_providers().authenticated` both read the pool; an expired row with `refresh_token` and a `refresh_credential` hook reports `needs_refresh` | `fetch_models()` with the pooled token, else `fallback_models` (declare at least one) |

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:

| Plugin | Why look |
| - | - |
| `plugins/model-providers/openrouter/` | Aggregator with provider preferences, public model catalog |
| `plugins/model-providers/gemini/` | `thinking_config` translation (native + OpenAI-compat nested forms) |
| `plugins/model-providers/kimi-coding/` | `OMIT_TEMPERATURE`, `extra_body.thinking`, top-level `reasoning_effort` |
| `plugins/model-providers/qwen-oauth/` | Message normalization, `cache_control` injection, VL high-res |
| `plugins/model-providers/nous/` | Attribution tags, "omit reasoning when disabled" |
| `plugins/model-providers/custom/` | Ollama `num_ctx` + `think: false` quirks |
| `plugins/model-providers/bedrock/` | `api_mode="bedrock_converse"`, `fetch_models` returns None (no REST endpoint) |

## 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`:

```python theme={null}
from providers import register_provider
from providers.base import ProviderProfile

register_provider(ProviderProfile(
    name="gmi",
    aliases=("gmi-cloud", "gmicloud"),
    env_vars=("GMI_API_KEY",),
    base_url="https://gmi-staging.internal.example.com/v1",
    auth_type="api_key",
    default_aux_model="google/gemini-3.1-flash-lite-preview",
))
```

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:

| Profile field | Registry row field | When |
| - | - | - |
| `base_url` | `inference_base_url` | profile sets a non-empty `base_url` |
| `env_vars` (non-URL entries) | `api_key_env_vars` | api-key row and profile sets `env_vars` |
| `env_vars` (final `*_BASE_URL` / `*_URL` entry) | `base_url_env_var` | profile declares one; otherwise the built-in env var (e.g. `GMI_BASE_URL`) stays |

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:

```python theme={null}
from agent.transports import register_transport
from agent.transports.chat_completions import ChatCompletionsTransport

class MyDialectTransport(ChatCompletionsTransport):
    api_mode = "mydialect"
    # override convert_messages / build_kwargs / normalize_response as needed

register_transport("mydialect", MyDialectTransport)
register_provider(ProviderProfile(name="myprovider", api_mode="mydialect", ...))
```

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

| `auth_type` | Meaning | Who uses it |
| - | - | - |
| `api_key` | Single env var carries a static API key | Most providers |
| `oauth_device_code` | Device-code OAuth flow | Nous Portal; out-of-tree plugins via `auth_handler` |
| `oauth_external` | User signs in elsewhere, tokens land in `auth.json` | Anthropic OAuth, MiniMax OAuth, Qwen Portal, Nous Portal |
| `copilot` | GitHub Copilot token refresh cycle | `copilot` plugin only |
| `aws_sdk` | AWS SDK credential chain (IAM role, profile, env) | `bedrock` plugin only |
| `external_process` | Auth handled by a subprocess the agent spawns (see [External-process providers](#external-process-acp-providers)) | `copilot-acp` plugin, out-of-tree ACP plugins |

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.

```python theme={null}
import uuid
from providers import register_provider
from providers.base import ProviderProfile

def example_auth(action: str, args) -> bool:
    """action: "add" | "status" | "logout" | "refresh"; args: parsed CLI namespace."""
    if action == "add":
        from agent.credential_pool import AUTH_TYPE_OAUTH, PooledCredential, load_pool
        tokens = run_device_code_flow()                      # provider-specific
        load_pool("example-oauth").add_entry(PooledCredential(
            provider="example-oauth", id=uuid.uuid4().hex[:6], label=tokens["account"],
            auth_type=AUTH_TYPE_OAUTH, priority=0, source="manual:example_device",
            access_token=tokens["access_token"], refresh_token=tokens["refresh_token"],
            extra={"tenant": tokens["tenant"]}))             # any extra keys round-trip through auth.json
        print("Signed in to Example.")
        return True
    if action == "status":
        print("example-oauth: " + ("logged in" if load_pool("example-oauth").entries() else "logged out"))
        return True
    return False   # decline → this action stays with the built-in credential-pool handling

def example_refresh(entry):
    """Called by the credential pool with the pooled row; return the rotated values, None, or raise."""
    tokens = post_refresh(entry.refresh_token)          # the raw token-endpoint response is fine as-is
    return {"access_token": tokens["access_token"], "refresh_token": tokens["refresh_token"],
            "expires_at_ms": tokens["expires_at_ms"], "expires_in": tokens["expires_in"]}

register_provider(ProviderProfile(
    name="example-oauth", auth_type="oauth_external", base_url="https://api.example.com/v1",
    auth_handler=example_auth, refresh_credential=example_refresh))
```

| Contract | |
| - | - |
| `auth_handler(action, args)` | `args` is the parsed `mibyan auth` namespace for CLI actions; the interactive setup picker passes a minimal namespace carrying only `provider`, so read options with `getattr(args, name, None)`. Truthy = handled (Mibyan prints nothing more, exit 0); falsy = fall back to the built-in path **for that action**. An exception becomes `SystemExit("<provider> auth handler failed for `\<action>`: …")`. |
| `refresh_credential(entry)` | Receives the `PooledCredential`; returns a mapping of rotated values or `None`. Keys that are `PooledCredential` fields (`access_token`, `refresh_token`, `expires_at_ms`, …) replace the row's fields; every other key (`expires_in`, `token_type`, `scope` — the raw token-endpoint shape) lands in `entry.extra` and round-trips through `auth.json`. Returning `None`/an empty mapping means the plugin could not rotate: the row is benched exactly like a failed refresh request (never reported as refreshed, so a dead bearer is not replayed). Its presence is what makes the provider *refreshable* — `mibyan auth refresh <name>` and the main-loop 401 recovery call it through the pool with no core name list involved; the auxiliary client's 401 recovery reaches it only for pooled rows it already treats as recoverable (api-key rows and the built-in OAuth routes). |
| Refresh failures | Raise `mibyan_cli.auth_constants.AuthError(..., relogin_required=True)` (or with `code` `invalid_grant` / `invalid_token` / `refresh_token_reused`) when the grant is dead: the row goes **DEAD**, leaves rotation and Mibyan logs a WARNING naming `mibyan auth add <name>`. Any other exception (network, 429, 5xx) is transient — the row is benched for one cooldown and retried. |
| Concurrency | The hook runs under the shared `auth.json` lock. Before calling it the pool re-reads the row; if another Mibyan process (gateway + CLI, two profiles) already rotated the pair, that pair is adopted and your hook is **not** called — safe for single-use refresh tokens. After the hook returns, the rotated row is written through to `auth.json`. |
| No hooks | `api_key` profiles behave exactly as before. Any other `auth_type` without `auth_handler` fails loud on `mibyan auth add`. |

`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.

```python theme={null}
from mibyan_cli.auth_oauth_pkce_plugin import OAuthPKCEConfig, pkce_auth_handler, pkce_refresh_credential
from providers import register_provider
from providers.base import ProviderProfile

cfg = OAuthPKCEConfig(
    client_id="mibyan-public-client",                       # public client — no secret, PKCE is the proof
    authorize_url="https://auth.example.com/oauth/authorize",
    token_url="https://auth.example.com/oauth/token",
    scopes=("inference", "offline_access"),
    redirect_port=0,                                        # 0 = OS-assigned; pin it if the IdP allowlists the URI
)

register_provider(ProviderProfile(
    name="example-pkce", auth_type="oauth_external", base_url="https://api.example.com/v1",
    auth_handler=pkce_auth_handler(cfg), refresh_credential=pkce_refresh_credential(cfg)))
```

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:

```python theme={null}
def classify(error, *, status_code, error_code, message, body, model):
    # A vendor-specific 403 that is a spent plan, not a bad credential.
    if status_code == 403 and error_code == "quota_exhausted":
        return {"reason": "billing", "retryable": False, "should_rotate_credential": True, "should_fallback": True}
    return None  # decline → built-in classification

register_provider(ProviderProfile(name="example-oauth", auth_type="oauth_external",
                                  base_url="https://api.example.com/v1",
                                  refresh_credential=example_refresh, classify_api_error=classify))
```

| Contract | |
| - | - |
| `classify_api_error(error, *, status_code, error_code, message, body, model)` | Consulted by `agent.error_classifier.classify_api_error` for failures of **this provider only**, after any generic `transform_api_error_classification` hooks and before the built-in pipeline. `message` is the lower-cased error text, `body` the parsed JSON body (may be empty). Return `{"reason": <FailoverReason name>}` plus optional `retryable` / `should_compress` / `should_rotate_credential` / `should_fallback` / `error_context` to override (for terminal reasons — billing, auth, model\_not\_found … — `should_fallback: True` implies `retryable: False` unless you set it, because the fallback chain only runs for non-retryable verdicts; rate-limit reasons keep the built-in retry-then-fallback shape); `None` (or an unknown reason) leaves the built-in verdict. Exceptions are swallowed and logged at DEBUG. The verdict drives the same recovery as for built-ins — e.g. `billing` benches the credential for the billing TTL instead of the transient 403 cooldown. |
| 401 on a plugin credential | Handled by the credential pool, no core edit: the failing pooled row is refreshed through `refresh_credential` once per attempt (capped at two refreshes per row per session), the client is rebuilt with the rotated token and the request retried. A `None`/empty return or an exception benches the row — the request then rotates or falls to the generic "sign in again: `mibyan auth add <name>`" copy, never to a built-in provider's guidance. |
| Auxiliary calls | Auxiliary-client 401s take the same pool refresh (`try_refresh_current` → `refresh_credential`). |

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:

```bash theme={null}
mibyan doctor
```

— a successful `auth_type="api_key"` profile appears under the Provider Connectivity section with a `/models` probe.

For programmatic inspection:

```python theme={null}
from providers import list_providers
for p in list_providers():
    print(p.name, p.base_url, p.api_mode)
```

## Testing your plugin

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

```bash theme={null}
export mibyan_HOME=$HOME/.mibyan/cache/scratch/mibyan-plugin-test
mkdir -p $mibyan_HOME/plugins/model-providers/my-provider
cat > $mibyan_HOME/plugins/model-providers/my-provider/__init__.py <<'EOF'
from providers import register_provider
from providers.base import ProviderProfile
register_provider(ProviderProfile(
    name="my-provider",
    env_vars=("MY_API_KEY",),
    base_url="https://api.my-provider.example.com/v1",
    auth_type="api_key",
))
EOF

export MY_API_KEY=your-test-key
mibyan -z "hello" --provider my-provider -m some-model
```

## 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`:

```toml theme={null}
[project.entry-points."mibyan_agent.plugins"]
acme-inference = "acme_mibyan_plugin:register"
```

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`:

  ```yaml theme={null}
  plugins:
    enabled:
      - acme-inference
  ```

* **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](/desktop/developer-guide/plugins/overview#distribute-via-pip) for the full entry-points setup.

## Related pages

* [Provider Runtime](/desktop/developer-guide/provider-runtime) — resolution precedence + where each layer reads the profile
* [Adding Providers](/desktop/developer-guide/adding-providers) — end-to-end checklist for new inference backends (covers both the fast plugin path and the full CLI/auth integration)
* [Memory Provider Plugins](/desktop/developer-guide/memory-provider-plugin)
* [Context Engine Plugins](/desktop/developer-guide/context-engine-plugin)
* [Building a Mibyan Plugin](/desktop/developer-guide/plugins/overview) — general plugin authoring


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.