Skip to content

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:

  1. Native Function Calling (tools): Used by app.agent() and Gemini Live Voice to project bounded action spaces into provider-native tool declarations.
  2. 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 pathText / JSONImageAudio / videoDocuments / arbitrary files
ChatGoogle.generateReasoning()SupportedNot transportedNot transportedNot transported
ChatOpenAI.generateReasoning()SupportedNot transportedNot transportedNot transported
ChatAnthropic.generateReasoning()SupportedNot transportedNot transportedNot transported
app.agent().run()String message/history plus JSON semantic contextNot supportedNot supportedNot supported
Workflow start / .wait() inputJSON-compatible application data and media metadata/referencesNo binary/media semanticsNo binary/media semanticsNo binary/media semantics
@invariant-tech/live-geminiTool/action carrier onlyApplication-owned media connectionApplication-owned media connectionApplication-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@beta
bash
npm install @invariant-tech/google@beta @invariant-tech/openai@beta @invariant-tech/anthropic@beta
bash
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
});
ConstantModel IDStatusRecommended Use Case
GEMINI_3_7_FLASH'gemini-3.7-flash'Active — defaultComplex coding, agents, and multi-step execution
GEMINI_3_6_FLASH'gemini-3.6-flash'ActiveFast structured reasoning and tool execution; provider vision is not transported by this adapter
GEMINI_3_5_FLASH'gemini-3.5-flash'Legacy stableExisting Flash integrations; prefer 3.7 for new work
GEMINI_3_5_FLASH_LITE'gemini-3.5-flash-lite'ActiveHigh-volume structured extraction and classification
GEMINI_3_1_FLASH_LITE'gemini-3.1-flash-lite'ActiveCost-sensitive, high-throughput workloads
GEMINI_3_1_FLASH_LIVE'gemini-3.1-flash-live-preview'Active previewRealtime 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
});
ConstantModel IDStatusRecommended Use Case
GPT_5_6'gpt-5.6'Active — defaultFrontier reasoning, coding, and agent workflows
GPT_5_6_TERRA'gpt-5.6-terra'ActiveBalanced capability and cost
GPT_5_6_LUNA'gpt-5.6-luna'ActiveEfficient high-volume workloads
GPT_5_4_MINI'gpt-5.4-mini'ActiveFast structured extraction and tool use
GPT_4_1'gpt-4.1'ActiveNon-reasoning instruction following
GPT_4O'gpt-4o'Active legacyExisting 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
});
ConstantModel IDStatusRecommended Use Case
CLAUDE_FABLE_5'claude-fable-5'ActiveLong-running agent workflows
CLAUDE_SONNET_5'claude-sonnet-5'Active — defaultAgent turns, coding, and balanced production reasoning
CLAUDE_OPUS_5'claude-opus-5'ActiveHighest-capability complex reasoning
CLAUDE_OPUS_4_8'claude-opus-4-8'ActiveComplex reasoning on the Claude 4 generation
CLAUDE_SONNET_4_6'claude-sonnet-4-6'ActiveStable Sonnet 4 migration target
CLAUDE_HAIKU_4_5'claude-haiku-4-5-20251001'ActiveLow-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:

  1. Full IDE IntelliSense: Typing model names gives immediate autocomplete for all known models.
  2. 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:real

The 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

  1. Zero Vendor Lock-In: Swap from OpenAI to an in-house model without modifying any workflow DSL nodes or agent contracts.
  2. Uniform Validation Boundary: Workflow reasoning results pass through the declared runtime schema before REASON_COMPLETED can be committed. Provider failures become REASON_FAILED; the Beta does not schedule automatic retries.
  3. Community & Ecosystem: Teams can publish custom adapters to npm (e.g., invariant-adapter-groq, invariant-adapter-bedrock) as standalone modular packages.

Invariant Durable Execution Engine.