# Custom model providers

Register a provider, its auth flow and its model list from a plugin — including an internal gateway that will never be in the public catalogue.

## What a provider plugin does

nikcli resolves models from a catalogue, but a plugin can add to it. Two hooks do the work, and they are independent — you can use either alone.

| Hook | Answers |
| --- | --- |
| `auth` | How does a user sign in to this provider, and what credentials does a request carry? |
| `provider` | Which models does this provider offer _for this user_? |

The pattern covers three real situations: a provider that needs a key nikcli does not know how to ask for, a provider behind OAuth, and a gateway whose model list depends on the account.

## An API-key provider

The smallest useful provider plugin declares one auth method. nikcli then offers it in the login flow and stores whatever the user enters.

.nikcli/plugins/internal-gateway.ts

ts 

1

2

3

4

5

6

7

8

```
import type { Plugin } from "@nikcli-ai/plugin"

export const InternalGateway: Plugin = async () => ({
  auth: {
    provider: "internal-gateway",
    methods: [{ type: "api", label: "API key" }],
  },
})
```

## Prompting for extra fields

Some providers need more than a key — an account id, a region, a gateway name. Declare `prompts` and the values are collected alongside it. Building the array conditionally lets you skip anything already in the environment.

ts 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

```
export const Gateway: Plugin = async () => {
  const prompts = [
    ...(!process.env.GATEWAY_ACCOUNT_ID
      ? [{
          type: "text" as const,
          key: "accountId",
          message: "Enter your gateway account ID",
          placeholder: "e.g. 1234567890abcdef",
        }]
      : []),
    {
      type: "select" as const,
      key: "region",
      message: "Which region?",
      options: [
        { label: "EU", value: "eu", hint: "Frankfurt" },
        { label: "US", value: "us" },
      ],
    },
  ]

  return {
    auth: {
      provider: "internal-gateway",
      methods: [{ type: "api", label: "Gateway API token", prompts }],
    },
  }
}
```

A `text` prompt can carry a `validate` function that returns an error string, and either kind can carry a `condition` that hides it based on earlier answers.

## The loader

`loader` turns a stored credential into the options the model client uses. Return `apiKey`, a `baseURL`, headers — or a whole `fetch` when the provider needs the request rewritten.

ts 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

```
auth: {
  provider: "internal-gateway",
  async loader(getAuth) {
    const auth = await getAuth()
    if (auth.type !== "api") return {}

    return {
      apiKey: auth.key,
      baseURL: "https://gateway.internal/v1",
      headers: { "x-team": "platform" },
    }
  },
  methods: [{ type: "api", label: "API key" }],
}
```

loader runs per request, not once

`getAuth()` is a function rather than a value precisely so a long-lived session sees a refreshed credential. Read it inside the call you are serving, not at plugin load.

## An OAuth provider

An `oauth` method returns a URL, instructions for the user, and a callback. There are two shapes: `auto`, where nikcli polls your callback until the provider completes the flow, and `code`, where the user pastes a code back.

ts 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

```
auth: {
  provider: "internal-gateway",
  methods: [
    {
      type: "oauth",
      label: "Sign in with SSO",
      async authorize() {
        const { verifier, challenge } = await pkce()
        const url = new URL("https://sso.internal/authorize")
        url.searchParams.set("client_id", CLIENT_ID)
        url.searchParams.set("code_challenge", challenge)
        url.searchParams.set("code_challenge_method", "S256")

        return {
          url: url.toString(),
          instructions: "Approve the request, then paste the code shown.",
          method: "code",
          async callback(code: string) {
            const tokens = await exchange(code, verifier)
            if (!tokens) return { type: "failed" }
            return {
              type: "success",
              refresh: tokens.refresh_token,
              access: tokens.access_token,
              expires: Date.now() + tokens.expires_in * 1000,
            }
          },
        }
      },
    },
  ],
}
```

A successful callback can return either an OAuth triple (`refresh`, `access`, `expires`) or a plain `{ key }` when the flow ends in a long-lived API key.

## Refreshing tokens

Stored credentials are readonly from the plugin's point of view. To rotate one, exchange the refresh token and write the new pair back through the client — then use the fresh access token for the request you are currently serving.

ts 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

```
async loader(getAuth) {
  return {
    apiKey: "unused-but-required",
    async fetch(input: RequestInfo | URL, init?: RequestInit) {
      const auth = await getAuth()
      if (auth.type !== "oauth") return fetch(input, init)

      let access = auth.access
      if (!access || auth.expires < Date.now()) {
        const tokens = await refresh(auth.refresh)
        access = tokens.access_token

        await client.auth.set({
          providerID: "internal-gateway",
          payload: {
            type: "oauth",
            refresh: tokens.refresh_token ?? auth.refresh,
            access: tokens.access_token,
            expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
          },
        })
      }

      const headers = new Headers(init?.headers)
      headers.set("authorization", `Bearer ${access}`)
      return fetch(input, { ...init, headers })
    },
  }
}
```

Strip the Authorization the SDK already set

The model SDK sets its own `Authorization` from `apiKey` before your `fetch` runs. If you add a second one without deleting the first, providers differ in which they honour — which is why the built-in xAI plugin explicitly removes it in all three `HeadersInit` shapes before setting its own.

## Listing models dynamically

The `provider` hook returns the model map. It receives the statically configured provider plus the current auth, so the list can depend on the signed-in account — and must degrade to the static list when the lookup fails.

ts 

1

2

3

4

5

6

7

8

9

10

11

12

```
provider: {
  id: "internal-gateway",
  async models(provider, ctx) {
    if (ctx.auth?.type !== "oauth") return provider.models

    return await fetchEntitledModels(ctx.auth.access).catch((error) => {
      // a failed lookup must not remove the user's models
      console.error("model list failed", error)
      return provider.models
    })
  },
}
```

Never return an empty map on failure

An empty list does not read as "lookup failed" anywhere downstream — it reads as "this provider has no models", and the provider silently disappears from the picker. Fall back to `provider.models`.

## Per-provider request fixups

Gateways that multiplex several upstreams often need a field removed or renamed for one family of models. `chat.params` is where that belongs — guarded on the provider id, so it cannot affect anyone else's traffic.

ts 

1

2

3

4

5

6

7

8

9

```
async "chat.params"(input, output) {
  if (input.model.providerID !== "internal-gateway") return
  if (!input.model.api.id.toLowerCase().startsWith("openai/")) return
  if (!input.model.capabilities.reasoning) return

  // OpenAI reasoning models reject max_tokens and want max_completion_tokens,
  // which the openai-compatible SDK cannot rename — so drop the cap.
  output.options.maxOutputTokens = undefined
}
```

That example is not hypothetical: it is what the built-in Cloudflare AI Gateway plugin does, for exactly that reason.

## Real examples in the tree

The built-in provider plugins are ordinary plugins using these same hooks. Read them rather than guessing — they are the reference implementations.

| File | Shows |
| --- | --- |
| `src/plugin/cloudflare.ts` | The compact case: two API-key providers with conditional prompts, plus a `chat.params` fixup. ~76 lines. |
| `src/plugin/github-copilot/copilot.ts` | A `provider.models` hook that fetches entitled models and falls back to the static list. |
| `src/plugin/xai.ts` | Full OAuth: PKCE, endpoint discovery, a `fetch` that strips and replaces the header, and token refresh written back through the client. |
| `src/plugin/cursor.ts` | The largest one, including provider-specific request shaping. |

## Failure modes

| Symptom | Cause |
| --- | --- |
| Provider missing from the picker | `models()` returned an empty map, usually from a caught error. |
| 401 despite a valid token | Two `Authorization` headers — the SDK's and yours. |
| Works, then fails after an hour | Reading `getAuth()` once at load instead of per request, so the expired access token is reused. |
| Auth method never offered | `auth.provider` does not match the provider id used by the model. |
| Fixup affects other providers | A `chat.params` hook without a `providerID` guard — every hook sees every call. |

## Related

-   [Writing a plugin The plugin basics this guide builds on.](/docs/guides/plugins)
-   [Providers The built-in provider catalogue and configuration.](/docs/providers)
-   [Plugin recipes Eight smaller plugins to copy from.](/docs/guides/plugin-recipes)
-   [Providers & Auth API The endpoints behind provider auth.](/docs/api/provider)

---

Source: https://nikcli-ai.dev/docs/guides/providers
