No description
Find a file
2026-09-20 19:06:43 +02:00
example feat: routing is something the caller asks for, not a string they assemble 2026-09-20 15:53:05 +02:00
lib feat: the catalog says which models think, and how hard 2026-09-20 19:02:23 +02:00
test feat: the catalog says which models think, and how hard 2026-09-20 19:02:23 +02:00
.gitignore Initial release: agentic-first OpenRouter Dart library 2026-02-09 20:54:26 +01:00
analysis_options.yaml Initial release: agentic-first OpenRouter Dart library 2026-02-09 20:54:26 +01:00
CHANGELOG.md feat: the catalog says which models think, and how hard 2026-09-20 19:02:23 +02:00
CLAUDE.md Add InputAudioPart and AudioFormat for audio input support (v0.15.0) 2026-03-22 14:44:16 +01:00
LICENSE Initial release: agentic-first OpenRouter Dart library 2026-02-09 20:54:26 +01:00
pubspec.yaml feat: the catalog says which models think, and how hard 2026-09-20 19:02:23 +02:00
README.md feat: the catalog says which models think, and how hard 2026-09-20 19:02:23 +02:00

openrouter_dart

Agentic-first Dart library for the OpenRouter API.

The primary API is creating agents that chat, use tools, and reason — with the raw HTTP client available for power users.

Features

  • Agentic chat — automatic tool-calling loop with configurable max rounds
  • Typed tools — extend Tool<TParams> for type-safe parameter parsing, returns sealed ToolResult
  • Bundled tools — sandboxed ReadFileTool (with image support), WriteFileTool, SearchTool, ListDirectoryTool, DeleteTool, MoveTool, CopyTool, DiffTool, SqliteTool, MailTool, WebSearchTool, AgendaTool, CalendarTool, TodoTool, and SubAgentTool ready to use
  • Event system — real-time observability via onEvent callback + post-hoc RoundDetail on every response
  • Sub-agents — SubAgentTool lets the model spawn arbitrary sub-agents at call time, or use createSubAgent() for manual orchestration
  • Multimodal — send text, images, and audio in user, assistant, and tool messages; injectToolImages routes tool-result images through user role for provider compatibility; for non-vision models, supportsVision: false strips images, or an imageDescriber hook replaces them with text descriptions ("blind mode")
  • Reasoning — first-class support for models with extended thinking, including reasoning_details preservation across multi-turn tool loops
  • Video generation — async /videos API with submit-and-poll generateVideoAndWait, frame anchors, style references, and cached model discovery
  • Sealed Result — Result<T> with exhaustive match / map for error handling
  • Model variants — :nitro, :floor, :exacto, :free, :batch as a typed ModelId with builders, so a slug is never concatenated by hand
  • Provider routing — the full provider object typed end to end: quantization, sort, price ceilings, percentile thresholds, ZDR, plus top-level service tiers
  • Model listing — cached /models endpoint carrying what each model declares it can do (tools, modalities, reasoning and the effort tiers it takes), and /models/{author}/{slug}/endpoints for per-endpoint precision and pricing
  • Strict analysis — strict-casts and strict-raw-types enabled

Quick Start

import 'package:openrouter_dart/openrouter_dart.dart';

void main() async {
  final client = OpenRouterClient(
    options: OpenRouterClientOptions(apiKey: 'your-api-key'),
  );

  final agent = client.createAgent(
    model: const ModelId('anthropic/claude-sonnet-4'),
    systemPrompt: 'You are a helpful assistant.',
  );

  final result = await agent.chat('Hello!');
  result.match(
    onSuccess: (response) => print(response.content),
    onFailure: (error, _) => print('Error: $error'),
  );
}

Tool Calling

Define a tool by extending Tool<TParams>:

class WeatherParams {
  final String city;
  WeatherParams({required this.city});

  factory WeatherParams.fromJson(Map<String, dynamic> json) =>
      WeatherParams(city: json['city'] as String);
}

class GetWeatherTool extends Tool<WeatherParams> {
  @override
  String get name => 'get_weather';

  @override
  String get description => 'Gets current weather for a city';

  @override
  Map<String, dynamic> get parametersSchema => {
    'type': 'object',
    'properties': {
      'city': {'type': 'string', 'description': 'City name'},
    },
    'required': ['city'],
  };

  @override
  WeatherParams parseParameters(Map<String, dynamic> json) =>
      WeatherParams.fromJson(json);

  @override
  Future<ToolResult> execute(WeatherParams params) async {
    return ToolResult.text('Weather in ${params.city}: 22°C, sunny');
  }
}

Use it with an agent:

final agent = client.createAgent(
  model: const ModelId('anthropic/claude-sonnet-4'),
  tools: [GetWeatherTool()],
);

final result = await agent.chat('What is the weather in Tokyo?');
// The agent automatically calls the tool and returns the final response.

Bundled Tools

Fifteen tools are included out of the box — just point them at a directory, database, mailbox, calendar, or the web:

import 'dart:io';
import 'package:openrouter_dart/openrouter_dart.dart';

final workspace = Directory('/path/to/sandbox');

final agent = client.createAgent(
  model: const ModelId('anthropic/claude-sonnet-4'),
  systemPrompt: 'You are a helpful file assistant.',
  tools: [
    ReadFileTool(rootDirectory: workspace),
    WriteFileTool(rootDirectory: workspace),
    SearchTool(rootDirectory: workspace),
    DiffTool(rootDirectory: workspace),
  ],
);

// The agent can now read, write, search, and patch files — safely confined to workspace.
final result = await agent.chat('Find all TODO comments and write a summary.');
Tool Description
ReadFileTool Read files with optional line ranges (start_line/end_line) or pagination (offset/limit). Caps at 2000 lines, detects binary files. Reads images (PNG, JPEG, GIF, WebP) as multimodal content.
WriteFileTool Write files with four modes: overwrite, append, replaceRange, insertBefore. Auto-creates parent directories.
SearchTool Two modes: filename (glob patterns like *.dart, **/*_test.dart) and content (text or regex with surrounding context).
ListDirectoryTool List directory contents with optional recursion (configurable depth 1-10). Directories-first sorting, file sizes, [DIR] markers. 500-entry cap with truncation notice.
DeleteTool Delete files and directories. Optional recursive mode for non-empty dirs. Root-level _-prefixed directories are protected from deletion.
MoveTool Move or rename files and directories. Auto-creates destination parent directories. Root-level _-prefixed directories are protected from being moved.
CopyTool Copy files and directories (recursive). Auto-creates destination parent directories.
DiffTool Apply unified diffs to files. Parses @@ hunk headers, verifies context lines, tracks offsets across multiple hunks. Saves tokens vs rewriting entire files.
SqliteTool Query a database via a user-provided ToolDatabase implementation. Auto-detects read/write, formats results as tables, supports readOnly mode and maxRows cap.
MailTool Email via IMAP/SMTP with permission-gated capabilities. List, read, search, send, delete, mark, and move emails. Read-only by default — consumer opts in to send/delete/move/flag.
WebSearchTool Web search via DdgsSearchBackend (DuckDuckGo, Google, Bing, Brave). No API key needed. Optional region and time filters.
AgendaTool Jira-like agenda board for agent self-management. Epics, stories, tasks, improvements with status tracking, pinning, and history. Backed by ToolDatabase.
CalendarTool CalDAV calendar access with permission-gated capabilities. List calendars, list/read/search events, create, update, and delete. Works with any CalDAV server (Google Calendar, Nextcloud, mailbox.org, iCloud, Fastmail). Read-only by default.
TodoTool Lightweight in-memory todo list for multi-step task tracking within a conversation. set to plan, update to advance items (pending → in_progress → completed), list to read, clear to reset. Always returns the full list with progress count.
SubAgentTool Spawn arbitrary sub-agents at call time. The orchestrator model provides a system_prompt and message; a fresh agent runs autonomously and returns only its final answer.

All filesystem paths are validated by SandboxedPath — attempts to escape the root directory (via ../, absolute paths, or symlinks) are rejected.

See example/filesystem_tools_example.dart for a full multi-turn demo.

SqliteTool

SqliteTool requires a user-provided ToolDatabase implementation — this keeps the library free of native SQLite dependencies:

import 'package:sqlite3/sqlite3.dart'; // your choice of package
import 'package:openrouter_dart/openrouter_dart.dart';

class MyDatabase implements ToolDatabase {
  final Database _db;
  MyDatabase(this._db);

  @override
  List<Map<String, Object?>> select(String sql, [List<Object?> parameters = const []]) {
    final result = _db.select(sql, parameters);
    return result.map((row) => Map<String, Object?>.from(row)).toList();
  }

  @override
  int execute(String sql, [List<Object?> parameters = const []]) {
    _db.execute(sql, parameters);
    return _db.updatedRows;
  }
}

final agent = client.createAgent(
  model: const ModelId('anthropic/claude-sonnet-4'),
  tools: [
    SqliteTool(database: MyDatabase(sqlite3.open('app.db'))),
  ],
);

final result = await agent.chat('Show me the top 10 users by signup date.');

MailTool

MailTool gives agents email access via IMAP/SMTP using ImapSmtpBackend (powered by enough_mail). All write operations are disabled by default — opt in with MailPermission.allowed:

import 'package:openrouter_dart/openrouter_dart.dart';

final mailBackend = ImapSmtpBackend(
  imapHost: 'imap.gmail.com',
  imapPort: 993,
  smtpHost: 'smtp.gmail.com',
  smtpPort: 465,
  username: 'user@gmail.com',
  password: 'app-password',
);

// Read-only email assistant (default — can list, read, search)
final agent = client.createAgent(
  model: const ModelId('anthropic/claude-sonnet-4'),
  tools: [MailTool(backend: mailBackend)],
);

// Full-featured email assistant
final agent = client.createAgent(
  model: const ModelId('anthropic/claude-sonnet-4'),
  tools: [
    MailTool(
      backend: mailBackend,
      canSend: MailPermission.allowed,
      canDelete: MailPermission.allowed,
      canMove: MailPermission.allowed,
      canFlag: MailPermission.allowed,
      canCreateFolder: MailPermission.allowed,
    ),
  ],
);

final result = await agent.chat('Check my inbox for unread emails.');

The JSON schema dynamically adapts — models only see actions matching the enabled permissions. For testing, implement MailBackend directly instead of using ImapSmtpBackend.

WebSearchTool

WebSearchTool gives agents web search capabilities using DdgsSearchBackend (powered by ddgs) — no API key required:

import 'package:openrouter_dart/openrouter_dart.dart';

final searchBackend = DdgsSearchBackend(); // defaults to DuckDuckGo

final agent = client.createAgent(
  model: const ModelId('anthropic/claude-sonnet-4'),
  tools: [WebSearchTool(backend: searchBackend)],
);

final result = await agent.chat('What are the latest Dart language features?');

Use a different search engine backend:

final searchBackend = DdgsSearchBackend(backend: 'google');

For testing, implement WebSearchBackend directly instead of using DdgsSearchBackend.

AgendaTool

AgendaTool gives agents a Jira-like board for managing their own strategy, goals, and tasks. Backed by SqliteAgendaBackend using your ToolDatabase implementation:

import 'package:openrouter_dart/openrouter_dart.dart';

final agendaBackend = SqliteAgendaBackend(database: MyDatabase(db));

final agent = client.createAgent(
  model: const ModelId('anthropic/claude-sonnet-4'),
  tools: [
    AgendaTool(backend: agendaBackend, agentName: 'PlannerAgent'),
  ],
);

final result = await agent.chat('Create an epic for the new auth system.');

Agents can create entries (epics, stories, tasks, improvements), transition statuses, add comments, pin important items, and review full history. For testing, implement AgendaBackend directly instead of using SqliteAgendaBackend.

CalendarTool

CalendarTool gives agents calendar access via CalDAV using CaldavBackend (powered by the caldav package). Works with any CalDAV-compliant server — Google Calendar, Nextcloud, mailbox.org, iCloud, Fastmail, etc. All write operations are disabled by default — opt in with CalendarPermission.allowed:

import 'package:openrouter_dart/openrouter_dart.dart';

final calendarBackend = await CaldavBackend.connect(
  baseUrl: 'https://dav.mailbox.org',
  username: 'user@example.com',
  password: 'app-password',
);

// Read-only calendar assistant (default — can list, read, search)
final agent = client.createAgent(
  model: const ModelId('anthropic/claude-sonnet-4'),
  tools: [CalendarTool(backend: calendarBackend)],
);

// Full-featured calendar assistant
final agent = client.createAgent(
  model: const ModelId('anthropic/claude-sonnet-4'),
  tools: [
    CalendarTool(
      backend: calendarBackend,
      canCreate: CalendarPermission.allowed,
      canUpdate: CalendarPermission.allowed,
      canDelete: CalendarPermission.allowed,
    ),
  ],
);

final result = await agent.chat('What meetings do I have this week?');

The JSON schema dynamically adapts — models only see actions matching the enabled permissions. Encoded event IDs embed ETags for automatic optimistic locking on updates. For testing, implement CalendarBackend directly instead of using CaldavBackend.

TodoTool

TodoTool gives agents a lightweight in-memory todo list for tracking multi-step tasks within a single conversation. No backend or database required — state lives in the tool instance:

import 'package:openrouter_dart/openrouter_dart.dart';

final agent = client.createAgent(
  model: const ModelId('anthropic/claude-sonnet-4'),
  tools: [TodoTool()],
);

final result = await agent.chat('Refactor the auth module and track your progress.');

Actions:

Action Description
set Replace the entire list. Use for initial planning.
update Patch one item by 0-based index — change status, content, or active_form.
list Read the current list without modifying it.
clear Empty the list.

Every action returns the full list with a progress count, so the model always sees the latest state:

[2/4 completed]
  0. ● Analyze codebase
  1. ● Write unit tests
  2. ◐ Refactoring auth service
  3. ○ Update documentation

Statuses: pending (○), in_progress (◐), completed (●), cancelled (✕). The active_form field is an optional present-tense description shown while an item is in progress (e.g. "Refactoring auth service" instead of "Refactor auth service").

This tool is ephemeral — the list does not persist across agent instances or sessions. For durable, cross-session task management with history, use AgendaTool.

SubAgentTool

SubAgentTool lets the orchestrator model spawn arbitrary sub-agents during its tool-calling loop. The model decides each sub-agent's role by writing a system_prompt at call time — no pre-configured specialists needed.

This keeps the parent's context clean: the sub-agent's internal tool calls (searching, browsing, navigating, etc.) never appear in the parent's conversation.

final agent = client.createAgent(
  model: const ModelId('anthropic/claude-sonnet-4'),
  systemPrompt: 'You are an orchestrator. Delegate research to sub-agents.',
  tools: [
    SubAgentTool(
      client: client,
      model: const ModelId('anthropic/claude-haiku-4-5-20251001'),
      tools: [WebSearchTool(backend: searchBackend), WebBrowserTool(backend: browserBackend)],
      maxToolRounds: 15,
    ),
  ],
);

// The orchestrator model now calls sub_agent with arbitrary roles:
// { "system_prompt": "You are a web researcher. Find current info and summarize.", "message": "What is the weather in Paris?" }
// { "system_prompt": "You are a tech analyst. Compare frameworks objectively.", "message": "Compare React vs Svelte in 2026" }
final result = await agent.chat('Research the latest Dart language features and summarize them.');

For manual orchestration from application code, use createSubAgent() instead:

final subAgent = agent.createSubAgent(
  systemPrompt: 'You are a travel planner.',
  tools: [GetWeatherTool()],
);
final result = await subAgent.chat('Plan a trip to Paris.');

Model Variants

OpenRouter routes on suffixes appended to a model slug — :nitro for throughput, :floor for price, :free for the free-tier entry. ModelId makes them first-class, so nothing concatenates a slug by hand:

final agent = client.createAgent(
  model: const ModelId('openai/gpt-5.2').withNitro(),  // -> "openai/gpt-5.2:nitro"
);

ModelId is a const wrapper around the raw id — nothing is parsed until you ask — so it drops into a const request. An id is sent verbatim, so OpenRouter's request log matches what you wrote; only the builders rewrite it, by the minimum needed:

const ModelId('openai/gpt-5.2:nitro:exacto').value;      // unchanged
const ModelId('openai/gpt-5.2:floor').withNitro();       // ":nitro" — the dead sort goes
const ModelId('poolside/laguna-s-2.1:nitro').withFree(); // ":free:nitro"

The two kinds of variant behave differently, and the type keeps them apart:

Suffixes Listed in /models? Carried at once
Catalog — a different entry, with its own pricing and context :free, :batch yes at most one
Routing — the same model, routed differently :nitro, :floor, :exacto no any number

That distinction decides metadata lookups. catalogId keeps the catalog variant and drops the routing ones, because stripping :free would resolve to the paid entry and report its pricing and context window:

const id = ModelId('poolside/laguna-s-2.1:free:nitro');
id.catalogId;        // "poolside/laguna-s-2.1:free"
id.sortingVariant;   // ModelRoutingVariant.nitro

await client.listModelEndpoints(id);   // resolves catalogId for you

An unknown suffix is reported by validate() and refused by chatCompletion rather than sent — OpenRouter ignores a typo like :nirto instead of rejecting it, so the request would otherwise route at standard speed without a word. When OpenRouter ships a variant this library has not learned, ModelId.passthrough sends it as-is and skips that check.

:nitro is not just a sort

:nitro sorts by throughput and admits priority-tier endpoints; :floor does the same for price and flex endpoints. Neither is expressible as sort alone. To get the sort without exposure to priority rates, pin the standard tier:

final agent = client.createAgent(
  model: const ModelId('openai/gpt-5.2').withNitro(),
  serviceTier: ServiceTier.defaultTier,  // keep the sort, drop the tier admission
);

// ...and the result reports the tier that actually served it — what the round
// was billed at. Per-round tiers are on `RoundDetail.serviceTier`, since
// routing is decided per round.
final result = await agent.chat('How fast can you be?');
result.match(
  onSuccess: (response) => print(response.serviceTier),  // .defaultTier / .flex / .priority / null
  onFailure: (error, _) => print(error),
);

Provider Routing

ProviderPreferences covers OpenRouter's full provider object, typed end to end:

final agent = client.createAgent(
  model: const ModelId('anthropic/claude-sonnet-4'),
  models: [const ModelId('anthropic/claude-haiku-4')],  // fallback chain
  route: 'fallback',
  provider: const ProviderPreferences(
    sort: ProviderSort.throughput,
    dataCollection: DataCollectionPolicy.deny,
    quantizations: [Quantization.fp8, Quantization.bf16],
    zdr: true,
    maxPrice: MaxPrice(prompt: 3, completion: 15),
    preferredMinThroughput: PercentileCutoffs.median(100),  // tokens/sec at p50
  ),
);

order pins an explicit provider order instead of sorting — setting it makes sort moot, and also disables the tier admission a :nitro or :floor suffix would grant. sortPartition promotes sort to OpenRouter's {by, partition} object form when the fallback chain should sort as one pool, and PercentileCutoffs takes percentiles as well as a bare median — each serializes back to the simpler shape when that is all you used.

Precision

quantizations filters endpoints by the precision they serve weights at, and listModelEndpoints reports it back — both as the Quantization enum rather than raw strings. Two things about it surprise people:

  • The filter is hierarchical, not exact. fp4 also admits mxfp4 and nvfp4; fp8 also admits mxfp8. Use covers rather than ==, which would drop endpoints OpenRouter routes to anyway:

    final result = await client.listModelEndpoints(const ModelId('openai/gpt-5.2'));
    result.match(
      onSuccess: (model) {
        for (final e in model.endpoints) {
          if (Quantization.fp8.covers(e.quantization)) {
            print('${e.providerName} serves ${e.quantization?.apiString}');
          }
        }
      },
      onFailure: (error, _) => print(error),
    );
    
  • unknown is a real value, not an absence. It means "the provider declared no precision", and it describes most live endpoints — omit it from a filter and you exclude them all. A precision this library has not learned arrives as null instead, so an endpoint's reported value can never be fed back into a filter and silently widen it.

Observability

Real-time events

Pass an onEvent callback to observe every phase of the agent loop as it happens:

final agent = client.createAgent(
  model: const ModelId('anthropic/claude-sonnet-4'),
  tools: [GetWeatherTool()],
  onEvent: (event) {
    switch (event) {
      case CompletionStartEvent(:final round):
        print('[Round $round] Calling API...');
      case ToolStartEvent(:final toolName):
        print('  Running $toolName...');
      case ToolEndEvent(:final toolName, :final duration):
        print('  $toolName done (${duration.inMilliseconds}ms)');
      case _:
        break;
    }
  },
);

You can also pass onEvent per-call to override the agent-wide default:

await agent.chat('What is the weather?', onEvent: myCallback);

Post-hoc round details

Every AgentResponse includes structured rounds data — no callback required:

result.match(
  onSuccess: (response) {
    for (final round in response.rounds) {
      print('Round ${round.round}: ${round.toolExecutions.length} tools, '
          '${round.usage?.totalTokens ?? 0} tokens');
    }
  },
  onFailure: (error, _) => print(error),
);

Event types

Event Emitted when
RoundStartEvent A new round begins
CompletionStartEvent Just before the API call
CompletionEndEvent API response received (includes usage, model, duration, toolCallCount)
ToolStartEvent Before each tool executes
ToolEndEvent After each tool finishes (includes result, duration, isError)
RoundEndEvent Round complete (includes cumulativeUsage)

Raw Client Access

Use OpenRouterClient directly for full control:

// Chat completion
final result = await client.chatCompletion(ChatCompletionRequest(
  model: const ModelId('anthropic/claude-sonnet-4'),
  messages: [UserMessage(content: 'Hello')],
));

// List available models (cached for 5 minutes)
final models = await client.listModels();

Model capabilities

listModels reports what each model declares it can do. supportedParameters carries OpenRouter's supported_parameters verbatim — an open vocabulary of the provider's own parameter names, so it stays a list of strings — and the questions this library asks of it have getters: supportsTools, supportsReasoning and supportsReasoningEffort. The reasoning block describes how a model thinks.

final result = await client.listModels();
result.match(
  onSuccess: (models) {
    for (final m in models.where((m) => m.supportsReasoningEffort)) {
      final tiers = m.reasoning?.supportedEfforts;
      print('${m.name}: '
          '${tiers?.map((e) => e.apiString).join(', ') ?? 'any tier'}'
          '${m.reasoning?.mandatory == true ? ' (always thinks)' : ''}');
    }
  },
  onFailure: (error, _) => print(error),
);

Two things about it surprise people:

  • An omitted supported_efforts and a null one mean opposite things. OpenRouter documents an omitted key as "the model does not expose effort selection" and a present-but-null one as "all gateway effort values are accepted". ModelReasoning.exposesEffortSelection tells them apart, and accepts resolves all three states — so ask it rather than reading supportedEfforts yourself. It also refuses ReasoningEffort.none on a mandatory model, which the provider rejects outright.
  • Thinking and a dial for it are different capabilities. 136 of the 314 reasoning models in the live catalog expose no tier at all; they are steered by an on/off default, or by a token budget where supportsMaxTokens says so. The presence of a reasoning block is not the signal for whether a model reasons either: a few entries carry one while advertising no reasoning parameter, so supportsReasoning reads the parameter list instead.

Video Generation

OpenRouter's video API is asynchronous: a request submits a job, which is then polled until it completes. generateVideoAndWait handles the whole lifecycle; the raw steps (createVideo, getVideoJob, downloadVideoContent) are also exposed for manual control or webhook-based flows (callbackUrl).

// Discover models and their capabilities (cached for 5 minutes).
final models = (await client.listVideoModels()).valueOrDefault!;
final wan = models.firstWhere((m) => m.id == 'alibaba/wan-2.7');
print(wan.supportedResolutions); // [720p, 1080p]
print(wan.supportedDurations);   // [2, ..., 10]

// Text-to-video: submit, poll every 30s (OpenRouter's recommendation),
// and wait for completion (default timeout: 30 minutes).
final result = await client.generateVideoAndWait(
  VideoGenerationRequest(
    model: 'alibaba/wan-2.7',
    prompt: 'A red fox trotting through fresh snow at dawn',
    duration: 5,
    resolution: '720p',
    aspectRatio: '16:9',
  ),
  onStatus: (job) => print('status: ${job.rawStatus}'),
);

// Download the finished video.
final job = result.valueOrDefault!;
final bytes = (await client.downloadVideoContent(job.id)).valueOrDefault!;
await File('fox.mp4').writeAsBytes(bytes);
print('cost: \$${job.usage?.cost}');

Guide the generation with images — anchor frames (frameImages) and/or style references (inputReferences), depending on what the model supports (VideoModelInfo.supportedFrameImages):

// Image-to-video with a style reference: the first frame is pinned to
// one image, while a second image steers the overall look.
final startFrame = await File('start.png').readAsBytes();
final styleRef = await File('style.jpg').readAsBytes();

final result = await client.generateVideoAndWait(
  VideoGenerationRequest(
    model: 'alibaba/happyhorse-1.1',
    prompt: 'The landscape slowly comes alive as the camera pans right',
    duration: 8,
    resolution: '1080p',
    frameImages: [
      VideoFrameImage.fromBytes(
        bytes: startFrame,
        mimeType: 'image/png',
        frameType: VideoFrameType.firstFrame,
      ),
    ],
    inputReferences: [
      VideoReferenceImage.fromBytes(bytes: styleRef, mimeType: 'image/jpeg'),
      // Remote URLs work too — they must be directly downloadable
      // (no redirects, cookies, or bot checks):
      // VideoReferenceImage(url: 'https://example.com/style.jpg'),
    ],
  ),
);

Provider-specific parameters (a model's allowedPassthroughParameters, e.g. negative_prompt on Kling) nest under provider:

VideoGenerationRequest(
  model: 'kwaivgi/kling-v3.0-pro',
  prompt: '...',
  provider: {
    'options': {
      'kling': {
        'parameters': {'negative_prompt': 'blurry, low quality'},
      },
    },
  },
)

Configuration

final client = OpenRouterClient(
  options: OpenRouterClientOptions(
    apiKey: 'sk-or-...',
    baseUrl: 'https://openrouter.ai/api/v1', // default
    // Works with any OpenAI-compatible endpoint:
    // baseUrl: 'https://api.openai.com/v1',
    // baseUrl: 'https://api.z.ai/api/coding/paas/v4',
    // baseUrl: 'http://localhost:11434/v1', // Ollama
    timeout: Duration(seconds: 120),          // default
    maxImageBytes: 3 * 1024 * 1024,           // default — auto-compress images over 3 MB
    // Automatic retries for rate limits / 5xx / network errors (default shown).
    // Covers both HTTP errors and OpenRouter's "200 OK with a retryable error
    // body" (e.g. an upstream provider 429). Use RetryOptions.disabled() to opt out.
    retry: RetryOptions(
      maxAttempts: 3,                         // total tries, including the first
      initialDelay: Duration(seconds: 1),
      backoffMultiplier: 2.0,                 // waits 1s, 2s, 4s (±jitter)
      maxDelay: Duration(seconds: 30),
      retryableStatusCodes: {408, 429, 500, 502, 503, 504},
      honorRetryAfter: true,                  // prefer a Retry-After header when present
      jitterFactor: 0.2,
    ),
  ),
);

final agent = client.createAgent(
  model: const ModelId('anthropic/claude-sonnet-4'),
  systemPrompt: 'You are helpful.',
  tools: [GetWeatherTool()],
  parameters: ChatParameters(temperature: 0.7, maxTokens: 1000),
  reasoning: ReasoningOptions(effort: ReasoningEffort.high),
  responseFormat: ResponseFormat.jsonObject(),
  maxToolRounds: 10, // default
  injectToolImages: true, // route tool-result images through user role

  // Non-vision models: either strip images (default placeholder behavior)...
  supportsVision: false,
  // ...or describe them as text so the model can still "see" them ("blind
  // mode"). The describer should cache its own results.
  imageDescriber: (part) async => await describeWithVisionModel(part),
);

License

MIT