npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

npm install @crow-agent/ai

TypeBox'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 with models.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 Context can 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 IDs

getModel 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:

  1. An explicit apiKey in the request options.
  2. A stored credential from the credential store (saved via crow-ai login, for example).
  3. Provider environment variables (table below).
  4. 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; raise maxTokens or 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 order

Helpers 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:

  1. No process.env. Pass apiKey explicitly in request options or provider auth — the environment-variable table above does not apply.
  2. 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 providers

The 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 credential

The 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-central1

The 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

  1. Create src/providers/<id>.ts with a factory function (start from a small one like src/providers/groq.ts).
  2. Add the provider to the generator's collectors in scripts/generate-models.ts, or hand-write the <id>.models.ts shard plus src/providers/data/<id>.json following the existing shape.
  3. Add the import and catalog entries in src/models.generated.ts (or regenerate — see below).
  4. 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 regenerating

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