Mastra with a Custom OpenAI-Compatible Endpoint

In short: baseURL belongs in the object passed to createOpenAI. Given to the model call instead it is treated as per-request settings and dropped, so requests reach OpenAI's own host with your gateway key and come back 401 — which reads as a key problem. With a custom gateway the model id gains a gatewayId/providerId/modelId prefix, and the model list is declared in fetchProviders rather than discovered from the endpoint.

Mastra is a TypeScript agent framework. It builds on the AI SDK, which means the way you point it at a gateway is inherited from that layer — and there are two places a base URL can go, only one of which is read.

baseURL has to be passed to createOpenAI(), not to the model call. Both spellings look reasonable and both typecheck; passing it as the second argument to the invocation makes it a per-request model setting that gets dropped, so traffic silently goes to OpenAI's own host carrying your gateway key. And when you register a custom gateway, the model id carries a gatewayId/providerId/modelId prefix — Mastra's router reads that prefix to decide which gateway handles the call.

You need a key before the code below runs. Create an account, generate a key, and copy the base URL (https://aicomp.ai/v1). Create one free →
Check current rates → Free to sign up · $1 minimum top-up · No prepayment

This page covers both paths, why the misplaced baseURL produces an auth error rather than a connection error, and what a month of multi-agent workflows costs.

The simple path

For a single OpenAI-compatible gateway, create a provider once and hand it to your agent:

import { Agent } from '@mastra/core/agent'
import { createOpenAI } from '@ai-sdk/openai'

const gateway = createOpenAI({
  apiKey: process.env.GATEWAY_API_KEY,
  baseURL: 'https://gw.example.com/v1',
})

const agent = new Agent({
  name: 'researcher',
  instructions: 'You are a concise research assistant.',
  model: gateway('gpt-5.6-terra'),
})

The baseURL goes in the object passed to createOpenAI. That is the only place it is read.

The failure that looks like a bad key

The tempting wrong version is to put the URL where the model id goes:

// WRONG — the second argument is model settings, not connection config
const agent = new Agent({
  name: 'researcher',
  model: gateway('gpt-5.6-terra', { baseURL: 'https://gw.example.com/v1' }),
})

This does not error at construction. The object is accepted as per-request model options and the connection setting is never applied, so the request leaves for the real OpenAI host with your gateway key attached. What comes back is a 401 — and a 401 reads as "my key is wrong", so the usual next move is to rotate the key and check the key and re-paste the key, none of which touches the actual cause. The tell is that the key works fine in a curl against the gateway and fails only from Mastra.

If you are used to the AI SDK directly, this is the same surface — see Vercel AI SDK with a custom endpoint for the underlying behaviour.

Custom gateways and the model id prefix

For anything Mastra does not know about — a private deployment, a proxy, an internal router — you register a gateway and the model id gains a prefix:

import { Mastra } from '@mastra/core'
import { MastraModelGateway, type ProviderConfig } from '@mastra/core/llm'
import { createOpenAICompatible } from '@ai-sdk/openai-compatible-v5'

class MyGateway extends MastraModelGateway {
  readonly id = 'private'
  readonly name = 'My Private Gateway'

  async fetchProviders(): Promise<Record<string, ProviderConfig>> {
    return {
      'my-provider': {
        name: 'My Provider',
        models: ['gpt-5.6-sol', 'claude-opus-5-5'],
        apiKeyEnvVar: 'MY_API_KEY',
        gateway: this.id,
        url: 'https://gw.example.com/v1',
      },
    }
  }

  buildUrl(modelId: string): string {
    return 'https://gw.example.com/v1'
  }

  async getApiKey(modelId: string): Promise<string> {
    const key = process.env.MY_API_KEY
    if (!key) throw new Error('Missing MY_API_KEY environment variable')
    return key
  }

  async resolveLanguageModel({ modelId, providerId, apiKey }: {
    modelId: string; providerId: string; apiKey: string
  }) {
    return createOpenAICompatible({
      name: providerId,
      apiKey,
      baseURL: this.buildUrl(modelId),
      supportsStructuredOutputs: true,
    }).chatModel(modelId)
  }
}

const mastra = new Mastra({ gateways: { private: new MyGateway() } })

const agent = new Agent({
  name: 'researcher',
  model: 'private/my-provider/gpt-5.6-sol',
})

Three things worth knowing about this path.

The model list is declared, not discovered. fetchProviders() returns the models as a literal array. A model your gateway serves but did not list is not reachable through this gateway even though the endpoint would answer for it — so adding a model upstream is a two-part change.

apiKeyEnvVar names a variable, it does not hold a key. The value is the name of the environment variable to read at resolve time. Putting the key itself there produces an empty or wrong lookup and a clean-looking config that 401s.

buildUrl() is called with the model id. If you serve several providers from one gateway, this is where per-provider URLs are decided — return a constant and every provider shares one host.

In development, setting MASTRA_DEV=true makes Mastra generate TypeScript types for your gateways so the prefixed model ids autocomplete. Without it the ids are strings the compiler cannot check, which is how typos in the prefix survive to runtime.

Across 219 catalogue models that publish both rates, the median output rate is 4.0× the input rate, and 68% of them (150 models) charge at least 4× more for output than input. The other 624 raw catalogue entries are dropped by cleaning — test entries, dated snapshots and non-chat variants — not for a missing rate. This is why an agent workload is decided on the output column, not the input one everyone quotes.

Why multi-agent changes the shape of the bill

Mastra's value is workflows: several agents, each with tools, calling models in sequence. Every step is a separate request, and each one re-sends the context it needs. Ten steps through a workflow is ten input bills, not one — and tool results come back as input on the following step.

A day of work on this page's workload is 280k input and 55k output tokens — 5.6M input and 1.1M output across 20 working days. The output share is higher here than in a chat client because agents write structured results between steps, but input still dominates. See output vs input pricing for working out where your own workflow sits.

Per 1M input / output tokens and a 20-day month
ModelGateway rate
in / out per 1M tokens
One day20 days
gpt-5.6-sol$2.5 / $15.00$1.52$30.50
claude-opus-5-5$2.00 / $10.00$1.11$22.20
qwen3.8-max$1.00 / $3.00$0.45$8.90
grok-4.1$1.00 / $5.00$0.56$11.10
MiniMax-M3$0.15 / $0.60$0.08$1.50

One day = 280k input + 55k output on this page's workload. 20 days = 5.6M input and 1.1M output. Rates checked 2026-10-11 — gateway rates move with upstream promotions, so verify the current number in your dashboard before committing to a budget.

Verifying the route

  1. Put baseURL inside createOpenAI(). The model call's second argument is per-request settings and is not read for connection config.
  2. Read a 401 as a possible routing bug, not a key problem. If the key works in curl and fails only from Mastra, the request never reached your gateway.
  3. Match the prefix exactly — gatewayId/providerId/modelId. The router falls back to built-in gateways when no prefix matches, which sends the call somewhere else entirely.
  4. Confirm the model is in fetchProviders() for custom gateways. The endpoint serving it is not sufficient.
  5. Set MASTRA_DEV=true once so the ids typecheck instead of failing at runtime.

FAQ

FAQ

Why do I get a 401 when my key works in curl?

baseURL was passed to the model call instead of createOpenAI(). The request went to OpenAI's own host with your gateway key.

Can I pass baseURL per request?

No. Connection configuration belongs on the provider created by createOpenAI(); the second argument to the model call is model settings.

My custom gateway is registered but the model is not found

The model has to be listed in the array fetchProviders() returns. Discovery does not happen against the endpoint for custom gateways.

What does apiKeyEnvVar hold?

The name of an environment variable. Mastra reads that variable when resolving the key — the value is not the key itself.

Is this the same as the OpenAI Agents SDK?

Both are agent frameworks, but Mastra adds the gateway registry and the prefixed model ids. The OpenAI Agents SDK takes a client object directly, and Pydantic AI is the closest Python equivalent to this shape.

Why is my workflow bill higher than one long chat?

Each workflow step is its own request and re-sends its context. Ten steps is ten input charges.

Related

Get API access