Miraiclip SDK
AI & agents

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/assistant

Pick 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 idProviderWire formatDefault modelKey from
openaiOpenAIChat Completionsgpt-5.4-miniplatform.openai.com
anthropicAnthropicMessagesclaude-sonnet-5-5console.anthropic.com
geminiGoogle GeminigenerateContentgemini-3.8-flashaistudio.google.com
openrouterOpenRouterChat Completionsopenrouter/autoopenrouter.ai
groqGroqChat Completionspick oneconsole.groq.com
mistralMistralChat Completionsmistral-medium-latestconsole.mistral.ai
deepseekDeepSeekChat Completionsdeepseek-chatplatform.deepseek.com
xaixAIChat Completionspick oneconsole.x.ai
togetherTogether AIChat Completionspick onetogether.ai
ollamaOllama (local)Chat Completionspick oneno key
lmstudioLM Studio (local)Chat Completionspick oneno key
openai-compatibleAny other serverChat Completionspick oneoptional; 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 optionWhat it does
providerA catalog id (required)
apiKeyRequired for hosted providers. Throws a ChatModelError with status 401 when missing
modelThe default model. Required for providers without one
modelsMore models to offer (default: the catalog's suggestions)
baseUrlAnother API root: a proxy, a gateway, a regional endpoint. Required for openai-compatible
headers, paramsSent with every call. params are vendor request fields, e.g. { reasoning_effort: "low" }
stream, maxRetriesStreaming (default on) and retries on 429/5xx (default 4)
browserCalling straight from the browser with the user's own key. See No backend
id, label, fetchAdapter identity and a custom fetch

The adapters

You can also use each adapter directly:

AdapterNotes
openAIChatModelChat Completions with function calling and streaming. maxTokensField ("max_completion_tokens" or "max_tokens") and streamUsage adapt it to servers that differ from OpenAI
anthropicChatModelMessages 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)
geminiChatModelgenerateContent 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
VariableWhat it does
{PROVIDER}_API_KEYTurns 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}_MODELIts default model. Required where the catalog has none
{PROVIDER}_MODELSMore models to offer, comma-separated
{PROVIDER}_BASE_URLAnother API root
OLLAMA_MODEL, LMSTUDIO_MODELTurn on a local server
OPENAI_BASE_URLAny 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 key

Pass null as the model to run with no server key at all: every user brings their own.

userKeys optionDefaultWhat it does
providersevery hosted providerWhich providers users may bring a key for
requiredfalseRefuse requests without a user key, even when the server has a model
allowBaseUrlfalseLet users send their own API root (x-ai-base-url) for OpenAI-compatible servers and gateways
createcreateChatModelBuild the model for a user's key: add headers, pin params, wrap with limits
HeaderValue
x-ai-providerA catalog id
x-ai-keyThe user's key
x-ai-base-urlOnly 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.

RouteResponse
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}/chatNDJSON 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 restart pnpm dev. {PROVIDER}_MODEL picks 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=0 turns it off, AI_USER_KEYS_REQUIRED=1 refuses the server's keys, and AI_USER_KEYS_BASE_URL=1 allows 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.

On this page