Models and API keys
Use any AI provider, let users pick the model, and let them bring their own key.
Every AI feature in Miraiclip talks to one small contract, ChatModel, so it works with any provider. @miraiclip/assistant ships adapters for OpenAI, Anthropic and Google Gemini, plus a catalog that covers OpenRouter, Groq, Mistral, DeepSeek, xAI, Together, Ollama, LM Studio and any OpenAI-compatible server.
You choose how keys are handled:
- Your keys: keys sit on your server, and users pick from the models you offer.
- Bring your own key (BYOK): each user adds their own provider key. It goes with each request to your server, which uses it for that call only and never stores it.
- Both: your models by default, and users can switch to their own key.
npm install @miraiclip/assistantPick a provider
createChatModel builds a model for any provider in the catalog:
import { createAssistant, createChatModel } from "@miraiclip/assistant";
const model = createChatModel({
provider: "anthropic", // "openai", "gemini", "openrouter", "groq", "ollama", …
apiKey: process.env.ANTHROPIC_API_KEY,
model: "claude-sonnet-5-5", // optional where the provider has a default
});
const assistant = createAssistant({ model });| Provider id | Provider | Wire format | Default model | Key from |
|---|---|---|---|---|
openai | OpenAI | Chat Completions | gpt-5.4-mini | platform.openai.com |
anthropic | Anthropic | Messages | claude-sonnet-5-5 | console.anthropic.com |
gemini | Google Gemini | generateContent | gemini-3.8-flash | aistudio.google.com |
openrouter | OpenRouter | Chat Completions | openrouter/auto | openrouter.ai |
groq | Groq | Chat Completions | pick one | console.groq.com |
mistral | Mistral | Chat Completions | mistral-medium-latest | console.mistral.ai |
deepseek | DeepSeek | Chat Completions | deepseek-chat | platform.deepseek.com |
xai | xAI | Chat Completions | pick one | console.x.ai |
together | Together AI | Chat Completions | pick one | together.ai |
ollama | Ollama (local) | Chat Completions | pick one | no key |
lmstudio | LM Studio (local) | Chat Completions | pick one | no key |
openai-compatible | Any other server | Chat Completions | pick one | optional; needs baseUrl |
The default models are suggestions from when the package was released. Model names change often, so each adapter can list what the account can use right now:
import { createChatModel } from "@miraiclip/assistant";
const model = createChatModel({ provider: "gemini", apiKey: process.env.GEMINI_API_KEY });
const models = await model.listModels?.(); // [{ id: "gemini-3.8-flash", label: "Gemini 3.8 Flash" }, …]AI_PROVIDERS is the catalog itself (id, label, wire format, base URL, key page, default and suggested models) if you want to build your own picker. aiProvider(id) returns one entry.
createChatModel option | What it does |
|---|---|
provider | A catalog id (required) |
apiKey | Required for hosted providers. Throws a ChatModelError with status 401 when missing |
model | The default model. Required for providers without one |
models | More models to offer (default: the catalog's suggestions) |
baseUrl | Another API root: a proxy, a gateway, a regional endpoint. Required for openai-compatible |
headers, params | Sent with every call. params are vendor request fields, e.g. { reasoning_effort: "low" } |
stream, maxRetries | Streaming (default on) and retries on 429/5xx (default 4) |
browser | Calling straight from the browser with the user's own key. See No backend |
id, label, fetch | Adapter identity and a custom fetch |
The adapters
You can also use each adapter directly:
| Adapter | Notes |
|---|---|
openAIChatModel | Chat Completions with function calling and streaming. maxTokensField ("max_completion_tokens" or "max_tokens") and streamUsage adapt it to servers that differ from OpenAI |
anthropicChatModel | Messages API with tool use and streaming. System messages become system. Tool results are grouped into one user turn. With extended thinking on (params: { thinking: … }), thinking blocks are kept and sent back, as the API requires. Options: version, betas, maxOutputTokens (default 8192) |
geminiChatModel | generateContent with function calling and streaming. Tools go as full JSON Schema (parametersJsonSchema), so the same tools work unchanged. Thought signatures are kept on each tool call and sent back |
Provider-specific state that has to survive a conversation rides on each tool call's meta. The agent loop keeps it, so you don't have to.
Read keys from the environment
chatModelsFromEnv builds a model for every provider whose key is set:
import { chatModelsFromEnv, routedChatModel } from "@miraiclip/assistant";
const models = chatModelsFromEnv(process.env, {
onSkip: (provider, reason) => console.warn(`${provider}: ${reason}`),
});
const model = routedChatModel(models); // one model serving all of them| Variable | What it does |
|---|---|
{PROVIDER}_API_KEY | Turns on a hosted provider: OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY (or GOOGLE_API_KEY), OPENROUTER_API_KEY, GROQ_API_KEY, MISTRAL_API_KEY, DEEPSEEK_API_KEY, XAI_API_KEY, TOGETHER_API_KEY |
{PROVIDER}_MODEL | Its default model. Required where the catalog has none |
{PROVIDER}_MODELS | More models to offer, comma-separated |
{PROVIDER}_BASE_URL | Another API root |
OLLAMA_MODEL, LMSTUDIO_MODEL | Turn on a local server |
OPENAI_BASE_URL | Any OpenAI-compatible server (with OPENAI_MODEL, optional OPENAI_API_KEY and OPENAI_LABEL) |
The list follows the catalog order, so the first entry is a sensible default.
Several providers as one model
routedChatModel(models) serves several adapters behind one ChatModel. Its model ids are provider/model, and a request's model picks both:
import { chatModelsFromEnv, createAssistant, routedChatModel } from "@miraiclip/assistant";
const model = routedChatModel(chatModelsFromEnv(process.env), { default: "anthropic" });
const assistant = createAssistant({ model, modelId: "gemini/gemini-3.8-flash" });
model.models; // [{ id: "anthropic/claude-sonnet-5-5", … }, { id: "gemini/gemini-3.8-flash", … }, …]
model.providers; // [{ id: "anthropic", label: "Anthropic" }, …]A bare model id goes to the adapter that lists it, else to the default adapter. No model means the default adapter's default model. listModels() merges every adapter's live list.
Let users bring their own key
Turn on userKeys in createChatHandler. A request can then carry the user's provider and key in headers. The handler builds a model for that one request and forgets the key:
// Server
import { chatModelsFromEnv, createChatHandler, routedChatModel } from "@miraiclip/assistant";
declare function canUseOwnKeys(request: Request): Promise<boolean>; // your plan check
const models = chatModelsFromEnv(process.env);
const handle = createChatHandler(models.length ? routedChatModel(models) : null, {
basePath: "/api/assistant",
userKeys: { providers: ["openai", "anthropic", "gemini", "openrouter"] },
// Your auth and plan checks still run first.
authorize: async (request) => {
const byok = request.headers.has("x-ai-key");
if (byok && !(await canUseOwnKeys(request))) return new Response("Your plan doesn't include your own keys", { status: 403 });
return undefined;
},
});// Browser
import { createAssistant, remoteChatModel, type UserCredentials } from "@miraiclip/assistant";
let mine: UserCredentials | null = null; // set from your settings UI
const model = await remoteChatModel("/api/assistant", { credentials: () => mine });
const assistant = createAssistant({ model });
mine = { provider: "anthropic", apiKey: "sk-ant-…" }; // applies from the next call
const theirModels = await model.listModels?.(); // listed with the user's keyPass null as the model to run with no server key at all: every user brings their own.
userKeys option | Default | What it does |
|---|---|---|
providers | every hosted provider | Which providers users may bring a key for |
required | false | Refuse requests without a user key, even when the server has a model |
allowBaseUrl | false | Let users send their own API root (x-ai-base-url) for OpenAI-compatible servers and gateways |
create | createChatModel | Build the model for a user's key: add headers, pin params, wrap with limits |
| Header | Value |
|---|---|
x-ai-provider | A catalog id |
x-ai-key | The user's key |
x-ai-base-url | Only with allowBaseUrl |
userKeyHeaders(credentials) builds these headers if you call the endpoint yourself.
Base URLs and your server
With allowBaseUrl on, your server calls whatever address a user sends. Leave it off on public servers, or put an allowlist in create.
When userKeys is on, GET {basePath}/ tells the browser what's allowed:
{
"id": "router",
"label": "AI models",
"models": [{ "id": "anthropic/claude-sonnet-5-5", "label": "Anthropic · claude-sonnet-5-5" }],
"providers": [{ "id": "anthropic", "label": "Anthropic" }],
"userKeys": {
"providers": [{ "id": "openai", "label": "OpenAI", "needsKey": true, "keyUrl": "https://platform.openai.com/api-keys", "defaultModel": "gpt-5.4-mini" }],
"allowBaseUrl": false,
"required": false
}
}needsKey: true means the server has no model of its own (or requires user keys): calls need the user's key. remoteChatModel exposes this as model.info.
| Route | Response |
|---|---|
GET {basePath}/ | Model info, as above |
GET {basePath}/models | { models }: the live list, for the server's account or, with user-key headers, the user's |
POST {basePath}/chat | NDJSON stream. A missing key is 401 with { needsKey: true }; a provider that isn't allowed is 403 |
Where the user's key lives
The key is stored wherever your app keeps user settings: the browser (as the React template does), or your own database, encrypted. Either way it reaches the provider only through your server, for one request at a time. The handler never logs or stores it.
No backend: keys in the browser
An app with no server at all can call providers straight from the browser with the user's own key. OpenAI, Anthropic, Gemini and OpenRouter accept cross-origin calls. browser: true adds what Anthropic needs for that:
import { createAssistant, createChatModel } from "@miraiclip/assistant";
declare const userKey: string; // typed in by the user, never yours
const model = createChatModel({ provider: "anthropic", apiKey: userKey, browser: true });
const assistant = createAssistant({ model });Never ship your own key
Anything in the browser can be read by whoever uses it. Only the user's own key belongs there. Your keys stay on a server behind createChatHandler.
In the React template
The Editor UI kit uses all of this:
- Put any provider key in
.env.local(OPENAI_API_KEY,ANTHROPIC_API_KEY,GEMINI_API_KEY,OPENROUTER_API_KEY, …) and restartpnpm dev.{PROVIDER}_MODELpicks the model. - The dropdown under the Assistant's input picks the model: the server's models grouped by provider (Load all models… adds each provider's live list), plus the providers you've added a key for.
- The gear beside it adds your own API key: pick a provider, paste a key, pick a model, then Save and test. When the server allows custom addresses, you can also point it at your own OpenAI-compatible server.
- Every AI feature (Assistant, Ask AI in the code editor, Create with AI, Translate, Voiceover rewrite) uses the same choice.
- Bring-your-own-key is on by default on the dev server.
AI_USER_KEYS=0turns it off,AI_USER_KEYS_REQUIRED=1refuses the server's keys, andAI_USER_KEYS_BASE_URL=1allows custom addresses.
The code is in vite-plugins/assistant.ts (server) and src/features/assistant/aiSettings.ts (settings and the live model).
Other AI features
Speech-to-text (@miraiclip/transcript: OpenAI, Deepgram) and voice and audio generation (@miraiclip/audio-sources: ElevenLabs, OpenAI) have their own vendor-neutral contracts and adapters. See Screen recordings for developers and Audio.
Tool definitions for your own loop come in all three vendor shapes: toToolDefinitions(catalog, { style: "anthropic" | "openai" | "gemini" }). See Tools from the command catalog.