instructor_dart 1.2.2 copy "instructor_dart: ^1.2.2" to clipboard
instructor_dart: ^1.2.2 copied to clipboard

Typed, validated structured outputs from LLMs. Build a JSON Schema in plain Dart, with validation and repair retries. Adapters for OpenAI, Anthropic and Gemini.

instructor_dart #

instructor_dart banner

Typed, validated structured outputs from LLMs.

A terminal run of the extraction example: a sentence goes in, llama3.2:3b
answers locally, and a typed Person(name: John Carmack, age: 55, city: Dallas) comes out rather than a Map

No model to hand? dart run example/no_model_demo.dart needs neither one nor a network. Its adapter answers from a script: the first answer puts the age outside the declared range, and you watch that rejection get written here and quoted back before the second answer arrives. That loop is the package.

Why this instead of what you already have #

Instead of parsing the reply yourself. A forced tool call, jsonDecode, and an if/else over the keys gets most of the way. Typing and repair are the tedious parts. IntegerSchema.normalize (lib/src/schema.dart:263) collapses 25.0 to 25 for an integer field, because jsonDecode('25.0') yields a double on the VM and an int on the web, and it leaves values past 2^53 alone rather than converting them lossily. On a violation, extract appends the model's own reply and a repair prompt to the transcript and asks again (lib/src/instructor.dart:155).

Instead of llm_schema. It validates the same kind of AI-generated JSON with a similar Zod-style builder and path-aware errors, and it is a real, adopted package, not a strawman: pure Dart, zero dependencies (pubspec.yaml lists none), published a month before this comparison was written. What it does not do is call a model. Its own README shows the retry as a hand-written for loop that calls callModel a second time and re-parses the reply (README.md, under "The repair loop"); nothing in its 1,206 lines of lib/ sends a request anywhere. Instructor.extract (lib/src/instructor.dart:65) is that same loop, already wired to an adapter — OpenAI, Anthropic, or Gemini — so a caller writes a schema and one call, not the retry itself.

Also newer, worth naming honestly. typed_llm reached pub.dev on 9 August 2026, three versions the same day, 0 likes and no 30-day download count yet (pub.dev/api/packages/typed_llm). It takes a third road: build_runner plus an @LlmSchema annotation that generates a .g.dart (README.md:66 and :100), where the schema here is a value you write at runtime with no build step. Its SchemaValidationException carries the final attempt's errors and a count (lib/src/exceptions.dart:42-46); ExtractionException.attempts (lib/src/instructor.dart:31) carries every attempt with its raw response.

Reach for it when

  • You are pulling fields out of unstructured text, such as an invoice or a scanned form, and the caller needs a typed object rather than a Map.
  • A wrong type or a missing required field should cost one more model call, not a crash three layers down.
  • You do not want a code generation step in the build.

Skip it if the model already returns clean JSON for your prompt and you are happy hand-checking two or three fields, since a schema is only worth writing once it is the thing doing the validating.

Define the shape of the data you want as a plain-Dart schema, call extract, and get back a validated Dart object. When the model returns data that does not match the schema, the validation errors are sent back to it and it gets another try.

No code generation, no build_runner. The schema is a value you write in Dart, and the same definition is used twice: sent to the provider as a tool signature, and used locally to validate what comes back.

import 'package:instructor_dart/instructor_dart.dart';

final instructor = Instructor(
  adapter: OpenAIAdapter(apiKey: apiKey, model: 'gpt-4o-mini'),
);
// An adapter given no http.Client creates and owns one. Close the
// Instructor when you are done with it; the call forwards to the adapter.
// A long-lived Instructor can simply live as long as the program.

final person = await instructor.extract(
  messages: const [Message.user('John Carmack is 55 and lives in Dallas.')],
  schema: Schema.object({
    'name': Schema.string(description: 'Full name'),
    'age': Schema.integer(min: 0, max: 130),
    'city': Schema.string().optional(),
  }),
  // Your model class, and your factory. The schema above describes the
  // shape; this turns the validated map into your type.
  fromJson: Person.fromJson,
);
// person is a Person. fromJson only runs after validation passed: every
// required field is present and correctly typed.

How it works #

  1. Your schema is rendered to JSON Schema and sent as a forced tool/function call, which makes the model answer with data, not prose.
  2. The response is validated locally against the same schema.
  3. On failure, the violations (with JSONPath locations) are appended to the conversation and the model retries, up to maxRetries times.
  4. If every attempt fails, ExtractionException carries the full attempt history: what the model said and why it was rejected.

A validated object is normalized to the Dart types its schema promises. An integer field is an int even when the model wrote 25.0, and a number field is a double even when the model wrote a whole number like 42, so json['age'] as int and json['price'] as double behave the same on the Dart VM and the web.

The one exception is an integral value beyond 2^53, where double can no longer represent every integer: those are left as a double rather than converted lossily, so as int would throw. If your field can hold a snowflake id or a nanosecond timestamp, read it as num and convert deliberately, or model it as a string.

Diagram of the extract loop: prompt and schema go to the model as a forced tool call, the reply is parsed and validated, a mismatch is fed back for a retry, and a valid reply becomes a typed Dart object

try {
  final result = await instructor.extractRaw(
    messages: messages,
    schema: schema,
    maxRetries: 2,
    onRetry: (attempt) => log('attempt ${attempt.number}: '
        '${attempt.violations.join('; ')}'),
  );
} on ExtractionException catch (e) {
  // e.attempts[i].rawResponse and .violations tell you exactly what
  // happened on each try.
}

Providers #

Adapter Works with
OpenAIAdapter OpenAI, and any OpenAI-compatible server: Ollama, LM Studio, vLLM, OpenRouter
AnthropicAdapter Anthropic Messages API
GeminiAdapter Gemini API generateContent
final adapter = GeminiAdapter(
  apiKey: Platform.environment['GEMINI_API_KEY']!,
  model: 'gemini-2.0-flash',
);

Gemini differs from the other two in two ways the adapter takes care of. Its contents only accepts the user and model roles, so an assistant message is sent as model; and system text is not a message at all, it goes in the top-level systemInstruction, where the adapter collects it. The schema is sent as a function declaration and forced with functionCallingConfig.mode: "ANY". The API key travels in the x-goog-api-key header rather than the key query parameter, which keeps it out of URLs and logs.

Local model via Ollama:

final adapter = OpenAIAdapter(
  apiKey: 'ollama', // any non-empty string
  model: 'llama3.2',
  baseUrl: 'http://localhost:11434/v1',
);

Note: some compatible servers, Ollama included, ignore tool_choice and may answer with plain text. Extraction still works: the JSON is parsed out of the text and validated the same way; a malformed answer costs one repair round.

Anything else: extend LlmAdapter (one method to override) and pass it to Instructor.

Schema reference #

Builder JSON Schema Constraints
Schema.string() string minLength, maxLength, pattern
Schema.integer() integer min, max
Schema.number() number min, max
Schema.boolean() boolean
Schema.enumeration([...]) string + enum
Schema.list(items) array minItems, maxItems
Schema.object({...}) object allowAdditionalProperties

Every builder takes a description; models read these when deciding what to put in each field, and short concrete descriptions improve results. Mark object properties with .optional() to leave them out of the required list. Objects reject unexpected keys by default.

Scope and roadmap #

This package does one thing: reliable typed extraction. It is not an agent framework and does not manage conversations, tools, or memory.

Planned: streaming partial results, MCP sampling support, server-side strict schema modes (OpenAI structured outputs, Anthropic strict tool use), and an optional bridge for json_serializable classes.

Credits #

The extract-validate-retry pattern follows the instructor library from the Python ecosystem, adapted to Dart idioms.

License #

MIT

0
likes
160
points
1.27k
downloads
screenshot

Documentation

API reference

Publisher

verified publisherdeveloperyusuf.com

Weekly Downloads

Typed, validated structured outputs from LLMs. Build a JSON Schema in plain Dart, with validation and repair retries. Adapters for OpenAI, Anthropic and Gemini.

Repository (GitHub)
View/report issues

Topics

#llm #structured-output #openai #anthropic #ollama

License

MIT (license)

Dependencies

http

More

Packages that depend on instructor_dart