@crow-agent/ai
v0.2.1
Published
Unified multi-provider LLM client: model discovery, auth, and streaming across OpenAI, Anthropic, Google, and more.
Readme
@crow-agent/ai
One TypeScript client for a crowd of LLM providers. @crow-agent/ai gives you a
single Models collection that talks to OpenAI, Anthropic, Google, Mistral,
xAI, and dozens more through the same streaming interface. Each provider ships
its own model catalog, credential resolution, and wire protocol; your
application code never branches on which one is answering. Tool calling with
TypeBox validation, vision input, image generation, classifier models,
reasoning controls, OAuth login flows, and a scripted in-memory provider for
tests are all included.
Contents
- Install
- Your first request
- How it fits together
- Provider roster
- Credentials
- Streaming and one-shot calls
- Tools
- Reasoning effort
- Images in, images out
- Classifiers
- Failures, retries, and aborts
- Bring your own provider
- Testing without the network
- Browsers and bundlers
- OAuth login
- The compat entrypoint
- Contributing: providers and catalog data
Install
npm install @crow-agent/aiTypeBox's core exports are re-exported for convenience, so Type, Static,
and TSchema can all be imported straight from @crow-agent/ai.
Your first request
A Models collection is the whole API surface. Register providers, resolve a
model from the local catalog (no network), and stream the answer:
import { Type, type Context, type Tool } from '@crow-agent/ai';
import { builtinModels } from '@crow-agent/ai/vendors/all';
const models = builtinModels(); // every built-in provider, one collection
// Catalog reads are synchronous — nothing leaves your machine here.
const model = models.getModel('anthropic', 'claude-sonnet-4-6')!;
// Tools are TypeBox schemas: type-safe in the editor, validated at runtime,
// and plain JSON when they need to cross a process boundary.
const tools: Tool[] = [{
name: 'check_stock',
description: 'Check warehouse stock for a SKU',
parameters: Type.Object({
sku: Type.String({ description: 'The SKU, e.g. WIDGET-42' }),
}),
}];
const context: Context = {
systemPrompt: 'You are a warehouse assistant. Be concise.',
messages: [{ role: 'user', content: 'Do we have WIDGET-42 in stock?', timestamp: Date.now() }],
tools,
};
// Credentials resolve from the environment — ANTHROPIC_API_KEY in this case.
for await (const event of models.stream(model, context)) {
switch (event.type) {
case 'text_delta':
process.stdout.write(event.delta);
break;
case 'thinking_delta':
break; // reasoning models only
case 'toolcall_end':
console.log(`\nTool requested: ${event.toolCall.name}`);
break;
case 'done':
console.log(`\nStopped because: ${event.reason}`);
break;
case 'error':
console.error(`\nRequest failed: ${event.error.errorMessage}`);
break;
}
}
const reply = await models.stream(model, context).result();
context.messages.push(reply); // the finished message, ready to keep chatting
for (const block of reply.content) {
if (block.type !== 'toolCall') continue;
context.messages.push({
role: 'toolResult',
toolCallId: block.id,
toolName: block.name,
content: [{ type: 'text', text: `SKU ${block.arguments.sku}: 137 units on hand.` }],
isError: false,
timestamp: Date.now(),
});
}
if (reply.content.some(b => b.type === 'toolCall')) {
context.messages.push(await models.complete(model, context));
}
console.log(`Usage: ${reply.usage.input} in / ${reply.usage.output} out`);
console.log(`Cost: $${reply.usage.cost.total.toFixed(4)}`);Want the whole answer at once instead of a stream? await models.complete(model,
context) returns the finished AssistantMessage directly. Everything below
assumes a models collection like the one above.
How it fits together
Three ideas carry the design:
- Provider. A bundle of a model catalog, credential resolution (API keys,
OAuth, ambient cloud credentials), and the streaming implementation for its
APIs. Providers are created by factory functions such as
anthropicProvider()and registered on a collection withmodels.setProvider(...). - Models collection. The router. You look models up and make requests through it; it hands each call to the provider that owns the model.
- Context. The provider-neutral conversation: system prompt, messages, and
tools. Because it carries no provider-specific state, the same
Contextcan move between providers mid-conversation (see below).
The generated catalog — src/models.generated.ts plus per-provider JSON under
src/providers/data/ — is compiled into the package, so catalog reads never
touch the network. That keeps startup fast and makes the catalog usable offline
and in browsers.
Two providers go further than the static data: Radius overlays its gateway's authenticated catalog at runtime, and GitHub Copilot advertises whatever the Copilot API currently serves. Everything else stays fixed until you regenerate (see Contributing).
When bundle size matters, skip builtinModels() and register only the
factories you need — each lives at @crow-agent/ai/providers/<id>:
import { createModels } from '@crow-agent/ai';
import { openaiProvider } from '@crow-agent/ai/vendors/openai';
import { anthropicProvider } from '@crow-agent/ai/vendors/anthropic';
const models = createModels();
models.setProvider(openaiProvider());
models.setProvider(anthropicProvider());Providers whose credentials are absent simply fail at request time with a clear auth error; there is no need to pre-filter the roster.
Catalog lookups you will use constantly:
models.getModel('openai', 'gpt-5.4'); // one model, or undefined
models.getModels('anthropic'); // every chat model for a provider
models.getModelOfType('image', 'openrouter', 'google/gemini-2.5-flash-image');
models.getProviders(); // registered provider IDsgetModel returns undefined for unknown pairs instead of throwing, so
feature detection is a one-liner.
Provider roster
| Provider | Notes |
|----------|-------|
| OpenAI | |
| Ant Ling | |
| Azure OpenAI (Responses) | |
| OpenAI Codex | ChatGPT Plus/Pro subscription; OAuth only |
| Radius | API key or OAuth; catalog refreshed at runtime |
| TypeSafe | System One classifier API |
| DeepSeek | |
| NVIDIA NIM | |
| Anthropic | |
| Google | Gemini |
| Vertex AI | Gemini through Google Cloud |
| Mistral | |
| Groq | |
| Cerebras | |
| Cloudflare AI Gateway | |
| Cloudflare Workers AI | |
| xAI | |
| OpenRouter | |
| Vercel AI Gateway | |
| ZAI Coding Plan | Global, plus a separate China provider |
| MiniMax | Plus a separate China provider |
| Together AI | |
| Baseten | |
| Hugging Face | |
| Moonshot AI | Plus a separate China provider |
| GitHub Copilot | OAuth only; catalog refreshed at runtime |
| Amazon Bedrock | |
| OpenCode Zen | |
| OpenCode Go | |
| Fireworks | OpenAI- and Anthropic-compatible endpoints |
| Kimi For Coding | Moonshot AI subscription endpoint, Anthropic-compatible |
| Meta | Model API, OpenAI Responses-compatible |
| Qwen Token Plan | Individual and existing catalogs, plus a separate China provider |
| Xiaomi MiMo | API billing endpoint by default, plus Token Plan providers for cn / ams / sgp |
| Any OpenAI-compatible API | Ollama, vLLM, LM Studio, and the rest |
Credentials
When a request goes out, the owning provider resolves credentials in order:
- An explicit
apiKeyin the request options. - A stored credential from the credential store (saved via
crow-ai login, for example). - Provider environment variables (table below).
- Ambient mechanisms — AWS credential chains for Bedrock, gcloud ADC for Vertex, and the like.
The first hit wins. A miss surfaces as an auth error naming the provider and everything that was tried.
Some gateways need extra per-request headers (Copilot's VS Code identity headers are the classic case). Providers declare fixed headers on their models, and you can add your own per request — explicit headers merge over provider defaults:
await models.complete(model, context, {
headers: { 'X-Request-Source': 'warehouse-bot' },
});Stored credentials
getCredentialStore() returns a file-backed store (OS-appropriate config
directory, locked-down file permissions) used by the crow-ai login CLI and
the programmatic OAuth flows. Entries are scoped per provider ID — a key saved
for openai is never offered to anthropic:
import { getCredentialStore } from '@crow-agent/ai';
const store = getCredentialStore();
await store.set('openai', { type: 'apiKey', apiKey: process.env.OPENAI_API_KEY! });
const saved = await store.get('openai');
await store.delete('openai');Environment variables
In Node.js, built-in providers read these. Browsers have no process.env —
pass apiKey explicitly there instead.
| Provider | Variable(s) |
|----------|-------------|
| OpenAI | OPENAI_API_KEY |
| Ant Ling | ANT_LING_API_KEY |
| Azure OpenAI | AZURE_OPENAI_API_KEY and AZURE_OPENAI_BASE_URL (e.g. https://{resource}.ai.azure.com) or AZURE_OPENAI_RESOURCE_NAME. Hosts under *.openai.azure.com, *.cognitiveservices.azure.com, and *.ai.azure.com are accepted; bare resource roots are normalized to /openai/v1. Optional: AZURE_OPENAI_API_VERSION (default v1), AZURE_OPENAI_DEPLOYMENT_NAME_MAP |
| Anthropic | ANTHROPIC_API_KEY or ANTHROPIC_OAUTH_TOKEN |
| Radius | RADIUS_API_KEY |
| TypeSafe | TYPESAFE_API_KEY |
| DeepSeek | DEEPSEEK_API_KEY |
| NVIDIA NIM | NVIDIA_API_KEY |
| Google | GEMINI_API_KEY |
| Vertex AI | GOOGLE_CLOUD_API_KEY, or GOOGLE_CLOUD_PROJECT (or GCLOUD_PROJECT) + GOOGLE_CLOUD_LOCATION with Application Default Credentials |
| Mistral | MISTRAL_API_KEY |
| Groq | GROQ_API_KEY |
| Cerebras | CEREBRAS_API_KEY |
| Cloudflare AI Gateway | CLOUDFLARE_API_KEY + CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_GATEWAY_ID |
| Cloudflare Workers AI | CLOUDFLARE_API_KEY + CLOUDFLARE_ACCOUNT_ID |
| xAI | XAI_API_KEY |
| Fireworks | FIREWORKS_API_KEY |
| Together AI | TOGETHER_API_KEY |
| Baseten | BASETEN_API_KEY |
| OpenRouter | OPENROUTER_API_KEY |
| Vercel AI Gateway | AI_GATEWAY_API_KEY |
| ZAI Coding Plan (global) | ZAI_API_KEY |
| ZAI Coding Plan (China) | ZAI_CODING_CN_API_KEY |
| MiniMax (global) | MINIMAX_API_KEY |
| MiniMax (China) | MINIMAX_CN_API_KEY |
| Moonshot AI (either region) | MOONSHOT_API_KEY |
| Hugging Face | HF_TOKEN |
| OpenCode Zen / OpenCode Go | OPENCODE_API_KEY |
| Kimi For Coding | KIMI_API_KEY |
| Meta | META_API_KEY |
| Qwen Token Plan (existing / Individual) | QWEN_TOKEN_PLAN_API_KEY |
| Qwen Token Plan (China) | QWEN_TOKEN_PLAN_CN_API_KEY |
| Xiaomi MiMo (API billing) | XIAOMI_API_KEY |
| Xiaomi MiMo Token Plan (China) | XIAOMI_TOKEN_PLAN_CN_API_KEY |
| Xiaomi MiMo Token Plan (Amsterdam) | XIAOMI_TOKEN_PLAN_AMS_API_KEY |
| Xiaomi MiMo Token Plan (Singapore) | XIAOMI_TOKEN_PLAN_SGP_API_KEY |
| GitHub Copilot | COPILOT_GITHUB_TOKEN |
qwen-token-plan and qwen-token-plan-individual share the international
endpoint and QWEN_TOKEN_PLAN_API_KEY. The Individual catalog is the
allowlisted subset for Individual subscriptions; the existing catalog keeps the
broader list for backward compatibility. Because stored credentials are
provider-scoped, save the key under whichever provider ID you register.
Bedrock resolves ambient AWS credentials (AWS_PROFILE, access-key pairs,
AWS_BEARER_TOKEN_BEDROCK, ECS task roles, web-identity tokens); its login
flow additionally covers bearer tokens, named profiles, and the standard chain.
Vertex AI takes an explicit API key or gcloud ADC plus project/location, with a
login flow for API keys, ADC, and service-account files.
Two CROW_-prefixed settings tune cross-provider behavior:
CROW_CACHE_RETENTION (short by default, or long where providers support
extended prompt-cache TTLs) and CROW_OAUTH_CALLBACK_HOST (the loopback host
OAuth flows listen on; default 127.0.0.1).
Streaming and one-shot calls
models.stream(model, context, options?) yields a normalized event sequence;
models.complete(...) resolves to the assembled AssistantMessage. The event
vocabulary:
| Event | Meaning |
|-------|---------|
| start | Stream opened; carries the model in use |
| text_start / text_delta / text_end | One text block's lifecycle |
| thinking_start / thinking_delta / thinking_end | Reasoning content (reasoning models only) |
| toolcall_start / toolcall_delta / toolcall_end | One tool call's lifecycle |
| done | Finished; event.reason is the stop reason |
| error | Terminal failure; event.error.errorMessage explains it |
stream.result() resolves to the assembled message once the stream completes,
no matter which events you consumed.
Assistant messages also support a compact frame encoding for cheap persistence and transport — text, thinking, tool calls, and usage survive without the full JSON envelope. See Context Serialization.
The done reason
stop— natural end of turn.toolCalls— the model wants tools run; execute them and continue.length— a token limit was hit; raisemaxTokensor pick a longer-context model.contentFilter— a provider safety filter tripped.abort— cancelled client-side.
stop with neither text nor tool calls usually means the model emitted only
thinking blocks — look at the thinking content on the final message.
Tools
Declaring them
import { Type, type Tool } from '@crow-agent/ai';
const tools: Tool[] = [
{
name: 'open_ticket',
description: 'File a support ticket',
parameters: Type.Object({
subject: Type.String(),
severity: Type.Union([Type.Literal('low'), Type.Literal('urgent')]),
cc: Type.Optional(Type.Boolean()),
}),
},
];Attach the array to the Context. The model decides when to call; argument
validation runs before your handler ever sees the payload, and a model that
violates the schema produces an error event naming the tool and the offending
properties — malformed input never reaches your code.
The call loop
Every tool interaction follows the same loop — stream or complete, collect
toolCall blocks, run your functions, append toolResult messages, continue:
const message = await models.complete(model, context);
for (const block of message.content) {
if (block.type !== 'toolCall') continue;
const output = await dispatchTool(block.name, block.arguments); // yours
context.messages.push(message, {
role: 'toolResult',
toolCallId: block.id,
toolName: block.name,
content: [{ type: 'text', text: output }],
isError: false,
timestamp: Date.now(),
});
}Tool results accept text and image content blocks.
Watching calls form mid-stream
toolcall_delta events carry incrementally-parsed arguments. The partial-json
parser recovers usable prefixes from incomplete JSON, so a UI can show
"calling open_ticket…" before the arguments finish arriving:
for await (const event of models.stream(model, context)) {
if (event.type === 'toolcall_delta') {
const block = event.partial.content[event.contentIndex];
if (block.type === 'toolCall') console.log('args so far:', JSON.stringify(block.arguments));
}
}toolcall_end delivers the final, validated call.
Constrained tool sampling
Some APIs (notably OpenAI Responses and Anthropic) support grammar-constrained
or structured-output tool calls. When a model advertises strict tool support,
@crow-agent/ai opts into the constrained mode on its own — your schemas are then
enforced by the provider, not merely validated after the fact.
Reasoning effort
Reasoning models (model.reasoning === true) expose one unified effort dial
plus provider-native escape hatches.
The simple interface
streamSimple / completeSimple take a plain-text transcript and return plain
text, hiding events, tools, and message assembly:
const answer = await models.completeSimple(
model,
[{ role: 'user', content: 'Why is the sky blue?' }],
{ thinkingLevel: 'high' }, // off | minimal | low | medium | high | xhigh | max
);thinkingLevel maps onto whatever the provider supports — Anthropic effort
levels, OpenAI reasoning effort, Google thinking budgets — through each model's
thinkingLevelMap. Levels a model doesn't support are dropped when the catalog
is generated.
Going native
For full control, pass API-specific options through options.providerOptions:
await models.complete(model, context, {
providerOptions: {
anthropic: { thinking: { type: 'enabled', budgetTokens: 16000 } },
},
});The key names the API implementation (anthropic, openaiResponses,
google, …). Native options merge under the unified thinkingLevel — the
explicit native choice wins on conflict.
Reading the reasoning
Reasoning streams emit thinking_start / thinking_delta / thinking_end
before (or interleaved with) text. Render them as collapsible "reasoning" UI
or ignore them — stream.result() assembles the final message either way. Some
providers return thinking signatures that must be echoed back next turn for
multi-turn reasoning; @crow-agent/ai keeps them on the message automatically.
Images in, images out
Vision input
Models whose input includes 'image' accept image blocks in user messages:
const visionContext: Context = {
systemPrompt: 'Describe what you see.',
messages: [{
role: 'user',
content: [
{ type: 'text', text: 'What is wrong with this dashboard?' },
{ type: 'image', data: screenshotBytes, mimeType: 'image/png' },
],
timestamp: Date.now(),
}],
tools: [],
};Images travel as buffers, data URLs, or remote URLs depending on the provider's
API implementation. Check model.input first — non-vision models reject image
blocks.
Generation
Image models live in the same collection, addressed by type:
const imageModel = models.getModelOfType('image', 'openrouter', 'google/gemini-2.5-flash-image')!;
const result = await models.generateImages(imageModel, {
prompt: 'A watercolor of a monsoon street market at dusk',
// optional: aspect ratio, count, and other provider-specific options
});
for (const image of result.images) {
await saveImage(image); // image.data: bytes; image.mimeType: e.g. 'image/png'
}generateImages resolves auth exactly like chat requests. A few things to know:
- Image models are cataloged separately from chat models; one upstream ID can expose both a chat and an image variant on different APIs.
- Sizes, counts, and aspect ratios are provider-defined; unsupported options fail with a descriptive error rather than being silently ignored.
- Billing follows the provider's image pricing, not the chat cost metadata.
Classifiers
Classifier models (TypeSafe's System One "Jev", served through the typesafe
and cloudflare-workers-ai providers) score content instead of generating it:
const classifier = models.getModelOfType('classifier', 'typesafe', 'jev-latest')!;
const verdict = await models.classify(classifier, {
content: [{ type: 'text', text: '...user content to score...' }],
});The result carries the classifier's labels and scores. Classifiers have their
own API implementations (typesafe-system-one,
cloudflare-workers-ai-system-one), their own catalog section in the generated
data, and no token-cost metadata — System One reports no token usage.
Failures, retries, and aborts
Provider failures arrive as error events on streams and as thrown exceptions
from complete(). The error carries a machine-readable code, a human
errorMessage, and the provider/model that failed. Rate limits are distinct
from auth failures and malformed requests, so retry logic can tell them apart.
Cancelling
Pass an AbortSignal; the stream stops promptly and provider connections are
torn down:
const controller = new AbortController();
setTimeout(() => controller.abort(), 30_000);
try {
for await (const event of models.stream(model, context, { signal: controller.signal })) {
// ...
}
} catch (error) {
if (controller.signal.aborted) console.log('cancelled by user');
else throw error;
}Aborting mid-stream poisons nothing: the partial AssistantMessage from
stream.result() (if any) is yours to keep or discard, and a fresh
stream()/complete() on the same context just works.
Debugging the wire
Set CROW_DEBUG (or pass debug: true in request options) to log raw request
payloads and response chunks per API implementation. Payloads are logged before
auth headers are attached, so keys never end up in your logs. For a live view
of provider-native events without disturbing the normalized flow:
for await (const event of models.stream(model, context, {
onProviderEvent: (raw) => console.dir(raw, { depth: 4 }),
})) { /* normalized events as usual */ }Bring your own provider
Assembling one
createProvider() builds a provider from parts — an ID and display name, auth,
a model list, and at least one API implementation (api for chat, images
for image generation, classifiers for classification). It is how you front
local inference servers, corporate proxies, or any OpenAI/Anthropic-compatible
endpoint:
import { createModels, createProvider, envApiKeyAuth, type Model } from '@crow-agent/ai';
import { openAICompletionsApi } from '@crow-agent/ai/transports/openai-completions.lazy';
const localModel: Model<'openai-completions'> = {
id: 'qwen3-32b',
name: 'Qwen3 32B (local)',
api: 'openai-completions',
provider: 'local-llm',
baseUrl: 'http://localhost:11434/v1',
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32768,
maxTokens: 8192,
};
const localLlm = createProvider({
id: 'local-llm',
name: 'Local LLM',
auth: envApiKeyAuth('LOCAL_LLM_API_KEY'),
models: [localModel],
api: openAICompletionsApi,
});
const models = createModels();
models.setProvider(localLlm);envApiKeyAuth reads a key from the named variable; static-key and OAuth auth
helpers exist too. Register the result with models.setProvider() like any
built-in.
The OpenAI-completions API carries a compat block per model describing
endpoint quirks: which token field to send (max_tokens vs
max_completion_tokens), store parameter and developer role support,
reasoning-effort support, strict structured outputs, and thinking-block formats
(deepseek, qwen, openai, ant-ling). The generated catalog fills these
in; override them on custom models when your endpoint deviates.
Calling an API directly
No provider or collection is required to use an API implementation. The
per-API modules export stream*/complete* functions taking an explicit
model, auth, and options — useful for one-off scripts:
import { streamAnthropicMessages } from '@crow-agent/ai/transports/anthropic-messages';
for await (const event of streamAnthropicMessages(model, context, { apiKey: '...' })) {
// normalized events, same as through a Models collection
}Lazy subpaths (@crow-agent/ai/api/<name>.lazy) defer loading the implementation
until first use, which helps CLI startup time.
Moving a conversation between providers
Because Context is provider-agnostic, a thread can hop providers mid-stream —
draft on one, review on another:
const draft = await models.complete(models.getModel('openai', 'gpt-5.4')!, context);
context.messages.push(draft);
const review = await models.complete(models.getModel('anthropic', 'claude-sonnet-4-6')!, context);Each API implementation translates the shared blocks into its wire format and back, so thinking signatures and tool results carry over without manual conversion.
Testing without the network
fauxProvider() builds an in-memory provider with scripted responses —
deterministic streams for unit tests and demos, no keys, no network:
import {
createModels,
fauxAssistantMessage,
fauxProvider,
fauxText,
fauxThinking,
fauxToolCall,
} from '@crow-agent/ai';
const faux = fauxProvider({ script: [
fauxAssistantMessage([fauxThinking('Let me check that.'), fauxText('On it.')]),
fauxAssistantMessage([fauxToolCall('check_stock', { sku: 'WIDGET-42' })]),
]});
const models = createModels();
models.setProvider(faux.provider);
const model = models.getModel('faux', 'faux-chat')!;
const reply = await models.complete(model, context); // replays the script in orderHelpers cover text, thinking, tool calls, and tool-result echoes. When the script runs out, the provider raises a clear "script exhausted" error instead of hanging.
Browsers and bundlers
@crow-agent/ai runs in browsers with two caveats:
- No
process.env. PassapiKeyexplicitly in request options or provider auth — the environment-variable table above does not apply. - CORS. Most provider APIs omit CORS headers, so call them through your own backend proxy (a thin endpoint that attaches the key) rather than directly.
The generated catalog and all message/tool serialization work unchanged in the browser; only credential resolution and direct network access differ.
The package is ESM with per-provider and per-API subpaths, so importing
@crow-agent/ai/providers/openai pulls in only that provider's factory, and the
lazy API modules defer implementation code until first use. The root entrypoint
re-exports shared types and helpers without dragging in every provider.
builtinModels() is the exception — it registers everything, which is
convenient but heavy. Prefer individual factories for size-sensitive builds.
Request-scoped environment overrides
Pass env in stream/complete options to scope provider configuration to one
request. It beats process.env for auth and settings such as Cloudflare
account IDs, Azure OpenAI endpoints, Vertex project/location, Bedrock options,
CROW_CACHE_RETENTION, and HTTP_PROXY/HTTPS_PROXY:
await models.complete(model, context, {
env: { CROW_CACHE_RETENTION: 'long', HTTPS_PROXY: 'http://proxy.internal:3128' },
});Overrides apply to that request only and never leak into the process environment.
OAuth login
Several providers authenticate with OAuth instead of (or alongside) API keys:
OpenAI Codex (ChatGPT Plus/Pro), GitHub Copilot, Anthropic (OAuth token), Kimi
For Coding, Meta, OpenRouter, and Radius. Each exposes a login flow on its
provider auth.
The CLI
The crow-ai binary handles OAuth without writing code:
npx @crow-agent/ai login # pick from OAuth-capable providers
npx @crow-agent/ai login openai-codex
npx @crow-agent/ai list # show available providersThe flow opens the provider's authorization page, listens on a loopback
callback (CROW_OAUTH_CALLBACK_HOST, default 127.0.0.1), and stores the
resulting credential in the credential store under the provider ID. Later
requests pick it up automatically.
From your own code
Drive the same flow from your own UI:
const provider = models.getProvider('github-copilot');
const flow = await provider.auth.oauth!.login({
onOpenUrl: (url) => openInBrowser(url), // your hook
});
await flow.waitForCallback(); // resolves with the stored credentialThe provider handles PKCE, token exchange, refresh, and storage; you supply only the "open this URL" step and wait.
Vertex AI, concretely
Vertex AI accepts an API key (GOOGLE_CLOUD_API_KEY) or Google Cloud
credentials: GOOGLE_CLOUD_PROJECT (or GCLOUD_PROJECT) plus
GOOGLE_CLOUD_LOCATION with Application Default Credentials, discovered from
GOOGLE_APPLICATION_CREDENTIALS or the standard gcloud well-known path:
# Local development (your own user credentials)
gcloud auth application-default login
# CI / production (service-account key file)
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
export GOOGLE_CLOUD_PROJECT=my-project
export GOOGLE_CLOUD_LOCATION=us-central1The provider's login flow also accepts an API key, ADC, or a service-account file interactively.
System prompts, serialization, and message hygiene
The system prompt lives on Context.systemPrompt, apart from the message
list. Providers map it onto their native mechanism — Anthropic's system
parameter, OpenAI's developer/system role, Bedrock's system blocks —
quirks included (endpoints that reject developer fall back to system; the
compat metadata drives the choice). Mid-conversation system messages are
supported where APIs allow them and folded into the conversation in a
documented way where they don't — never silently dropped.
Context, AssistantMessage, and tool schemas are plain JSON-serializable
data. Persist a conversation with JSON.stringify, restore it later (possibly
on another machine), and resume:
await kv.set(sessionId, JSON.stringify(context));
// ... later, perhaps in another process ...
const context: Context = JSON.parse(await kv.get(sessionId));
const reply = await models.complete(model, context);The compact frame encoding mentioned under Streaming shrinks assistant messages for storage-heavy workloads; frames round-trip back into full messages losslessly.
The compat entrypoint
Earlier versions exposed module-level functions — stream() / complete()
dispatching on model.api through a global registry, plus getModel(),
getModels(), getProviders(), registerApiProvider(), and getEnvApiKey().
That surface is preserved verbatim on the compat entrypoint:
// Before
import { getModel, complete } from '@crow-agent/ai';
// After — same behavior, one import-path change
import { getModel, complete } from '@crow-agent/ai/legacy';Compat is a strict superset of the root entrypoint, so a file can switch
wholesale. It is slated for removal in a future release; new code should use
createModels() plus provider factories:
| Old way | New way |
|---------|---------|
| getModel('openai', 'gpt-4o-mini') | models.getModel('openai', 'gpt-4o-mini'), or getBuiltinModel() from providers/all |
| getModels('anthropic') / getProviders() | models.getModels('anthropic') / models.getProviders() |
| stream(model, ctx, opts) (env-key injection) | models.stream(model, ctx, opts) (provider auth resolution) |
| registerApiProvider({ api, stream, streamSimple }) | createProvider({ id, auth, models, api }) + models.setProvider() |
| getEnvApiKey('openai') | await models.getAuth(model.provider) |
| streamAnthropic(model, ctx, opts) | stream from @crow-agent/ai/api/anthropic-messages, or a provider in a collection |
| registerFauxProvider() | fauxProvider() + models.setProvider() |
| getImageModel('openrouter', id) / generateImages(model, ctx, { apiKey }) | models.getModelOfType('image', 'openrouter', id) / models.generateImages(model, ctx) |
Contributing: providers and catalog data
Adding a provider
- Create
src/providers/<id>.tswith a factory function (start from a small one likesrc/providers/groq.ts). - Add the provider to the generator's collectors in
scripts/generate-models.ts, or hand-write the<id>.models.tsshard plussrc/providers/data/<id>.jsonfollowing the existing shape. - Add the import and catalog entries in
src/models.generated.ts(or regenerate — see below). - Document env vars and quirks in this README's tables.
Regenerating the catalog
Model metadata is generated, never hand-edited:
npm run generate-models # full regeneration, strict mode (default)
npm run hydrate-model-data # refresh only src/vendors/data/*.json
npm run check:model-data # validate the generated data without regeneratinggenerate-models pulls models.dev, OpenRouter, the Vercel AI Gateway catalog,
and the Radius gateway, merges them with the manual corrections in the script,
and rewrites the per-provider JSON, the .models.ts shards, and
src/models.generated.ts. It stages everything in a temp directory and
validates before replacing, so a failed run never leaves half-written data.
Strict mode (the npm script default) turns any upstream fetch failure or
catalog mismatch into a hard error — including the Qwen Token Plan Individual
allowlist check.
Never edit src/models.generated.ts or the .models.ts shards by hand; change
scripts/generate-models.ts and regenerate.
