Agent Development Kit · Build · the model seam

Choosing a model, and swapping it

An agent's model: is a descriptor — a small data object naming a provider, a model and its options. It never talks to anyone. The runner resolves an adapter for that provider when a call actually happens, and that seam is why the same agent runs against OpenAI, against Vertex, or against a scripted test with no key at all.

Runs here · scripted cells need no key Live cells · your own OpenAI key Providers · openai · gemini · claude

Step 1

A model is a value

openai(name, options?) returns an object, not a client. It holds provider, name, and whatever options you passed — nothing else you can see. The adapter that will eventually make the HTTP call rides along on a non-enumerable symbol, so a descriptor stays inspectable, comparable and safe to store.

The first three cells below run with no key. The two live cells further down call OpenAI directly from this page, so paste a key when you get there.

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'

const app = adk()

// Two descriptors. No network, no client, no key — just data.
const luna = openai('gpt-5.6-luna')
const tuned = openai('gpt-5.6-terra', { temperature: 0.2, maxTokens: 300 })
const descriptors = { luna, tuned }

descriptors

Every provider factory takes the same shape: a model name, then an options object typed for that provider. temperature and maxTokens are the two options all three share; everything else is provider-specific and covered in step 3. The factories also carry a realtime variant (openai.realtime, gemini.realtime) that wraps a descriptor for voice — that wrapper belongs to the voice runtime, not to this chapter.

Step 2

Descriptor, adapter, runner

A descriptor names a model; an adapter is the thing that speaks the provider's wire protocol. The runner picks one per model call, in a fixed order. An adapter registered on the app — adk({ adapters: { openai: myAdapter } }) — wins outright. Otherwise the runner uses the factory the descriptor carries, which is what importing @animahealth/adk/openai attaches. Failing both, it imports the provider's adapter dynamically; a provider it has no case for throws, naming the subpath you should have imported.

That order is the whole trick. Below, runTest registers a scripted adapter, so the descriptor still says which model this agent is for while no provider client is ever built — no key, no SDK, no network.

import { runTest, user, model, getLastAssistantText } from '@animahealth/adk/testing'

const triage = app.agent({
  name: 'triage',
  model: luna,
  context: [app.context.system('Answer in one short sentence.'), app.context.history()],
})

const scriptedOpenAI = await runTest(triage, [user('Ping'), model('Pong, from a scripted turn.')])

getLastAssistantText(scriptedOpenAI.events)

Now point the same agent at a different provider. A descriptor is plain data, so you can write one out by hand instead of calling a factory — useful when the model comes from configuration. This agent names Gemini, and it runs exactly as the OpenAI one did, because runTest registers its scripted adapter for both providers.

const triageOnGemini = app.agent({
  name: 'triage_gemini',
  model: { provider: 'gemini', name: 'gemini-3-flash' },
  context: [app.context.system('Answer in one short sentence.'), app.context.history()],
})

const scriptedGemini = await runTest(triageOnGemini, [user('Ping'), model('Pong, again.')])
const swapped = {
  named: triageOnGemini.model,
  reply: getLastAssistantText(scriptedGemini.events),
}

swapped

One limit, worth knowing before it bites: runTest registers its scripted adapter for openai and gemini only. An agent whose descriptor says claude falls through to the real Vertex adapter and will ask for Google credentials. Pass your own adapter through adk({ adapters: { claude: … } }) for that case.

This page is itself an instance of rule one. The adk() it serves has an OpenAI adapter registered on it — one that reads the key from the box above at call time, and sets the browser flag from step 4. Your openai(…) descriptors are honoured for which model to call; the registered adapter decides how.

Step 3

Options that change the call

Provider options live on the descriptor, so changing one is a one-line edit with no plumbing behind it. OpenAI takes reasoning: { effort, summary? }; effort is minimal, low, medium or high. Run this, then change the effort and run it again — reasoningTokens moves, and so does the cost.

One rule to know: when a descriptor carries reasoning, the adapter does not send temperature at all, because reasoning models reject a non-default value. Setting both is not an error; the temperature is simply dropped.

const scout = app.agent({
  name: 'scout',
  model: openai('gpt-5.6-luna', { reasoning: { effort: 'minimal' } }),
  context: [app.context.system('Answer in one short sentence.'), app.context.history()],
})

const scouted = await app.run(scout, 'Why is the sky blue?')

scouted.usage

The modelName in that summary is the name you wrote, not whatever the endpoint resolved it to, and the cost estimate is looked up from it. That matters in step 4, where a deployment can be called something else entirely.

Gemini and Claude take their own thinking controls. Their SDKs are optional peers behind @animahealth/adk/gemini and @animahealth/adk/claude, which this page does not serve — so these two are static, checked against the same types the cells above compile against.

import { gemini } from '@animahealth/adk/gemini'
import { claude } from '@animahealth/adk/claude'

// Gemini: a thinking budget in tokens, or a level; thoughts can be returned.
const flash = gemini('gemini-3-flash', {
  temperature: 0.3,
  thinkingConfig: { thinkingLevel: 'low', includeThoughts: true },
})

// Claude runs through Google Vertex, so `vertex` is required — there is no second argument
// without it. Credentials fall back to GOOGLE_APPLICATION_CREDENTIALS when the path is omitted.
const sonnet = claude('claude-sonnet-4-20250514', {
  vertex: { project: 'my-project', location: 'us-east5' },
  thinking: { budgetTokens: 4000 },
})

All three providers also accept retry on the descriptor, applied around the model stream. It takes the same RetryConfig as a tool's — see Tools.

Step 4

Endpoints, Azure and the browser

Where the call goes is the adapter's business, not the descriptor's. The OpenAI adapter takes an ordered list of endpoints and tries them in turn, falling forward only on failures worth retrying elsewhere: rate limits, timeouts, connection errors, 500/502/503, and a missing model or deployment. Any other error is thrown from the first endpoint that raises it.

import { adk } from '@animahealth/adk'
import { OpenAIAdapter } from '@animahealth/adk/openai'

const openAIAdapter = new OpenAIAdapter([
  {
    type: 'azure',
    baseUrl: 'https://my-resource.openai.azure.com',
    apiVersion: '2025-01-01-preview',
    apiKey: process.env.AZURE_OPENAI_API_KEY,
    // The name your code writes, mapped to the name your Azure resource deploys it under.
    modelMapping: { 'gpt-5.6-luna': 'gpt-5-6-luna-2026-04' },
  },
  // Second choice: OpenAI's EU region, by base URL. Any OpenAI-compatible host works here.
  { type: 'openai', baseUrl: 'https://eu.api.openai.com/v1', apiKey: process.env.OPENAI_EU_API_KEY },
  // Last: plain api.openai.com.
  { type: 'openai', apiKey: process.env.OPENAI_API_KEY },
])

const configuredApp = adk({ adapters: { openai: openAIAdapter } })

For an azure endpoint the adapter builds the client against {baseUrl}/openai/deployments/{deployment} with your apiVersion; modelMapping is what turns the logical name on the descriptor into that deployment. Usage and cost still report the logical name, so the accounting does not change when you move a model behind an alias.

Pass no endpoints at all and the adapter builds the list from the environment, in this order: AZURE_OPENAI_ENDPOINT with AZURE_OPENAI_API_KEY, then OPENAI_EU_API_KEY, then OPENAI_API_KEY. With none of them set it throws, naming all three.

The last endpoint field is a safety catch. The OpenAI client refuses to run in a browser, because the usual reason it is there is a server key baked into shipped JavaScript. dangerouslyAllowBrowser: true opts out of that guard, for the one case it was meant to allow: the end user typed the key themselves. This page is that case — it is exactly how the cells above reach api.openai.com with no backend in between. Never set it with a key your user did not type.

// A page where the reader supplies the key, as this one does: `keyTheReaderTyped` came from an
// input on the page, never from a build-time constant.
const browserAdapter = new OpenAIAdapter([
  { type: 'openai', apiKey: keyTheReaderTyped, dangerouslyAllowBrowser: true },
])

The other two providers put their connection on the descriptor instead of on an endpoint list: Gemini takes an optional vertex: { project, location, credentials? } (or an API key given to new GeminiAdapter({ apiKey })), and Claude requires vertex, as step 3 showed.

Step 5

Prompt caching

Caching is opt-in per descriptor, and the two providers spell it differently because their APIs do. OpenAI supports explicit breakpoints only: give a stable key (1 to 64 characters — the adapter throws outside that), the mode explicit, and the 30m sliding window that refreshes on reuse.

The key alone does nothing. Some context message must also be tagged as cacheable, marking where the reusable prefix ends. app.context.cacheableUser(text) is that tag; if nothing in the rendered context carries it, the adapter throws rather than quietly paying full price.

const briefer = app.agent({
  name: 'briefer',
  model: openai('gpt-5.6-luna', {
    promptCache: { key: 'clinic-handbook-v3', mode: 'explicit', ttl: '30m' },
  }),
  context: [
    app.context.system('You answer questions about the handbook.'),
    // The long, stable prefix — everything before this point is the cacheable span.
    app.context.cacheableUser(handbookText),
    app.context.history(),
  ],
})

Claude's caching is on by default for Vertex models and is configured by disabling or tuning it: enabled, a ttl of 5m or 1h, and system choosing whether all system blocks are cacheable or only tagged ones. A 1h cache write costs more than a 5m one.

const cachedSonnet = claude('claude-sonnet-4-20250514', {
  vertex: { project: 'my-project', location: 'us-east5' },
  promptCache: { enabled: true, ttl: '1h', system: 'tagged' },
})

Either way, the result shows up in the run's usage as totalCachedTokens and totalCacheWriteTokens, priced separately from fresh input.

Step 6

Images and audio going in

Multimodal input is not a model option — it is a property of the message. A user message carries media, a list of parts, each an image, audio or document with a source that is either base64 with a mimeType, or a url. The adapter for whichever provider you named translates the parts.

This is live, and it needs a model that can see. The image below is a 32-pixel square inlined as base64 — swap the data or the question and run it again.

const redSquarePng =
  'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAKklEQVR42mO4o6ZGU8QwasGoBaMWjFowasGoBaMWjFowasGoBaMWDBULAIjyoD2JhwFtAAAAAElFTkSuQmCC'

const looker = app.agent({
  name: 'looker',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Answer with one word.'), app.context.history()],
})

const seen = await app.run(looker, {
  input: {
    message: {
      text: 'What colour is this square?',
      media: [
        {
          type: 'image',
          source: { type: 'base64', mimeType: 'image/png', data: redSquarePng },
        },
      ],
    },
  },
})

seen.output.text

Coverage is per adapter and per position, and it is not uniform. On a user message, image reaches all three providers and audio reaches OpenAI and Gemini; Claude's user serializer has no audio branch. document parts are dropped from a user message by all three — they are serialized only when they arrive on a tool result, and then only for Gemini and Claude. Nothing throws when a part is dropped, so check the part type against the provider you are actually calling.

That is the model seam whole: a descriptor names who answers, and an adapter decides how the call is made. The other half of an agent's config — what it can actually do — is Tools, the next chapter.