Skip to Content
AIModels

Models

Every model in NativeExpress, including OpenAI’s, Anthropic’s and Google’s, is reached through one gateway: OpenRouter . One API key covers all of them, so adding a model is a config change rather than a new integration. If you’d rather use a different provider, see Using a different provider below.

@openrouter/ai-sdk-provider is imported inside the edge functions as a Deno npm: specifier, so it will not appear in your package.json; it’s a server dependency, not an app one.

config.js and the server read the same file

The model list is defined in one file: supabase/functions/_utils/ai.config.json. config.js requires that exact file for the app’s picker, and the edge functions import it too, so there is one list, not two:

config.js
ai: require('./supabase/functions/_utils/ai.config.json'),
supabase/functions/_utils/ai.config.json
"models": [ { "id": "openai/gpt-5.6-sol", "provider": "openai", "label": "GPT-5.6 Sol", "hostedTier": true, "vision": true }, { "id": "openai/gpt-5.6-luna", "provider": "openai", "label": "GPT-5.6 Luna", "hostedTier": true, "vision": true }, { "id": "anthropic/claude-sonnet-5", "provider": "anthropic", "label": "Claude Sonnet 5", "hostedTier": true, "vision": true }, { "id": "anthropic/claude-opus-4.8", "provider": "anthropic", "label": "Claude Opus 4.8", "hostedTier": false, "vision": true }, { "id": "google/gemini-3-flash-preview", "provider": "google", "label": "Gemini 3 Flash", "hostedTier": true, "vision": true }, { "id": "google/gemini-3.1-flash-image", "provider": "google", "label": "Gemini 3.1 Flash (Image)", "hostedTier": false, "vision": true, "imageOutput": true } ]
FieldMeaning
idThe OpenRouter slug, sent to the provider as-is.
providerDisplay grouping in the picker only, not the routing.
labelWhat the user sees.
hostedTierfalse means Pro-only. The picker keeps the row tappable and opens the paywall instead of selecting it, so the user can see why it’s locked.
visionThe model accepts image input, so the attach button is available.
imageOutputThe model can return images. Always Pro-gated.

The client only ever sends a model id. The chat function checks it against MODELS, built from this same file, so a request for a slug that was never in the picker is rejected before it reaches OpenRouter:

supabase/functions/chat/index.ts
const caps = MODELS.get(model); if (!caps) return new Response(JSON.stringify({ code: 'invalid_model' }), { status: 400 });

Editing the file is one change, but the server needs a redeploy to see it. Until you run supabase functions deploy chat, the app’s picker can offer a model the running function still rejects with invalid_model.

Adding a model

Add a model to this project. Add it to the models array in supabase/functions/_utils/ai.config.json, then redeploy chat. Tell me which capability flags the model needs.

Find the slug

Browse openrouter.ai/models  and copy the exact slug. Check the model’s input and output modalities on that page, which is where vision and imageOutput come from.

Add it to ai.config.json

supabase/functions/_utils/ai.config.json
{ "id": "vendor/model-slug", "provider": "vendor", "label": "Model Name", "hostedTier": true, "vision": true }

Set hostedTier: false if you want it to be Pro-only. Any model with imageOutput: true should also be hostedTier: false, because image output is expensive and is Pro-gated server-side regardless.

This one edit updates both the app’s picker and the server’s allow-list, since both read this file.

Redeploy

supabase functions deploy chat

Verify

The model appears in the picker and a message sent with it streams a reply. invalid_model in the response means the redeploy was missed.

Gating rules

The chat function applies these in order, before anything is sent to a provider:

  1. Unknown slug400 invalid_model.
  2. Image attached to a non-vision model400 model_not_vision.
  3. An imageOutput model requested by a non-Pro user403 pro_required.
  4. Free quota exhausted and user is not Pro402 quota_exceeded.

“Pro” means the user holds an unexpired RevenueCat entitlement: pro by default, or whatever you set PRO_ENTITLEMENT_ID and config.jspurchases.proEntitlementId to. The function checks this server-side with REVENUECAT_SECRET_KEY. If that secret is unset, the check fails closed, and every user is treated as not-Pro. See Quota & Paywall.

Using a different provider

The functions build their provider in one place, supabase/functions/_utils/ai.ts:

supabase/functions/_utils/ai.ts
export function openRouterFromKey(apiKey: string) { return createOpenRouter({ apiKey }); }
Switch this project from OpenRouter to a direct provider. Swap the AI SDK provider in supabase/functions/_utils/ai.ts, change resolveApiKey() to read the new secret, and update the model slugs in ai.config.json.

To go direct to OpenAI, Anthropic or Google instead, swap @openrouter/ai-sdk-provider for that vendor’s AI SDK provider here, change resolveApiKey() to read your new secret, and update the model slugs. The AI SDK call sites (streamText, generateText, generateObject) do not change.

Last updated on