- Dart 100%
|
|
||
|---|---|---|
| example | ||
| lib | ||
| test | ||
| .gitignore | ||
| analysis_options.yaml | ||
| CHANGELOG.md | ||
| CLAUDE.md | ||
| LICENSE | ||
| pubspec.yaml | ||
| README.md | ||
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 sealedToolResult - Bundled tools — sandboxed
ReadFileTool(with image support),WriteFileTool,SearchTool,ListDirectoryTool,DeleteTool,MoveTool,CopyTool,DiffTool,SqliteTool,MailTool,WebSearchTool,AgendaTool,CalendarTool,TodoTool, andSubAgentToolready to use - Event system — real-time observability via
onEventcallback + post-hocRoundDetailon every response - Sub-agents —
SubAgentToollets the model spawn arbitrary sub-agents at call time, or usecreateSubAgent()for manual orchestration - Multimodal — send text, images, and audio in user, assistant, and tool messages;
injectToolImagesroutes tool-result images throughuserrole for provider compatibility; for non-vision models,supportsVision: falsestrips images, or animageDescriberhook replaces them with text descriptions ("blind mode") - Reasoning — first-class support for models with extended thinking, including
reasoning_detailspreservation across multi-turn tool loops - Video generation — async
/videosAPI with submit-and-pollgenerateVideoAndWait, frame anchors, style references, and cached model discovery - Sealed Result —
Result<T>with exhaustivematch/mapfor error handling - Model variants —
:nitro,:floor,:exacto,:free,:batchas a typedModelIdwith builders, so a slug is never concatenated by hand - Provider routing — the full
providerobject typed end to end: quantization, sort, price ceilings, percentile thresholds, ZDR, plus top-level service tiers - Model listing — cached
/modelsendpoint carrying what each model declares it can do (tools, modalities, reasoning and the effort tiers it takes), and/models/{author}/{slug}/endpointsfor per-endpoint precision and pricing - Strict analysis —
strict-castsandstrict-raw-typesenabled
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.
fp4also admitsmxfp4andnvfp4;fp8also admitsmxfp8. Usecoversrather 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), ); -
unknownis 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 asnullinstead, 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_effortsand 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.exposesEffortSelectiontells them apart, andacceptsresolves all three states — so ask it rather than readingsupportedEffortsyourself. It also refusesReasoningEffort.noneon amandatorymodel, 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
supportsMaxTokenssays so. The presence of areasoningblock is not the signal for whether a model reasons either: a few entries carry one while advertising noreasoningparameter, sosupportsReasoningreads 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