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.
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.
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.
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" }],
} 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.
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.
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 })
},
}
}
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.
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
})
},
}
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.
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. |