Model Adapters
Invariant provides first-class, lightweight model adapters for Google Gemini, OpenAI, and Anthropic Claude.
Adapters bridge Invariant workflows (.reason()) and conversational agents (app.agent()) to model providers with two distinct execution modes:
- Native Function Calling (
tools): Used byapp.agent()and Gemini Live Voice to project bounded action spaces into provider-native tool declarations. - Structured JSON Output (
schema): Used by workflow.reason()nodes to perform deterministic classification and extraction conforming to strict schemas.
Current Beta input boundary
The first-party ChatGoogle, ChatOpenAI, and ChatAnthropic adapters are text/JSON-input adapters, even when the configured provider model supports vision or other modalities. generateReasoning() accepts instruction, context, schema, and tools; each adapter serializes context into a text message. An image URL or base64 string placed inside context is therefore text, not a provider-native image part.
Do not pass File, Blob, Buffer, raw image bytes, or a provider file ID expecting visual understanding. First-class image transport is not required in the first-party v1 surface; it can be added later through a compatible PR or implemented today in an application-owned custom adapter.
Current Input Modality Matrix
| Public path | Text / JSON | Image | Audio / video | Documents / arbitrary files |
|---|---|---|---|---|
ChatGoogle.generateReasoning() | Supported | Not transported | Not transported | Not transported |
ChatOpenAI.generateReasoning() | Supported | Not transported | Not transported | Not transported |
ChatAnthropic.generateReasoning() | Supported | Not transported | Not transported | Not transported |
app.agent().run() | String message/history plus JSON semantic context | Not supported | Not supported | Not supported |
Workflow start / .wait() input | JSON-compatible application data and media metadata/references | No binary/media semantics | No binary/media semantics | No binary/media semantics |
@invariant-tech/live-gemini | Tool/action carrier only | Application-owned media connection | Application-owned media connection | Application-owned media connection |
Provider capability is broader than this adapter contract: current OpenAI models accept image inputs, Claude Messages accepts image content blocks, and Gemini generateContent accepts image/audio/video/document parts. Those provider features are not exposed by the current Invariant ModelAdapter interface. See the official OpenAI image-input API, Claude vision guide, and Gemini file-input methods.
If a Beta application needs vision now, use an application-owned .capability() or a custom/provider-specific adapter with an explicit storage, authorization, MIME/size, retention, and redaction policy. A custom adapter may interpret an application-defined media-reference field inside context and construct provider-native content blocks; Invariant passes that context through but does not validate or assign portable media semantics to the convention. Returning derived JSON into workflow state is supported; portable first-party multimodal reasoning is not part of the current compatibility promise.
Installation & Provider Types
Invariant model adapters execute lightweight non-streaming HTTP calls directly and re-export selected request/response types from the official provider SDKs (@google/genai, openai, @anthropic-ai/sdk). The Google packages also install @modelcontextprotocol/sdk, because @google/genai references that otherwise-optional peer from its public declarations; this keeps downstream compilation valid with skipLibCheck: false even when the application does not use MCP.
Official vendor types describe request construction at compile time; they do not validate network JSON. Each first-party adapter receives response.json() as unknown and checks the provider-specific object, content, candidate, tool-call, and usage fields before returning a canonical Invariant response. A malformed success payload is an adapter error, not trusted model output.
bash
pnpm add @invariant-tech/google@beta @invariant-tech/openai@beta @invariant-tech/anthropic@betabash
npm install @invariant-tech/google@beta @invariant-tech/openai@beta @invariant-tech/anthropic@betabash
yarn add @invariant-tech/google@beta @invariant-tech/openai@beta @invariant-tech/anthropic@beta**Core Design Philosophy: Zero Type Erasure**
"Infrastructure boundaries should preserve domain types, not erase them." You get compile-time IntelliSense and type-safety directly from the underlying vendor types without adding heavy runtime dependencies to your bundle.
1. Google Gemini (@invariant-tech/google)
Connects Invariant directly to Google Gemini models using secure header authentication (x-goog-api-key) and non-streaming REST generation.
Official Models Constant Map
ts
import { ChatGoogle, GEMINI_MODELS, GEMINI_MODEL_CATALOG } from '@invariant-tech/google';
export const modelAdapter = new ChatGoogle({
apiKey: process.env.GEMINI_API_KEY,
defaultModel: GEMINI_MODELS.GEMINI_3_7_FLASH, // Current default
});Recommended Active Models (GEMINI_MODELS)
| Constant | Model ID | Status | Recommended Use Case |
|---|---|---|---|
GEMINI_3_7_FLASH | 'gemini-3.7-flash' | Active — default | Complex coding, agents, and multi-step execution |
GEMINI_3_6_FLASH | 'gemini-3.6-flash' | Active | Fast structured reasoning and tool execution; provider vision is not transported by this adapter |
GEMINI_3_5_FLASH | 'gemini-3.5-flash' | Legacy stable | Existing Flash integrations; prefer 3.7 for new work |
GEMINI_3_5_FLASH_LITE | 'gemini-3.5-flash-lite' | Active | High-volume structured extraction and classification |
GEMINI_3_1_FLASH_LITE | 'gemini-3.1-flash-lite' | Active | Cost-sensitive, high-throughput workloads |
GEMINI_3_1_FLASH_LIVE | 'gemini-3.1-flash-live-preview' | Active preview | Realtime bidirectional voice and audio |
Gemini 2.5 constants remain active compatibility choices; Google currently announces no shutdown date for gemini-2.5-pro, gemini-2.5-flash, or gemini-2.5-flash-lite. GEMINI_MODEL_CATALOG exposes lifecycle metadata so applications can audit configured models. Verify changes in Google's model catalog and deprecation schedule before each release.
2. OpenAI (@invariant-tech/openai)
Connects Invariant to current OpenAI models with native tools and JSON schema mode.
ts
import { ChatOpenAI, OPENAI_MODELS, OPENAI_MODEL_CATALOG } from '@invariant-tech/openai';
export const openaiAdapter = new ChatOpenAI({
apiKey: process.env.OPENAI_API_KEY,
defaultModel: OPENAI_MODELS.GPT_5_6, // Current default
});Recommended Models (OPENAI_MODELS)
| Constant | Model ID | Status | Recommended Use Case |
|---|---|---|---|
GPT_5_6 | 'gpt-5.6' | Active — default | Frontier reasoning, coding, and agent workflows |
GPT_5_6_TERRA | 'gpt-5.6-terra' | Active | Balanced capability and cost |
GPT_5_6_LUNA | 'gpt-5.6-luna' | Active | Efficient high-volume workloads |
GPT_5_4_MINI | 'gpt-5.4-mini' | Active | Fast structured extraction and tool use |
GPT_4_1 | 'gpt-4.1' | Active | Non-reasoning instruction following |
GPT_4O | 'gpt-4o' | Active legacy | Existing Chat Completions integrations; this adapter sends text/JSON context only |
O1, O1_MINI, O3_MINI, and GPT_4_TURBO remain exported as deprecated source-compatibility constants. OPENAI_MODEL_CATALOG marks their lifecycle state and replacement. Check OpenAI's current model catalog before release.
3. Anthropic (@invariant-tech/anthropic)
Connects Invariant to active Claude models with native tool use and structured JSON output.
ts
import { ChatAnthropic, ANTHROPIC_MODELS } from '@invariant-tech/anthropic';
export const anthropicAdapter = new ChatAnthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
defaultModel: ANTHROPIC_MODELS.CLAUDE_SONNET_5, // Current default
});Recommended Active Models (ANTHROPIC_MODELS)
| Constant | Model ID | Status | Recommended Use Case |
|---|---|---|---|
CLAUDE_FABLE_5 | 'claude-fable-5' | Active | Long-running agent workflows |
CLAUDE_SONNET_5 | 'claude-sonnet-5' | Active — default | Agent turns, coding, and balanced production reasoning |
CLAUDE_OPUS_5 | 'claude-opus-5' | Active | Highest-capability complex reasoning |
CLAUDE_OPUS_4_8 | 'claude-opus-4-8' | Active | Complex reasoning on the Claude 4 generation |
CLAUDE_SONNET_4_6 | 'claude-sonnet-4-6' | Active | Stable Sonnet 4 migration target |
CLAUDE_HAIKU_4_5 | 'claude-haiku-4-5-20251001' | Active | Low-latency, high-volume classification |
Retired Claude 3 constants remain exported only as deprecated source-compatibility aliases; requests using them fail at Anthropic. ANTHROPIC_MODEL_CATALOG records their retired status and replacement. Check Anthropic's model lifecycle table before release.
4. Extensibility: Zero-Lock-In Custom Models
Each provider adapter owns its model catalog and uses the Autocomplete-Preserving Literal Union Pattern (KnownGoogleModelName | (string & {}), with equivalent OpenAI and Anthropic types). Deterministic @invariant-tech/core knows only string; it does not import provider product catalogs.
This means:
- Full IDE IntelliSense: Typing model names gives immediate autocomplete for all known models.
- Zero Block / Future-Proof: You can pass newly released models or custom fine-tuned endpoints without waiting for SDK updates or using
as any.
ts
// Custom fine-tuned model or provider-specific endpoint:
const customAdapter = new ChatGoogle({
defaultModel: 'my-company-gemini-finetune-v2', // Supported without `as any`
});
// The adapter owns its configured model/endpoint identity:
const endpointAdapter = new ChatGoogle({
defaultModel: 'projects/my-org/locations/us-central1/endpoints/custom-salon-v2',
});
const turn = await endpointAdapter.generateReasoning({
instruction: 'Classify salon booking intent',
context: { userMessage: 'I need a haircut tomorrow' },
});5. Registering Adapters in Invariant
Pass your model adapters when initializing invariant():
ts
import { invariant } from '@invariant-tech/sdk';
import { ChatGoogle, GEMINI_MODELS } from '@invariant-tech/google';
import { ChatOpenAI, OPENAI_MODELS } from '@invariant-tech/openai';
export const app = invariant({
models: {
default: new ChatGoogle({ defaultModel: GEMINI_MODELS.GEMINI_3_7_FLASH }),
reasoning: new ChatOpenAI({ defaultModel: OPENAI_MODELS.GPT_5_6_LUNA }),
},
session: { ... },
});
// Use in agents:
export const bookingAgent = app.agent('booking-agent', {
instructions: 'Interpret booking intent and use only the registered workflow.',
model: 'default',
workflows: [bookingWorkflow],
projection: ({ session }) => ({ customerTier: session.context.customerTier }),
});
// Use in workflow .reason() steps:
export const intentWorkflow = app.workflow('intent-classifier')
.reason('classify', {
model: 'reasoning',
instruction: 'Extract customer intent from message',
schema: IntentSchema,
});Registry keys route execution, while each adapter owns its provider model ID. Different .reason() nodes in the same workflow may therefore use different models or providers. A node without model uses the default registry entry; an explicit missing key produces REASON_FAILED instead of silently falling back. See the runnable per-node model routing example.
6. Live vs. Explicit Sandbox Mode
Provider adapters default to mode: "live". Missing credentials, network failures, and non-success provider responses throw; they are never converted into a fake successful model result. Workflow .reason() converts the failure into REASON_FAILED; conversational agent.run() rejects with AgentModelError and emits llm.call.failed without a synthetic reply.
Use sandbox mode only when a deterministic offline response is intentional:
ts
const offlineGoogle = new ChatGoogle({
defaultModel: GEMINI_MODELS.GEMINI_3_7_FLASH,
mode: "sandbox",
});For application tests, a dedicated RuntimeModelAdapter fixture that returns the exact schema under test is usually clearer than a provider sandbox.
Provider smoke tests are intentionally opt-in because they use credentials, network access, and billable model calls:
bash
GEMINI_API_KEY=... pnpm --filter @invariant-tech/google test:integration:real
OPENAI_API_KEY=... pnpm --filter @invariant-tech/openai test:integration:real
ANTHROPIC_API_KEY=... pnpm --filter @invariant-tech/anthropic test:integration:realThe release gate must execute all three commands without skipped tests. A provider mock, sandbox response, or successful typecheck is not a substitute for a live API response.
7. Building Custom Model Adapters (Ollama, DeepSeek, vLLM, Groq)
Invariant is open by contract. The core runtime does not enforce any specific LLM provider. Any developer or enterprise can connect any LLM (local models running in Ollama, an internal vLLM cluster in Kubernetes, DeepSeek, Groq, Mistral, or a fine-tuned endpoint) by implementing the ModelAdapter interface from @invariant-tech/core.
The ModelAdapter Contract
A custom adapter requires only a single method: generateReasoning(params):
ts
import type { ModelAdapter, ModelReasonParams, ModelReasonResponse } from '@invariant-tech/core';
export interface ModelAdapter<TModel extends string = string> {
readonly provider: string;
readonly defaultModel: TModel;
generateReasoning(params: ModelReasonParams): Promise<ModelReasonResponse>;
}Complete Implementation Example: ChatDeepSeek / Custom OpenAI-Compatible Endpoint
Many providers (DeepSeek, Groq, Ollama, Together AI, vLLM, LocalAI) expose an OpenAI-compatible REST endpoint. Here is a minimal adapter boundary; add provider-specific timeouts, retries outside the SDK retry contract, telemetry, and response hardening before production use:
ts
import type { ModelAdapter, ModelReasonParams, ModelReasonResponse } from '@invariant-tech/core';
// 1. Define model names (preserves IDE autocomplete while allowing any custom model)
export type DeepSeekModel = 'deepseek-reasoner' | 'deepseek-chat' | (string & {});
export class ChatDeepSeek implements ModelAdapter<DeepSeekModel> {
public readonly provider = 'deepseek';
public readonly defaultModel: DeepSeekModel;
private readonly apiKey: string;
private readonly baseUrl: string;
constructor(options?: { apiKey?: string; defaultModel?: DeepSeekModel; baseUrl?: string }) {
this.apiKey = options?.apiKey || process.env.DEEPSEEK_API_KEY || '';
this.defaultModel = options?.defaultModel ?? 'deepseek-reasoner';
this.baseUrl = options?.baseUrl ?? 'https://api.deepseek.com/v1';
}
async generateReasoning(params: ModelReasonParams): Promise<ModelReasonResponse> {
const modelToUse = this.defaultModel;
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: modelToUse,
messages: [
...(params.instruction ? [{ role: 'system', content: params.instruction }] : []),
{ role: 'user', content: JSON.stringify(params.context, null, 2) },
],
// Support structured JSON output mode when requested by .reason()
...(params.schema ? { response_format: { type: 'json_object' } } : {}),
// Support tool calling when requested by app.agent()
...(params.tools && params.tools.length > 0
? {
tools: params.tools.map(t => ({
type: 'function',
function: { name: t.name, description: t.description, parameters: t.parameters },
})),
}
: {}),
}),
});
if (!response.ok) {
throw new Error(`DeepSeek API error (${response.status}): ${await response.text()}`);
}
const data = (await response.json()) as {
choices?: Array<{
message?: {
content?: string;
tool_calls?: Array<{
function?: { name?: string; arguments?: string };
}>;
};
}>;
usage?: { prompt_tokens?: number; completion_tokens?: number };
};
const choice = data.choices?.[0]?.message;
return {
text: choice?.content || undefined,
result: params.schema && choice?.content ? JSON.parse(choice.content) : undefined,
toolCalls: (choice?.tool_calls ?? []).flatMap(({ function: fn }) =>
fn?.name
? [{
name: fn.name,
args: fn.arguments ? JSON.parse(fn.arguments) : {},
}]
: [],
),
usage: {
promptTokens: data.usage?.prompt_tokens ?? 0,
completionTokens: data.usage?.completion_tokens ?? 0,
},
};
}
}Adapters report proposals; they never choose which proposal receives authority. Preserve all provider tool calls in order. app.agent() executes zero when the provider returns more than one proposal and re-validates a single proposal against the authority frame captured before inference.
Local / On-Premise LLM Example: ChatOllama
For air-gapped or privacy-restricted environments (HIPAA / GDPR):
ts
import type { ModelAdapter, ModelReasonParams, ModelReasonResponse } from '@invariant-tech/core';
export class ChatOllama implements ModelAdapter<string> {
public readonly provider = 'ollama';
public readonly defaultModel: string;
private readonly baseUrl: string;
constructor(options?: { defaultModel?: string; baseUrl?: string }) {
this.defaultModel = options?.defaultModel ?? 'llama3.3:70b';
this.baseUrl = options?.baseUrl ?? 'http://localhost:11434';
}
async generateReasoning(params: ModelReasonParams): Promise<ModelReasonResponse> {
const response = await fetch(`${this.baseUrl}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: this.defaultModel,
system: params.instruction,
prompt: `Context:\n${JSON.stringify(params.context, null, 2)}`,
format: params.schema ? 'json' : undefined,
stream: false,
}),
});
const data = (await response.json()) as any;
return {
text: data.response,
result: params.schema && data.response ? JSON.parse(data.response) : undefined,
};
}
}Strategic Benefits of Invariant's Adapter Architecture
- Zero Vendor Lock-In: Swap from OpenAI to an in-house model without modifying any workflow DSL nodes or agent contracts.
- Uniform Validation Boundary: Workflow reasoning results pass through the declared runtime schema before
REASON_COMPLETEDcan be committed. Provider failures becomeREASON_FAILED; the Beta does not schedule automatic retries. - Community & Ecosystem: Teams can publish custom adapters to npm (e.g.,
invariant-adapter-groq,invariant-adapter-bedrock) as standalone modular packages.
8. Related Adapters & Roadmap
- Realtime Audio Adapter (@invariant-tech/live-gemini) — Realtime WebSocket voice and audio integration.
- MCP Server Adapter (Roadmap) — Model Context Protocol tool bridge integration.