No description
Find a file
2026-08-13 10:13:16 +02:00
example Add ToolResult sealed class and image reading support (v0.4.0) 2026-02-09 22:10:25 +01:00
lib fix: make puppeteer force-kill compile on web targets 2026-08-13 06:14:28 +02:00
test feat: maxInlineImages — describe all but the newest N history images for vision models 2026-07-20 17:18:11 +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: maxInlineImages — describe all but the newest N history images for vision models 2026-07-20 17:18:11 +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: maxInlineImages — describe all but the newest N history images for vision models 2026-07-20 17:18:11 +02:00
README.md Address review: terminal states, guaranteed poll, docs example 2026-07-14 22:41:20 +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-agentsSubAgentTool 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 ResultResult<T> with exhaustive match / map for error handling
  • Model listing — cached /models endpoint
  • Strict analysisstrict-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: '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: '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: '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: '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: 'anthropic/claude-sonnet-4',
  tools: [MailTool(backend: mailBackend)],
);

// Full-featured email assistant
final agent = client.createAgent(
  model: '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: '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: '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: 'anthropic/claude-sonnet-4',
  tools: [CalendarTool(backend: calendarBackend)],
);

// Full-featured calendar assistant
final agent = client.createAgent(
  model: '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: '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: 'anthropic/claude-sonnet-4',
  systemPrompt: 'You are an orchestrator. Delegate research to sub-agents.',
  tools: [
    SubAgentTool(
      client: client,
      model: '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.');

Observability

Real-time events

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

final agent = client.createAgent(
  model: '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: 'anthropic/claude-sonnet-4',
  messages: [UserMessage(content: 'Hello')],
));

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

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