llm_sdk 0.7.0
llm_sdk: ^0.7.0 copied to clipboard
Unified interface for talking to LLMs (Claude, OpenAI, Gemini) from Dart/Flutter: multi-provider, streaming, tool calling, structured outputs.
llm_sdk #
The unified toolkit for talking to any LLM from a Dart / Flutter app. One interface, several interchangeable brains.
On the web (JS) there is a polished toolkit for talking to LLMs (think Vercel AI
SDK). On the Dart / Flutter side, there was nothing equivalent — everyone
re-plumbs their own HTTP calls. llm_sdk is that clean bridge:
multi-provider, streaming, tool calling and structured outputs behind one
API.
Switching AI = changing one line #

Streaming, word by word #

Table of contents #
- Why llm_sdk
- Install
- Quick start
- Providers & configuration
- Local models (Ollama, LM Studio, llama.cpp, vLLM)
- The 4 building blocks
- Sampling options
- Escape hatches
- Retries & timeouts
- Error handling
- Resource cleanup
- API reference
- Architecture
- Status & limitations
- Testing
- License
Why llm_sdk #
| Building block | What it does |
|---|---|
| Multi-provider | The same "buttons" no matter which vendor is behind them. |
| Streaming | Show the answer word by word, live. |
| Tool calling | The AI asks to run functions; the SDK orchestrates the round-trip. |
| Structured outputs | The AI fills a typed Dart object instead of returning free-form text. |
The contract a provider must implement is just two methods
(generate, generateStream). All the usage logic — the tool loop,
streamText, generateObject — is built once in LlmClient, on top of
that contract. Switching from Claude to OpenAI to Gemini changes a single
line; nothing else in your code moves.
Install #
Add the dependency:
dart pub add llm_sdk
or, in a Flutter project:
flutter pub add llm_sdk
or add it manually to your pubspec.yaml:
dependencies:
llm_sdk: ^0.7.0
Then import it:
import 'package:llm_sdk/llm_sdk.dart';
The only runtime dependency is http, so the
package works anywhere Dart runs (CLI, server, Flutter mobile/desktop/web).
Requires Dart 3.4 or newer.
Quick start #
import 'dart:io';
import 'package:llm_sdk/llm_sdk.dart';
Future<void> main() async {
// Pick the brain. Switching AI = changing this one line.
final client = LlmClient(
ClaudeProvider(apiKey: Platform.environment['ANTHROPIC_API_KEY']!),
);
final answer = await client.generateText([
Message.system('You are a concise assistant.'),
Message.user('Give me one productivity tip.'),
]);
print(answer);
}
Tip: never hard-code API keys. Read them from environment variables (
Platform.environment['...']) or your app's secret storage.
Providers & configuration #
Every provider implements the same LlmProvider contract, so they are fully
interchangeable inside an LlmClient. They differ only in their constructor
options and default model.
Claude (Anthropic) #
final provider = ClaudeProvider(
apiKey: 'sk-ant-...', // required
model: 'claude-opus-5', // default
maxTokens: 1024, // default — required by Anthropic
);
OpenAI (and OpenAI-compatible servers) #
final provider = OpenAIProvider(
apiKey: 'sk-...', // optional — empty for local servers
model: 'gpt-5.6-terra', // default
maxTokens: null, // optional — null lets the model decide
baseUrl: 'https://api.openai.com/v1', // default — override for local models
);
Gemini (Google) #
final provider = GeminiProvider(
apiKey: 'AIza...', // required
model: 'gemini-3.8-flash', // default
maxTokens: null, // optional
baseUrl: 'https://generativelanguage.googleapis.com/v1beta', // default
);
Common options #
| Option | Type | Notes |
|---|---|---|
apiKey |
String |
Required for Claude & Gemini. Optional for OpenAI (empty for local servers). |
model |
String |
Model id. The default tracks each provider's current general-purpose model and may change in a minor release — pin it explicitly in production. |
maxTokens |
int / int? |
Required & defaults to 1024 on Claude; optional (null) on OpenAI & Gemini. |
baseUrl |
String |
Available on OpenAI & Gemini to point at a different endpoint. |
retry |
RetryPolicy |
Backoff retries + per-request timeout. See Retries & timeouts. |
extraHeaders |
Map<String, String> |
Extra HTTP headers, applied last. See Escape hatches. |
httpClient |
http.Client? |
Inject your own client (timeouts, proxy, tests). |
Swapping providers #
Because the surface is identical, swapping is a one-line change:
final client = LlmClient(ClaudeProvider(apiKey: myKey));
// ... or, without touching any other line:
final client = LlmClient(OpenAIProvider(apiKey: myKey));
final client = LlmClient(GeminiProvider(apiKey: myKey));
You can also tune the tool loop bound:
final client = LlmClient(provider, maxSteps: 8); // default is 5
Local models (Ollama, LM Studio, llama.cpp, vLLM) #
Any server that exposes an OpenAI-compatible API works out of the box: just
point baseUrl at your local endpoint. The API key is optional — a local
server ignores it.
final client = LlmClient(OpenAIProvider(
baseUrl: 'http://localhost:11434/v1', // Ollama
model: 'llama3.2',
));
All 4 building blocks (text, streaming, tool calling, structured outputs) stay
identical — only the baseUrl changes. No data leaves the machine.
Common endpoints:
| Server | baseUrl |
|---|---|
| Ollama | http://localhost:11434/v1 |
| LM Studio | http://localhost:1234/v1 |
| llama.cpp / vLLM | their own endpoint |
The 4 building blocks #
1. Text generation #
generate returns a full LlmResponse (text, usage, finish reason, tool
calls). generateText is the shortcut that returns the final string directly.
final response = await client.generate([
Message.user('Summarize relativity in one sentence.'),
]);
print(response.text); // the answer
print(response.usage?.totalTokens); // token count, if provided
print(response.finishReason); // FinishReason.stop, length, ...
Build conversations by stacking messages:
final messages = [
Message.system('You are a helpful translator.'),
Message.user('Translate "good morning" to French.'),
Message.assistant('Bonjour.'),
Message.user('And to Spanish?'),
];
final reply = await client.generateText(messages);
2. Streaming #
Stream the answer word by word for a typewriter effect:
await for (final chunk in client.streamText([Message.user('Tell me a joke')])) {
stdout.write(chunk); // typewriter effect
}
Need the raw event stream (text deltas, tool calls, and the assembled final
response)? Use streamEvents:
await for (final event in client.streamEvents([Message.user('Hi')])) {
switch (event) {
case TextDelta(:final text): stdout.write(text);
case ToolCallDelta(:final call): print('tool: ${call.name}');
case StreamDone(:final response): print('\nusage: ${response.usage}');
}
}
Streaming with tools
Both streaming methods take tools and run the same automatic tool loop as
generate. The model can narrate, ask for a tool, and keep streaming its final
answer — all inside one stream:
await for (final chunk in client.streamText(
[Message.user('Compare the weather in Douala and Yaoundé.')],
tools: [weather], // ← runs automatically, mid-stream
)) {
stdout.write(chunk);
}
TextDelta and ToolCallDelta are forwarded for every step, so a UI can
show "running getWeather…" as it happens. StreamDone is emitted once, at
the very end — never per step — and its usage is the sum across all steps,
so a tool round-trip doesn't go uncounted (generate sums the same way).
Bounded by maxSteps, exactly like generate. If you want raw single-step
access with no loop, call provider.generateStream(...) directly.
3. Tool calling #
Declare a tool (name, description, JSON-schema parameters, and the function to run). The SDK orchestrates the whole round-trip automatically.
final weather = Tool(
name: 'getWeather',
description: 'Returns the current weather for a city',
parameters: {
'type': 'object',
'properties': {'city': {'type': 'string'}},
'required': ['city'],
},
run: (args) async => '29 °C and humid in ${args['city']}',
);
final response = await client.generate(
[Message.user('What is the weather in Douala?')],
tools: [weather],
);
print(response.text); // "It's 29 °C and humid in Douala."
The SDK loops automatically (bounded by maxSteps): the AI asks →
getWeather runs → the result is sent back to the AI → final answer. You can
register multiple tools; the model picks which to call (and may call several).
The same loop runs on the streaming path — see Streaming with tools.
When a tool fails
By default a tool that throws does not abort the run: the error is handed
back to the model as a tool result (flagged isError, which Claude carries on
the wire as is_error), so it can retry differently, pick another tool, or tell
the user the information is unavailable. The turn you already paid for isn't
lost. The same applies when the model asks for a tool you didn't register.
Because a swallowed exception is a silent failure, pass onToolError to keep a
trace — it fires in both policies:
final client = LlmClient(
provider,
onToolError: (toolName, error, stack) =>
log.warning('tool $toolName failed', error, stack),
);
Want the old behaviour — the exception reaching you and the run aborting?
final client = LlmClient(provider, toolErrors: ToolErrorPolicy.throwToCaller);
The original exception is rethrown with its stack trace intact.
Parallel tool calls
When the model requests several tools in one turn, they run concurrently — providers emit parallel calls on purpose, and running them in series just adds up the latencies. Results are always re-injected in call order. If your tools share state that doesn't tolerate concurrency:
final client = LlmClient(provider, parallelTools: false);
Controlling tool use
toolChoice constrains how the model arbitrates tools. All three providers
support the same four cases, each in its own dialect:
ToolChoice |
Meaning | Claude | OpenAI | Gemini |
|---|---|---|---|---|
| omitted | provider default | — | — | — |
ToolChoice.auto |
the model decides | {type: auto} |
"auto" |
{mode: AUTO} |
ToolChoice.none |
no tools this turn | {type: none} |
"none" |
{mode: NONE} |
ToolChoice.any |
must call some tool | {type: any} |
"required" |
{mode: ANY} |
ToolChoice.named('x') |
must call x |
{type: tool, name: x} |
{type: function, …} |
{mode: ANY, allowed_function_names: [x]} |
// Answer in prose, even though tools are registered.
await client.generateText(messages, tools: tools, toolChoice: ToolChoice.none);
// Force a tool on the first turn, then let the model answer.
await client.generateText(messages, tools: tools, toolChoice: ToolChoice.any);
toolChoice applies to the first step only. The tool loop then lets the
provider arbitrate normally. Without that, any or a named tool would force a
tool call on every turn, so the loop would never reach a final answer and
would run straight into maxSteps.
Forced tool use (
anyandnamed) isn't accepted by every model — Claude Fable 5.1 returns a 400 — whileautoandnonework everywhere. This is also what limits structured outputs on those models, since they are built on a forced tool.
4. Structured outputs #
Make the model fill a typed "form" instead of returning free-form text. You
provide a JSON schema and a fromJson constructor; the model's tool arguments
are the object.
class Invoice {
final String client;
final double amount;
Invoice(this.client, this.amount);
factory Invoice.fromJson(Map<String, dynamic> j) =>
Invoice(j['client'] as String, (j['amount'] as num).toDouble());
}
final invoice = await client.generateObject<Invoice>(
[Message.user('Invoice for Metchera, 1,250 EUR incl. tax.')],
schema: {
'type': 'object',
'properties': {
'client': {'type': 'string'},
'amount': {'type': 'number'},
},
'required': ['client', 'amount'],
},
fromJson: Invoice.fromJson,
);
print(invoice.client); // "Metchera"
print(invoice.amount); // 1250.0
No runtime reflection in Flutter: the JSON schema and the
fromJsonare manual in v1. Codegen via annotations is planned for later.
Sampling options #
Tune temperature, topP and stopSequences with a single
GenerationOptions, accepted by every LlmClient method. Each provider maps
it to its own dialect, so the same object works everywhere. Any field left
null is omitted — the model's default applies.
final answer = await client.generateText(
[Message.user('Write a haiku about Dart.')],
options: const GenerationOptions(
temperature: 0.9,
topP: 0.95,
stopSequences: ['\n\n'],
),
);
maxTokens belongs here too, so the cap can change per request — the provider's
own maxTokens stays the default:
await client.generateText(
[Message.user('One word, please.')],
options: const GenerationOptions(maxTokens: 16),
);
It works the same on generate, streamText, streamEvents and
generateObject.
Escape hatches #
No wrapper models everything, and a missing field shouldn't mean forking the package. Three doors out, all opt-in and all deliberately not portable — they speak each provider's own dialect:
| Hatch | Where | For |
|---|---|---|
GenerationOptions.providerOptions |
request body | anything the SDK doesn't model: seed, frequency_penalty, thinking, safetySettings, cache_control… |
extraHeaders |
provider constructor | beta headers, an organization id, your own proxy's auth |
LlmResponse.raw |
response | citations, logprobs, safetyRatings, cache token counts… |
final answer = await client.generateText(
messages,
options: const GenerationOptions(
temperature: 0.5,
providerOptions: {'generationConfig': {'seed': 42}}, // Gemini dialect
),
);
providerOptions is deep-merged into the body: the example above adds
seed to the generationConfig the SDK built, rather than replacing it. At an
equal key with a non-map value, your value wins — including over the SDK's own.
That is the point of a door out, and also its risk.
extraHeaders is applied last, so it can override the SDK's headers. That's
what makes it possible to route through your own backend instead of shipping an
API key inside a published app:
final provider = OpenAIProvider(
baseUrl: 'https://my-backend.example/llm', // your proxy holds the real key
extraHeaders: {'authorization': 'Bearer $userSessionToken'},
);
raw carries the provider's decoded JSON body. It is null on the streaming
path — a response assembled from dozens of SSE chunks has no single body to
expose.
Retries & timeouts #
Every provider is created with a RetryPolicy. By default the generate path
retries transient failures — HTTP 408/429/5xx, timeouts and dropped
connections — with jittered exponential backoff, and every request is bounded by
a timeout. Streaming applies the connection timeout only (replaying a started
stream is unsafe).
When a rate-limited response carries a Retry-After header, that wins over
the backoff: only the provider knows when its window reopens. Both header forms
are read — a number of seconds, or an HTTP date. If the server asks for longer
than maxRetryAfter, the SDK stops retrying and hands the response back, rather
than blocking your request for minutes.
The backoff is jittered: each delay is drawn uniformly from
[nominal × (1 - jitter), nominal]. Without it, every client that hits the same
429 retries on the same millisecond and re-saturates the service.
final provider = ClaudeProvider(
apiKey: myKey,
retry: const RetryPolicy(
maxRetries: 3, // extra attempts after the first
initialDelay: Duration(milliseconds: 500),
backoffFactor: 2.0, // 0.5s, 1s, 2s, ... before jitter
timeout: Duration(seconds: 30),
maxRetryAfter: Duration(seconds: 30), // cap on a server-dictated wait
),
);
// Opt out entirely:
final noRetry = OpenAIProvider(apiKey: myKey, retry: RetryPolicy.none);
| Option | Default | Meaning |
|---|---|---|
maxRetries |
2 |
Extra attempts after the first (0 disables). |
initialDelay |
400 ms |
Nominal delay before the first retry. |
backoffFactor |
2.0 |
Multiplier applied between attempts. |
jitter |
0.5 |
Random spread, 0.0–1.0. 0 gives a strictly deterministic backoff. |
timeout |
60 s |
Per-request deadline before a TimeoutException. |
retryStatusCodes |
{408, 429, 500, 502, 503, 504} |
Statuses treated as transient. |
respectRetryAfter |
true |
Honour a Retry-After header over the backoff. |
maxRetryAfter |
60 s |
Longest server-dictated wait accepted; beyond it, stop retrying. |
random |
null |
Inject a seeded Random to make the jitter deterministic in tests. |
RetryPolicy.parseRetryAfter(header) is public if you need to read the header
yourself — it returns null for a missing or unparseable value.
Error handling #
When a provider returns a non-200 HTTP status, or a response cannot be parsed,
the SDK throws an LlmException:
try {
final answer = await client.generateText([Message.user('Hello')]);
print(answer);
} on LlmException catch (e) {
print('Provider error ${e.statusCode}: ${e.body}');
}
| Field | Type | Meaning |
|---|---|---|
statusCode |
int |
HTTP status from the provider (0 if the error isn't network-level). |
body |
String |
Raw response body, or an error message. |
A StateError is thrown if the tool loop hits maxSteps without a final
answer, or if the model requests a tool you didn't register.
Streaming errors #
A stream always ends with either a StreamDone or an error — never
quietly. Two subclasses of LlmException cover what can go wrong mid-stream,
so a single on LlmException catch still catches everything:
| Exception | When | Extra |
|---|---|---|
LlmStreamErrorException |
The provider announced an error during generation (HTTP was already 200): overload, rate limit, safety stop. | body holds the provider's raw payload. |
LlmStreamInterruptedException |
The stream ended before its terminal marker — dropped connection, server closing early. | partial holds the response assembled so far. |
try {
await for (final chunk in client.streamText([Message.user('Tell me a joke')])) {
stdout.write(chunk);
}
} on LlmStreamInterruptedException catch (e) {
// Keep what arrived, tell the user it was cut short, offer a retry.
print('\n[truncated after ${e.partial.text.length} chars]');
} on LlmStreamErrorException catch (e) {
print('\n[provider failed mid-answer: ${e.body}]');
}
Fragments already yielded stay valid in both cases — they are simply
incomplete. Terminal markers per provider: message_stop (Claude), [DONE]
(OpenAI), and the last chunk's finishReason (Gemini, which has no dedicated
end marker).
Resource cleanup #
Each provider owns an internal http.Client. If you create a provider
manually (rather than injecting one), close it when you're done:
final provider = ClaudeProvider(apiKey: myKey);
// ... use it ...
provider.close();
For long-lived apps you usually create the provider once and keep it for the process lifetime — no need to close per request.
API reference #
LlmClient #
| Member | Signature | Description |
|---|---|---|
| constructor | LlmClient(LlmProvider provider, {int maxSteps = 5, ToolErrorPolicy toolErrors = ToolErrorPolicy.feedToModel, bool parallelTools = true, ToolErrorCallback? onToolError}) |
Wraps a provider. maxSteps bounds the tool loop; see When a tool fails and Parallel tool calls. |
generate |
Future<LlmResponse> generate(List<Message>, {List<Tool> tools, ToolChoice? toolChoice}) |
Full response, with automatic tool loop. |
generateText |
Future<String> generateText(List<Message>, {List<Tool> tools, ToolChoice? toolChoice}) |
Convenience: returns the final text. |
streamText |
Stream<String> streamText(List<Message>, {List<Tool> tools, ToolChoice? toolChoice}) |
Text chunks, word by word, with automatic tool loop. |
streamEvents |
Stream<LlmStreamEvent> streamEvents(List<Message>, {List<Tool> tools, ToolChoice? toolChoice}) |
Typed event stream, with automatic tool loop. One StreamDone at the end. |
generateObject |
Future<T> generateObject<T>(List<Message>, {required Map schema, required T Function(Map) fromJson, String description}) |
Typed structured output. |
Every method above also takes an optional GenerationOptions options (see
Sampling options).
Core types #
| Type | Purpose |
|---|---|
Message |
A conversation turn. Factories: Message.system/user/assistant(text). |
Part (sealed) |
TextPart, ToolCallPart, ToolResultPart. Put a _ case in your switches — see below. |
Tool |
A callable tool: name, description, parameters (JSON schema), run. |
ToolChoice |
auto / none / any / named(x) — how tools are arbitrated. |
ToolErrorPolicy |
feedToModel (default) or throwToCaller, for a tool that throws. |
ToolErrorCallback |
void Function(String toolName, Object error, StackTrace) — observe tool failures. |
LlmResponse |
message, text, toolCalls, usage, finishReason, raw. |
Usage |
inputTokens, outputTokens, totalTokens. |
FinishReason |
stop, length, toolUse, contentFilter, unknown. |
LlmStreamEvent (sealed) |
TextDelta, ToolCallDelta, StreamDone. |
LlmException |
Thrown on provider/HTTP errors. Base class of the two below. |
LlmStreamErrorException |
The provider errored mid-stream (HTTP was 200). |
LlmStreamInterruptedException |
The stream was cut before its end marker; carries partial. |
Architecture #
The contract boils down to two methods (generate, generateStream) that
each provider implements. All usage logic — the tool loop, streamText,
generateObject — is built once in LlmClient, on top of the contract.
Providers stay thin: they only translate to/from their own dialect.
LlmClient ── tool loop (Future *and* streamed), streamText, generateObject
│
└── LlmProvider (contract: generate + generateStream)
├── ClaudeProvider ✅ (text, tools, structured outputs, SSE streaming)
├── OpenAIProvider ✅ (same, Chat Completions dialect + local endpoints)
└── GeminiProvider ✅ (same, generateContent dialect)
Status & limitations #
Current version: 0.7.0
- ✅ Provider-agnostic core: types, contract,
LlmClient(tool loop,generateObject,streamText). - ✅ All 3 adapters (Claude, OpenAI, Gemini) complete:
generate, tool calling, structured outputs (via forced tool), SSE streaming. - ✅ Local models via the OpenAI adapter (overridable
baseUrl, optional key): Ollama, LM Studio, llama.cpp, vLLM. - ✅ Sampling options (
temperature,topP,stopSequences) across all providers. - ✅ Retries + timeouts with exponential backoff (
RetryPolicy) on thegeneratepath. - ✅ No silent streaming failures: a stream always ends with a
StreamDoneor an error (LlmStreamErrorException/LlmStreamInterruptedException). - ✅ Streaming + tools together: the automatic tool loop runs on the streaming path too.
- ✅ Resilient tool loop: a failing tool is handed back to the model instead
of aborting the run, tools in one turn run concurrently, and
usageis summed across every step on both paths. - ✅ Rate-limit aware retries:
Retry-After(seconds or HTTP date) is honoured over the backoff, which is itself jittered. - ✅ Full tool arbitration:
ToolChoice(auto / none / any / named) across all three dialects, applied to the first step of the loop. - ✅ Escape hatches: per-request
maxTokens,providerOptionsmerged into the body,extraHeaders, andLlmResponse.raw. - ✅ 112 tests (mocked client logic + round-trip/SSE for all 3 providers +
options/retry + streaming error/interruption + streamed tool loop end-to-end
through each provider's dialect + tool-error / usage / concurrency +
Retry-After/ jitter + tool-choice mapping / escape-hatch coverage). - 🎯 All 4 building blocks work across the 3 providers with no change to the core — the abstraction is the product, and it held.
- ⬜ Out of scope for v1: embeddings, vision/audio, cost tracking, caching, multi-step agents beyond the tool loop.
Switching on sealed types #
Part and LlmStreamEvent are sealed so that adapters are forced to handle
every variant — they encode to the wire, nothing may slip through. The trade-off
is on your side: new variants will appear (image, audio, reasoning), and an
exhaustive switch over today's variants would then fail to compile.
You pick which behaviour you want, and the two are mutually exclusive:
Forward-compatible — match only the variants you act on and close with _.
Nothing breaks on upgrade:
switch (event) {
case TextDelta(:final text): stdout.write(text);
case StreamDone(:final response): print(response.usage);
case _: break; // ToolCallDelta + future variants
}
Told about new variants — enumerate them all and omit _. A new variant
then becomes a compile error pointing at the switch, which is what you want when
every kind of content must be handled deliberately:
switch (event) {
case TextDelta(:final text): stdout.write(text);
case ToolCallDelta(:final call): print('tool: ${call.name}');
case StreamDone(:final response): print(response.usage);
}
Don't combine the two: covering every variant and adding _ makes the
analyzer flag the _ as unreachable_switch_case.
LlmResponse.text and LlmResponse.toolCalls don't switch at all, and are the
better choice when you only need those.
Migrating from 0.6.x #
The only breaking change is on the LlmProvider contract, so code that uses
LlmClient needs no change. Update your own adapters if you wrote any:
Future<LlmResponse> generate(
List<Message> messages, {
List<Tool> tools = const [],
- String? forceTool,
+ ToolChoice? toolChoice,
GenerationOptions? options,
});
Stream<LlmStreamEvent> generateStream(
List<Message> messages, {
List<Tool> tools = const [],
+ ToolChoice? toolChoice,
GenerationOptions? options,
});
forceTool: 'x' becomes toolChoice: ToolChoice.named('x'), and the three other
cases are newly expressible.
Known limitations #
- On OpenAI,
maxTokensis sent asmax_tokens; current models expectmax_completion_tokens, and the reasoning tiers rejecttemperature/topP. - The same
Tool.parametersisn't accepted everywhere: Gemini takes only an OpenAPI subset, OpenAI strict mode has its own rules. No schema sanitiser yet. - Prompt caching is neither settable nor measurable (
Usagehas no cache counters) — the biggest cost lever still on the table. generateObjectdoesn't validate against your schema or retry a bad parse.- On Flutter Web,
package:http's browser client buffers the whole response, sostreamTextarrives in one chunk instead of streaming. - No embeddings, no vision/audio parts, no request cancellation.
Testing #
dart pub get
dart test
License #
See LICENSE.
