cardan
v0.21.0
Published
Zero-dependency TypeScript AI and LLM SDK for OpenAI, Anthropic, Gemini, xAI, Groq, and Modal
Maintainers
Keywords
Readme
cardan
Unified TypeScript adapter for major LLM provider APIs. Zero runtime dependencies — adapters speak HTTP via native fetch. Runs on Node ≥ 20, Deno, and edge runtimes.
See docs/design.md for goals, non-goals, and provider tiers (full design notes in docs/).
Status
0.x — unstable until raven/ticks migrate onto it. Implemented: core schema + Anthropic, OpenAI (Responses API), Google (Gemini API), xAI, Groq, and Modal (self-deployed, Chat Completions) adapters — generate, streaming, tools, structured output, thinking, vision; OpenAI/Google/Modal also embeddings; xAI also image generation and editing.
Providers
Model ids are prefix/model. createCardan() reads these env vars per provider (or pass keys explicitly):
| Provider | Prefix | Env vars |
| --------- | ----------- | -------------------------------------------------------------- |
| Anthropic | anthropic | ANTHROPIC_API_KEY, or CLAUDE_CODE_OAUTH_TOKEN (subscription) |
| OpenAI | openai | OPENAI_API_KEY |
| Google | google | GEMINI_API_KEY (or GOOGLE_API_KEY) |
| xAI | xai | XAI_API_KEY, or GROK_BUILD_OAUTH_TOKEN (subscription — see xAI Grok subscription) |
| Groq | groq | GROQ_API_KEY |
| Modal | modal | MODAL_BASE_URL (required); MODAL_API_KEY, or MODAL_KEY + MODAL_SECRET |
- Anthropic auth precedence (most explicit first): config
oauth→ configapiKey→ envCLAUDE_CODE_OAUTH_TOKEN→ envANTHROPIC_API_KEY.CLAUDE_CODE_OAUTH_TOKEN(fromclaude setup-token) bills against a Claude.ai subscription; if both env vars are set, the OAuth token wins and cardan warns. For the full refreshable OAuth flow, passoauthin config — see Anthropicoauth. - xAI auth precedence (most explicit first): config
xaiOAuth→ configxai.apiKey→ envGROK_BUILD_OAUTH_TOKEN(Grok Build subscription) → envXAI_API_KEY. If both env vars are set, the OAuth token wins and cardan warns. A bare env token is inference-only (no refresh); passxaiOAuthfor the refreshable flow. - Google: prefers
GEMINI_API_KEY; if both it andGOOGLE_API_KEYare set, cardan warns (Google's own@google/genaiprefersGOOGLE_API_KEY, so the two can disagree).
CLI
npx cardan@latest detect finds local Claude Code and Grok CLI subscription credentials and prints account info plus a ready-to-paste .env block; add --all-users to scan every readable account's home. It is read-only and never refreshes tokens. Runs on Linux, macOS, and Windows. Details in docs/cli.md.
The output contains live access tokens — treat stdout as a secret. Don't pipe it into shared logs or CI output, and note that
--all-userscan print other accounts' tokens (e.g. when run as root).
Anthropic (Claude Code)
file ~/.claude/.credentials.json
subscription pro · default_claude_ai
access token valid · expires 2026-07-12 18:42 UTC
refresh token present · expires 2026-08-06 21:11 UTC
# .env — cardan reads these automatically
CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-…
GROK_BUILD_OAUTH_TOKEN=eyJ0…Programmatically, detectCredentials() and detectAllUsers() return the same detection as structured data (rendering stays in the CLI).
Local OAuth (long-running services)
detect is read-only. For a service that should stay logged in, use loadLocalOAuth / localOAuthPool: they build providers from the same CLI files, wire onRefresh to write rotated tokens back, and optionally merge bare env tokens (deduped — file wins).
import { createCardan, localOAuthPool, loadLocalOAuthPrefix } from "cardan";
// Env families auto-expand: BASE, BASE1, BASE2, … BASE10 — set as many as you need.
// Default `env: true` uses CLAUDE_CODE_OAUTH_TOKEN / GROK_BUILD_OAUTH_TOKEN as bases.
const anthropic = await localOAuthPool("anthropic", {
files: false, // setup-token env only (not ~/.claude login session)
});
const xaiMembers = await loadLocalOAuthPrefix("xai"); // file + env family, file wins on dedupe
// xaiMembers[i].provider is XAIOAuthProvider (subscriptionUsage, …)
const cardan = createCardan({
providers: {
...(anthropic ? { anthropic } : {}),
...(xaiMembers.length
? {
xai: await localOAuthPool("xai"), // or createPool from xaiMembers
}
: {}),
},
});Usage
import { createCardan } from "cardan";
const cardan = createCardan(); // reads ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY / XAI_API_KEY / GROQ_API_KEY from env
// generate
const result = await cardan.generate({
model: "anthropic/claude-opus-5",
messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
});
console.log(result.message, result.usage, result.finishReason);
// result.rateLimit: subscription quota snapshot from response headers, when reported (see Behavior notes)
// finishReason "length" / "refusal" still return successfully with any partial
// output — use isIncompleteFinish(result.finishReason) when you need to treat
// them as incomplete rather than a clean success.
// streaming
for await (const event of cardan.stream({ model: "anthropic/claude-opus-5", messages })) {
if (event.type === "text_delta") process.stdout.write(event.text);
}
// tools (parameters accept plain JSON Schema or a zod 4 schema)
await cardan.generate({
model: "anthropic/claude-opus-5",
messages,
tools: [{ name: "get_weather", description: "…", parameters: { type: "object", properties: { city: { type: "string" } } } }],
});
// structured output — zod schemas are converted and the result validated via .parse()
const extracted = await cardan.generate({
model: "anthropic/claude-opus-5",
messages,
output: { schema: z.object({ name: z.string() }) },
});
console.log(extracted.output);
// built-in web search — the provider runs the searches server-side and the
// answer comes back with citations. `true` for defaults, or an options object.
const searched = await cardan.generate({
model: "anthropic/claude-opus-5",
messages,
webSearch: { maxUses: 5, allowedDomains: ["arxiv.org"] },
});
console.log(searched.message, searched.citations); // [{ url, title?, snippet? }, …]Per-provider use without the provider/ prefix:
import { AnthropicProvider } from "cardan";
const anthropic = new AnthropicProvider({ apiKey: "sk-…" });
await anthropic.generate({ model: "claude-opus-5", messages });collectStream(stream) accumulates a stream into a GenerateResult (message + finishReason + usage); collectStreamToMessage(stream) returns just the assistant Message, ready to push into the next request. See Reasoning / thinking state.
xAI Grok subscription (grok login)
Bill against a SuperGrok subscription instead of the pay-per-token API. Install the Grok CLI (https://x.ai/cli/install.sh), run grok login, then set the eyJ0… token from ~/.grok/auth.json as GROK_BUILD_OAUTH_TOKEN — createCardan() auto-wraps it into credentials, so xai/grok-4.5 just works:
export GROK_BUILD_OAUTH_TOKEN=$(jq -r '.[]|select(.key).key' ~/.grok/auth.json | head -1)For the refreshable flow (refresh_token, onRefresh, clientVersion) pass xaiOAuth / new XAIOAuthProvider(...). Design + wire details: docs/providers.md.
Conversation
cardan.conversation(options) returns a stateful Conversation that holds a running transcript and collapses "push user → generate → push assistant" into one ask. Every generation option (model, reasoning, tools, …) is a default on defaults, overridable per turn and reassignable mid-conversation — model is not privileged.
import { defineTool, type Infer } from "cardan";
import { z } from "zod";
const c = cardan.conversation({
model: "anthropic/claude-opus-5",
system: "You are a research assistant.",
reasoning: { effort: "high" }, // a default for every turn
label: "research", // tag for onCall telemetry
onCall: (i) => console.log(`${i.tag} ${i.model} ${i.ms}ms ${i.usage.output.total}tok ${i.finishReason ?? i.error}`),
});
// Tools: defineTool infers the handler args from the schema (no casts).
const search = defineTool(
{ name: "web_search", description: "Search the web.", parameters: z.object({ query: z.string() }) },
({ query }) => runSearch(query), // query: string
);
// With tools, ask loops model↔tools until it stops. `compact` then rewrites the
// round-trips so their bulky raw outputs aren't replayed on later turns — the
// default keeps the tool-use trace but blanks the result bodies.
await c.ask("Research the topic and conclude.", { tools: [search], compact: true, step: "research" });
// Structured output is just an option on ask: pass `output.schema` and read the
// parsed (zod-validated) value off the result — type it yourself if you want.
const res = await c.ask("Emit the final report as JSON.", { output: { schema: reportSchema } });
const report = res.output as Infer<typeof reportSchema>;
c.defaults.model = "openai/gpt-5.6-terra"; // switch model for all later turnsask adds a user turn, generates, appends the reply, returns the GenerateResult; with tools it loops (maxRounds caps it, forcing a tool-free conclusion on the last round). Structured output is a plain option (output.schema) — res.output holds the parsed value; cast it with Infer<typeof schema> for the static type. output and tools can't combine in one ask (structured output is constrained decoding, which blocks tool calls, so ask throws) — run the tool loop first, then ask again with output. cardan logs nothing itself: pass onCall for per-call telemetry (tag, model, ms, usage, citations, finishReason on success / error on failure).
compact keeps a tool-using turn from bloating later context. The default compactor (redactToolResults) keeps the tool-call/result structure — so the model still sees it reached the conclusion by using tools, not as innate knowledge — but replaces each result's payload with a short placeholder (this also keeps raw page content from tripping provider filters on replay). Pass your own Compactor ((region: Message[]) => Message[]) to customize, e.g. the built-in dropToolRounds (keep only the conclusion) or an LLM-written summary.
fork(overrides?) branches a conversation: the copy shares the client/defaults but gets an independent transcript, so diverging turns never touch the original — use it before fanning out in parallel (a shared mutable transcript would corrupt).
Agent
cardan.agent(spec) builds a reusable identity — { name, system?, model?, tools?, memory? } — layered over Conversation. It holds no runtime of its own; it builds conversations on demand.
run(input, opts?) runs one closed task: recall memory → ask (auto tool-loop if the agent has tools) → observe → return. Its usage is the accumulated total across every generate the run made (tool-loop rounds included), so it gives the task's full cost — unlike a bare ask, whose usage is only the last turn.
conversation(opts?) returns a fresh Conversation pre-configured with the agent's identity, to drive the turns yourself — mid-task or conditional steering is just ask between ifs. It does not apply memory (observe timing is undefined under manual driving).
const analyst = cardan.agent({
name: "analyst",
system: "You are a terse market analyst.",
model: "anthropic/claude-opus-5",
tools: [search],
memory, // optional; see below
});
const { text, usage } = await analyst.run("Summarize today's ETH moves.");
// or drive the turns yourself for mid-task steering:
const conv = analyst.conversation();
const draft = await conv.ask("Draft the thesis.");
if (offTrack(draft.text)) await conv.ask("Too broad — focus on L2 flows.");
const final = await conv.ask("Finalize it.");memory is what an agent carries between conversations (a transcript is within one). It's the lightest possible hook — { recall(): string; observe(result): void } — called by run (recall before, observe after); where and how to store is yours. No vector store. An agent without memory is a stateless identity.
Orchestration is ordinary async — there is no flow/graph layer to learn. Multi-step is await, branching is if, loops are while, and fan-out is parallel(items, fn, { concurrency, signal }) (concurrency-limited, order-preserving, fail-fast, cancellable). Give each branch its own agent/conversation (or conversation.fork()) so transcripts don't collide:
// screen → investigate N concurrently → publish
const picked = await screener.run(`Pick the noteworthy events:\n${format(candidates)}`, {
output: { schema: z.object({ ids: z.array(z.string()) }) },
});
const reports = await parallel((picked.output as { ids: string[] }).ids, async (id, _i, signal) => {
const conv = investigator.conversation();
await conv.ask(`Research ${id}.`, { tools: [search], compact: true, signal });
const res = await conv.ask("Emit the report.", { output: { schema: reportSchema }, signal });
return [id, res.output as Report] as const;
}, { concurrency: 4 }); // ≤ 4 in flight; signal threads into each ask
await publish(Object.fromEntries(reports));Pool
createPool({ members }) builds a PoolProvider — a Provider that rotates over several accounts of the same provider and fails over on transient errors. For multi-account credential rotation (e.g. several Claude.ai OAuth subscriptions), not cross-provider routing. Use it directly, or inject it: createCardan({ providers: { anthropic: pool } }).
members accepts bare provider instances; map your credentials straight into them. Use the { provider, weight?, label? } form only for a custom weight or label.
import { AnthropicProvider, createPool } from "cardan";
// one member per Claude setup-token (oauth accepts a bare token string)
const pool = createPool({
members: tokens.map((token) => new AnthropicProvider({ oauth: token })),
onFailover: (i) => log.warn(`switch ${i.fromLabel} → ${i.toLabel}: ${i.error.code}`),
});
await pool.generate({ model: "claude-opus-5", messages }); // routed to whichever member is up
// mix in weights / labels with the object form where needed
createPool({ members: [primary, { provider: backup, weight: 2, label: "backup" }] });A pool is a Provider, so it composes anywhere a provider is expected. Inject it under one slot of a Cardan while others stay single — the provider/model prefix routes transparently:
import { AnthropicProvider, OpenAIProvider, createCardan, createPool } from "cardan";
const cardan = createCardan({
providers: {
anthropic: createPool({ members: tokens.map((t) => new AnthropicProvider({ oauth: t })) }),
openai: new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }), // single
},
});
await cardan.generate({ model: "anthropic/claude-opus-5", messages }); // → the pool
await cardan.generate({ model: "openai/gpt-5.6-sol", messages }); // → the single providerA pool also nests: a PoolProvider can itself be a member of another pool (e.g. group several account pools), and the Conversation/Agent layers accept it wherever they accept a Cardan or provider.
A request can pin a member by label with poolMember (e.g. to keep a conversation on the account holding its prompt cache). A ready pinned member serves first; a cooling or unknown label falls back to normal rotation. The pool tracks the displacement as per-member debt and repays it on unpinned requests, so usage stays roughly balanced. The member that actually served is reported on result.poolMember / the finish event:
const res = await cardan.generate({ model: "anthropic/claude-opus-5", messages, poolMember: sticky });
sticky = res.poolMember; // re-pin to whoever served (pin may fall back on cooldown/failover)Telemetry
createCardan({ telemetry: { onRequest } }) observes every logical request at the routing layer — once per generate / stream / embed, after pool failover and per-attempt retries. No call-site instrumentation needed. Absent telemetry is a no-op.
const cardan = createCardan({
telemetry: {
onRequest(event) {
// event: { provider, model, op, ok, durationMs, usage?, errorCode?, status?, … }
metrics.record(event);
},
},
});provideris the routing prefix ("anthropic"even when that slot holds a pool);modelis the id without the prefix.- Success:
ok: true;generateincludesresult.usage;streamincludes thefinishevent's usage;embedomits usage. - Failure:
ok: falsewitherrorCode(andstatus/retryAfterMs/resetAtwhen the error is aCardanError); the original error is rethrown. - Stream abandon: if the consumer stops iterating before
finish, one event fires withok: trueand no usage (not treated as an error).durationMsstarts at the firstnext(). - Observer exceptions are swallowed so a broken sink cannot break requests.
This is separate from Conversation's per-ask onCall telemetry (turn-level, with labels/steps).
- Rotation: a fixed, evenly-interleaved round-robin built from member
weights (default 1); each request takes the next slot. - Failover: on a
rate_limit | auth | server | network | timeouterror it switches to the next distinct member and retries (the pool owns this retry, so per-attempt provider retry is disabled while ≥2 members are tried, and also on an all-cooling last-ditch attempt).maxFailoverscaps switches;shouldFailovercustomizes which errors qualify. Forstream, a switch is only possible before the first event. - Cooldown: a failed member is skipped on later requests until it recovers, scoped to the error's signal. An absolute
resetAt(exact, uncapped — e.g. Anthropic's account-wide subscription window reset, read from theanthropic-ratelimit-unified-resetheader) cools the whole member across every model. A relativeRetry-After(limit may be per-model, e.g. OpenAI TPM) cools only that model, so a 429 onopusdoesn't sidelinesonnet(capped bymaxCooldownMs, default 15 min). With neither signal, no cooldown (failover only) — a transient fault isn't necessarily an account problem. Cooled members thaw automatically when the deadline passes. - All cooling: if every member is cooling for the model, the pool tries the soonest-to-recover one as a last-ditch attempt (it may have reset early), then throws a
rate_limitCardanErrorwith the soonest recovery time (retryAfterMs+resetAt). The message names the model and member count only — never member labels (those may be internal env names). - Quota observability:
pool.rateLimits()returns each member's last-known quota snapshot ({ label, rateLimit }). Observation only — the pool won't sideline a member that still has quota; act on it yourself. Admin force-clear viapool.clearMemberLimits(label)thaws that member's cooldowns and drops its cached snapshot (Provider.clearRateLimit).
Behavior notes
- Message normalization (before every request): consecutive same-role messages merge;
tool_resultparts relocate into atoolmessage directly after theirtool_call, in call order; a danglingtool_callgets a synthesized error result (isError: true) so aborted conversations stay replayable; an orphan or duplicatetool_resultthrowsinvalid_request. - System messages: leading system messages hoist to the provider's top-level field (Anthropic
system, GeminisystemInstruction); mid-conversation ones downgrade to user text. OpenAI's Responses API acceptssystemanywhere, so they pass through in place. - Anthropic
oauth: pass{ oauth: { credentials: { accessToken, refreshToken?, expiresAt? }, onRefresh? } }to authenticate with a Claude.ai OAuth token instead ofapiKey, or a bare token string ({ oauth: token }) as shorthand for{ credentials: { accessToken: token } }— handy for aclaude setup-tokentoken. Sends Bearer auth, refreshes before expiry (persist the rotated token viaonRefresh), and retries once on 401/403 — skipping a redundant refresh if a concurrent request already rotated the token. A failingonRefreshis surfaced as a warning but never aborts the request (the in-memory token is valid; only the on-disk rotation is lost). On a subscription 429 the adapter reads the exact window reset from theanthropic-ratelimit-unified-resetheader intoCardanError.resetAt(epoch ms) — this gives a Pool precise per-account cooldowns, and works even for an inference-onlyclaude setup-token(unlike/api/oauth/usage, which needs theuser:profilescope). The same lifecycle backs xAI'sxaiOAuth. - OAuth tokens: env vs config: the env vars (
CLAUDE_CODE_OAUTH_TOKEN,GROK_BUILD_OAUTH_TOKEN) are consumed as a non-refreshable bearer — the token is sent verbatim and goes stale at its expiry (a refresh token can't be substituted; it is only valid at the token endpoint, not the inference API). For a durable env token use one built to be long-lived (claude setup-token). For automatic freshness, use the configoauth/xaiOAuthobject withrefreshToken+onRefreshinstead: refresh rotates the refresh token, which must be persisted to a writable store, so the file-backed config flow — not the read-only env path — is what keeps a long-running service fresh. File-backed members fromloadLocalOAuthre-read their credential file before each refresh and adopt externally rotated tokens (e.g. by the official CLI) without a token-endpoint call, so sharing the file with the CLI is safe. Caveat: two processes each refreshing against one credential file between reloads can still rotate out from under each other; keep a single owner of refresh where possible. - Subscription rate limit (
result.rateLimit): Anthropic's unified rate-limit headers ride on every response (no special scope), parsed into aRateLimitStatus— therepresentativewindow plus per-windowfiveHour/sevenDay(utilization,resetAt,status). Also on the streamfinishevent;provider.rateLimitkeeps the last-known snapshot (a live quota view, not a token accumulator — that'susage).undefinedfor API-key requests. Observation only — nothing acts on it (the Pool cools on real 429s); read per-account viapool.rateLimits(). - OpenAI is stateless by default: every request sends
store: false+include: ["reasoning.encrypted_content"]; context replays frommessagesand reasoning items survive multi-turn tool use viaencrypted_content(held inThinkingPart.signature, item id inThinkingPart.id). Override viaproviderOptions. The Responses API has no stop-sequence parameter, sostopSequencesis ignored. - Background mode (
background?: boolean, OpenAI Responses only): keeps long high-effort generations from dropping on idle-connection timeouts by decoupling execution from the HTTP connection.undefined(default) auto-enables it forhigh/xhigh/maxreasoning effort;true/falseforce it. It forcesstore: true(so not ZDR-compatible; data retained ~10 min):generatecreates the response then pollsGET /v1/responses/{id}to completion, andstreamtransparently resumes a dropped SSE viastarting_after. Other providers (including xAI) ignore the flag (use streaming there). Total time is bounded by yoursignal. - xAI speaks the same Responses API (its Chat Completions endpoint is legacy), so the adapter subclasses the OpenAI one and inherits the stateless defaults. Differences:
backgroundis never sent (xAI rejects it —Argument not supported: background),reasoning.effortacceptslow/medium/high(noneis rejected, so reasoning cannot be disabled — the field is omitted instead;xhigh/maxcap tohigh), nosummaryis sent (xAI always returns detailed reasoning summaries), grok models keeptemperature/top_p, and there is no embeddings API. - Groq speaks the Chat Completions API (
/openai/v1/chat/completions) — its Responses API is beta and rejects thestore/includethe stateless OpenAI adapter depends on. Reasoning models (gpt-oss, qwen3) always getreasoning_format: "parsed", so thinking arrives inmessage.reasoning→ thinking parts (no signature; never replayed).reasoning.effort→reasoning_effort: gpt-oss gradeslow/medium/high(xhigh/maxcap), qwen3 only acceptsnone/defaultso graded efforts are omitted;enabled: false→"none"(qwen3 only — gpt-oss can't disable reasoning). Omitreasoningfor non-reasoning models. Structured output sendsstrict: trueon gpt-oss, best-effort elsewhere; models withoutjson_schemasupport (llama-3.x) reject it. Prompt caching is automatic (cache_read); oversized prompts (413) map tocontext_length; no embeddings API. - Modal is for self-deployed models behind Modal web endpoints (vLLM/SGLang), which speak the Chat Completions API.
baseUrlis required (per-deployment*.modal.runURL; orMODAL_BASE_URL). Auth is optional and dual-track:apiKey→Authorization: Bearer(vLLM/SGLang--api-key;MODAL_API_KEY) and/orproxyAuth→Modal-Key/Modal-Secretheaders (Modal Proxy Auth Tokens;MODAL_KEY/MODAL_SECRET).reasoning_contentmaps to thinking parts; thinking is never replayed (Chat Completions has no replay format).reasoning.effort→reasoning_effort(caps athigh; unsupported servers reject it — omitreasoningthen),reasoning.enabledis ignored (useproviderOptions, e.g. vLLMchat_template_kwargs). Sendsmax_tokens;embedhits/v1/embeddingsif the deployment serves an embedding model. - Web search (
webSearch: boolean | WebSearchOptions): a first-class option, not aTool— it's server-side, so the provider runs the searches and returns a finished answer withcitations({ url, title?, snippet? }[], also on thefinishstream event).WebSearchOptions(maxUses,allowedDomains,blockedDomains,userLocation,contextSize) is the cross-provider subset; each adapter maps what it supports (provider-specific knobs viaproviderOptions). Routing: Anthropic/OpenAI/xAI server tools, Gemini Google Search grounding, Groq built-inbrowser_search(gpt-oss; incompatible with structured output) or automatic compound search. Requesting it on a model that can't do web search throwsinvalid_request(Modal never can). Anthropic'spause_turn(server tool-loop limit) is resumed transparently, so a single call still returns a finished turn. Citations are a normalized source list; provider-specific inline-span data stays inraw. - Usage:
input.totalincludes cached tokens; breakdown indetails(cache_read,cache_write,reasoning, andweb_search_requests— a billed request tally, not tokens). - Retry: 429/529/5xx/network errors retry with exponential backoff (default 2 retries), honoring
Retry-Afterup tomaxDelayMs(and Gemini'sRetryInfo.retryDelay). Anthropic subscription 429s that carry a windowresetAtare not retried (fail over or surface immediately). Disable withretry: false. Streams only retry before the first byte. - Timeout:
timeoutMs(per request, or a provider-option default — per-request wins) bounds each HTTP attempt; retries reset it, andundefined/0(default) means no timeout. A timeout aborts with a retryableCardanError(code: "timeout"), distinct from a caller-signalabort (code: "aborted", not retried). It bounds the wait until the response begins (headers arrive): for non-streaminggeneratethis effectively caps total generation time; forstreamit bounds connection setup only (bound a mid-stream stall withsignal). For a hard ceiling across retries, passsignal: AbortSignal.timeout(ms). - Capability table: models that reject sampling params (Fable 5 / Mythos 5 / Opus 4.7+, OpenAI o-series / non-chat gpt-5*) have
temperature/topPdropped silently; Gemini 3 mapsreasoning.efforttothinkingLevel, Gemini 2.x tothinkingBudget. reasoning:{ enabled: true }→ Anthropic adaptive thinking / GeminiincludeThoughts/ OpenAIreasoning.summary: "auto";effortis mapped per model (OpenAI: gpt-5.6 keeps distinctmax, Codex tops atxhigh, o-series athigh; xAI grok-4.5+ caps athigh; Anthropic adaptive passes effort through, older lines map tobudget_tokens).enabled: false→ OpenAIeffort: "none"(gpt-5.1+ only; o-series/Codex omit), Anthropicthinking: { type: "disabled" }where supported (Sonnet 5 needs it because adaptive is default-on; Fable/Mythos cannot disable). Provider-specific viaproviderOptions(e.g. beta headers in providerheaders).- Thinking parts: replayed with their
signature; unsigned ones are dropped on send;redacted: truemaps to Anthropicredacted_thinking. - Provider blocks: a response block with no generic mapping (Anthropic
server_tool_use/web_search_tool_result) becomes aProviderBlockPartholding the raw block, so a server-tool turn can be replayed exactly as it came back. Replayed only to the provider that produced it; other providers drop it. Only the Anthropic adapter emits them — it is the one that validates an assistant turn as a whole; OpenAI/Gemini validate replay state per item, so their unmapped blocks (web_search_call,executableCode, …) stay inrawonly. - Tool call ids: provider-assigned ids are preserved verbatim. Gemini 2.x omits function-call ids, so the adapter synthesizes
cardan_call_…ids for pairing and strips them on replay; GeminithoughtSignatures ride onsignatureof text/thinking/tool_call parts and are required for Gemini 3 function-calling replay. Replaying a tool call that lacks a signature (history carried over from another provider, or an unsigned parallel call) to a Gemini 3+ model injects the documentedskip_thought_signature_validatorsentinel so the call is accepted instead of rejected; Gemini 2.x, which neither requires nor validates signatures, is left untouched. - Gemini files: image/file input supports inline bytes (
inlineData) andURL→fileData.fileUripassthrough (Files API URIs); cardan does not wrap the File API.embedusesbatchEmbedContents, which returns no usage metadata. - Errors: all failures are
CardanErrorwithcode(auth/rate_limit/overloaded/context_length/invalid_request/not_found/server/network/timeout/aborted/unknown),status,retryable,retryAfterMs(relative, fromRetry-After),resetAt(absolute epoch ms, when the provider reports an exact reset), and the raw provider body inraw.
Reasoning / thinking state
Providers return opaque reasoning state that must be replayed verbatim for multi-turn / tool-use loops to keep working. cardan normalizes it onto ThinkingPart/TextPart/ToolCallPart and replays it to the same provider:
- Anthropic —
thinkingblocks carrysignature;redacted_thinkingcarries opaquedata(mapped tosignaturewithredacted: true). Both are replayed unchanged and in order; unsigned thinking is dropped on send. An assistant turn is replayed verbatim — thinking plus theProviderBlockParts holding its server-tool blocks — only while a client tool call is in flight (tool results follow it), because the API rejects a turn whose signed blocks lost their surrounding context; a completed turn replays as its visible content alone. - OpenAI / xAI — stateless by default (
store: false+include: ["reasoning.encrypted_content"]). The encrypted reasoning item is held inThinkingPart.signature, its id inThinkingPart.id; both are required to replay, so summary-only thinking (noencrypted_content) is dropped. For server-side state instead, passprevious_response_idviaproviderOptions. - Gemini — every
Part(text, thought, orfunctionCall) may carry athoughtSignature; it rides onsignatureand is sent back on the original Part. Signed Parts are never merged with each other or with unsigned Parts. Function-callids are preserved and echoed in the matchingfunctionResponse. UnsignedfunctionCallparts replayed to a Gemini 3+ model get theskip_thought_signature_validatorsentinel (required there; Gemini 2.x is left as-is).
Streaming and non-streaming preserve the same replay-critical state. Signatures, encrypted reasoning content, ids, and tool-call signatures all survive collection identically.
Use collectStream(stream) / collectStreamToMessage(stream) to capture a streamed turn — they reassemble the parts (including signatures) correctly. If you consume stream events yourself, retain the signature field on text_delta/thinking_delta deltas, thinking_signature events, and tool_call event signatures; dropping them loses reasoning state and breaks the next turn. Push the collected Message back into messages as-is — don't reduce a tool-use turn to its text.
Opaque state is provider-specific: replay a reasoning-bearing turn to the same provider that produced it. cardan does not strip foreign signatures, so feeding one provider's thinking parts to another sends invalid opaque state — start a fresh turn (or drop the thinking parts) when switching providers mid-conversation.
Development
npm install
npm run typecheck
npm test # fixture unit tests (no network)
npm run build