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.
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.