> ## Documentation Index
> Fetch the complete documentation index at: https://docs.e2e.army/llms.txt
> Use this file to discover all available pages before exploring further.

# Choosing a model

> Which model an agent uses, which package constructs it, and where its key comes from.

The built-in agent uses an AI SDK model instance your config constructs and
can name a separate model for visual judgments. [Custom executors](/executors)
own their model calls and may run without a model. The runner has no default
model, no gateway of its own, and reads no model or key variable. The line in
`e2e.config.ts` that builds the instance decides who serves it and how it
authenticates.

## Pick a gateway or a provider

A gateway reaches every vendor's models with one key. A provider package
calls one vendor directly. `e2e init` writes the one you chose.

<Tabs>
  <Tab title="Vercel AI Gateway">
    ```ts title="e2e.config.ts" theme={"theme":"catppuccin-mocha"}
    import type { E2EConfig } from '@e2edev/e2e';
    import { playwright } from '@e2edev/playwright';
    import { createAgent } from '@e2edev/e2e/agent';
    import { gateway } from 'ai';

    export default {
      targets: [{ engine: playwright({ url: 'http://localhost:3000' }) }],
      agents: { default: createAgent({ model: gateway('anthropic/claude-sonnet-4.5') }) },
    } satisfies E2EConfig;
    ```

    `gateway()` ships with `ai`, so no extra package. It reads `AI_GATEWAY_API_KEY`.
  </Tab>

  <Tab title="OpenRouter">
    ```ts title="e2e.config.ts" theme={"theme":"catppuccin-mocha"}
    import type { E2EConfig } from '@e2edev/e2e';
    import { playwright } from '@e2edev/playwright';
    import { createAgent } from '@e2edev/e2e/agent';
    import { openrouter } from '@openrouter/ai-sdk-provider';

    export default {
      targets: [{ engine: playwright({ url: 'http://localhost:3000' }) }],
      agents: { default: createAgent({ model: openrouter('anthropic/claude-sonnet-4.5') }) },
    } satisfies E2EConfig;
    ```

    `npm i -D @openrouter/ai-sdk-provider`. It reads `OPENROUTER_API_KEY`.
  </Tab>

  <Tab title="OpenAI-compatible endpoint">
    ```ts title="e2e.config.ts" theme={"theme":"catppuccin-mocha"}
    import type { E2EConfig } from '@e2edev/e2e';
    import { playwright } from '@e2edev/playwright';
    import { createAgent } from '@e2edev/e2e/agent';
    import { createOpenAICompatible } from '@ai-sdk/openai-compatible';

    const local = createOpenAICompatible({
      name: 'ollama',
      baseURL: 'http://127.0.0.1:11434/v1',
      // apiKey: process.env.LLM_API_KEY, // when the endpoint needs one
    });

    export default {
      targets: [{ engine: playwright({ url: 'http://localhost:3000' }) }],
      agents: { default: createAgent({ model: local.chatModel('llama3.2') }) },
    } satisfies E2EConfig;
    ```

    `npm i -D @ai-sdk/openai-compatible`. Ollama, vLLM, LiteLLM, and most vendors'
    own APIs speak this protocol. You pass the URL and the key; there is no default.
  </Tab>

  <Tab title="A provider directly">
    ```ts title="e2e.config.ts" theme={"theme":"catppuccin-mocha"}
    import type { E2EConfig } from '@e2edev/e2e';
    import { playwright } from '@e2edev/playwright';
    import { openai } from '@ai-sdk/openai';
    import { createAgent } from '@e2edev/e2e/agent';

    export default {
      targets: [{ engine: playwright({ url: 'http://localhost:3000' }) }],
      agents: { default: createAgent({ model: openai('gpt-5.6-luna') }) },
    } satisfies E2EConfig;
    ```

    Any AI SDK provider package works: `@ai-sdk/anthropic`, `@ai-sdk/google`,
    `@ai-sdk/amazon-bedrock`, a community provider. Each reads its own variable
    (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, ...).
  </Tab>
</Tabs>

| You want                                       | Constructor                                                    | Package                       | Key variable                  |
| ---------------------------------------------- | -------------------------------------------------------------- | ----------------------------- | ----------------------------- |
| One key for every vendor, billed by Vercel     | `gateway('vendor/model')`                                      | `ai`                          | `AI_GATEWAY_API_KEY`          |
| One key for every vendor, billed by OpenRouter | `openrouter('vendor/model')`                                   | `@openrouter/ai-sdk-provider` | `OPENROUTER_API_KEY`          |
| A local or self-hosted server                  | `createOpenAICompatible({ name, baseURL }).chatModel('model')` | `@ai-sdk/openai-compatible`   | whatever you pass as `apiKey` |
| One vendor, your own account                   | `openai('model')`, `anthropic('model')`, ...                   | `@ai-sdk/<vendor>`            | the vendor's own              |

The model id is whatever the package accepts. Gateways take `vendor/model`;
a direct provider takes the vendor's bare id; a local server takes the name
it serves the model under.

## Where the key comes from

The provider package reads its key, not the runner. Every AI SDK provider
accepts `apiKey` at construction and otherwise reads its own environment
variable on the first request.

```ts theme={"theme":"catppuccin-mocha"}
import { createOpenRouter, openrouter } from '@openrouter/ai-sdk-provider';

// Reads OPENROUTER_API_KEY when the first model call is made.
openrouter('openai/gpt-5.6-luna');

// Use MY_TEAM_MODEL_KEY when set; otherwise use OPENROUTER_API_KEY.
const apiKey = process.env.MY_TEAM_MODEL_KEY;
createOpenRouter({
  ...(apiKey === undefined ? {} : { apiKey }),
}).chat('openai/gpt-5.6-luna');
```

Three consequences:

* **The key is read late.** Constructing `gateway('...')` needs no variable.
  Tests without agent steps, `e2e list`, and config loading all work without a
  key. Only a step that calls a model needs the provider's key.
* **There is no `E2E_*` key variable.** The runner never sees the key, so it
  has nothing to name. Set the variable the package you imported documents.
* **The runner never logs it.** The key lives in your config module and the
  provider's request headers. It does not enter the report, the trace, or
  automatically inherited environment of the app process. Explicitly putting
  a key in `command.env` forwards it to that process.

## Several models in one config

Each model slot is its own instance, so they can come from different
packages:

```ts title="e2e.config.ts" theme={"theme":"catppuccin-mocha"}
import type { E2EConfig } from '@e2edev/e2e';
import { playwright } from '@e2edev/playwright';
import { gateway } from 'ai';

export default {
  targets: [{ engine: playwright({ url: 'http://localhost:3000' }) }],
  agents: {
    default: {
      context: 'You are a thorough QA agent.',
      model: gateway('openai/gpt-5.6-luna'),
    },
    careful: { model: gateway('anthropic/claude-opus-5'), context: 'Read every label twice.' },
  },
} satisfies E2EConfig;
```

With no configured executor, `model` drives `agent.act` and the judgments,
pixels included: the act loop shows it screenshots, so pick one that accepts
images and grounds a point in them well. The provider needs its key when the
model is first used.
An `agents.<name>.model` that names a different model than the one passed to
`createAgent({ model })` is `INVALID_CONFIG`.

A configured executor, including `createAgent(...)`, handles `agent.act`
and `agent.assert` through `runStep`. That assertion path rejects `vision`
and `screenshot` options. An executor may implement both methods without a
model. `agent.waitFor` and `agent.extract` still use the built-in judgment
tier and its model routing; see [Custom executors](/executors#replace-the-executor).

To switch models per run without editing the config, read a variable of your
own:

```ts title="Agent options fragment" theme={"theme":"catppuccin-mocha"}
model: gateway(process.env.MODEL ?? 'openai/gpt-5.6-luna'),
```

## Models that refuse forced tool calls

The built-in `agent.act` loop asks the model for a tool call on every turn.
Some models reject that request shape. Anthropic's Claude Fable 5.1 answers HTTP 400 to any
forced tool choice. The runner recognizes the refusal, retries with the
choice left to the model and a tools-only rule in its instructions, and keeps
that mode for the rest of the process. The refused request does not count as
a model call.

## What fails, and where

| Symptom                                                              | Code                    | Cause                                                                                              |
| -------------------------------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------- |
| The run stops before any test, exit 2, naming `agents.default.model` | `INVALID_CONFIG`        | A string in a model slot. Write the constructor: `gateway('openai/gpt-5.6-luna')`.                 |
| The run stops when the first test asks for `agent`, exit 2           | `MODEL_UNAVAILABLE`     | No model anywhere: neither `createAgent({ model })` nor `agents.<name>.model` holds an instance.   |
| The first agent step fails with an authentication message            | `MODEL_PROVIDER_FAILED` | The provider rejected or could not find its key. The message names the variable the package reads. |
| The first agent step fails with `ai` not found                       | `MODEL_UNAVAILABLE`     | `ai` is an optional peer dependency; `npm i -D ai`.                                                |

A missing model is checked once per run, so a suite without one reports one
run-level error rather than one blocked step per test. A missing key is not
checked ahead of time: the runner does not know which variable to look for,
so the provider reports it on the first call.

<CardGroup cols={2}>
  <Card title="Agents and personas" icon="users" href="/agents">
    Several agents in one suite.
  </Card>

  <Card title="Config reference" icon="sliders" href="/reference/config#model">
    The `model` slot and every other key.
  </Card>
</CardGroup>
