Skip to content
nikcli
Docs
Documentation

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.

HookAnswers
authHow does a user sign in to this provider, and what credentials does a request carry?
providerWhich 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
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
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
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
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
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
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
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.

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

Failure modes

SymptomCause
Provider missing from the pickermodels() returned an empty map, usually from a caught error.
401 despite a valid tokenTwo Authorization headers — the SDK's and yours.
Works, then fails after an hourReading getAuth() once at load instead of per request, so the expired access token is reused.
Auth method never offeredauth.provider does not match the provider id used by the model.
Fixup affects other providersA chat.params hook without a providerID guard — every hook sees every call.