DEV Community

Qihu Zhang for coderqh

Posted on Edited on Originally published at coderqh.com

Structured AI output in Flutter: Gemini, OpenAI, Claude

Short answer: all three APIs can return JSON that matches a schema you send. Each wants the schema in its own dialect: Gemini takes upper-case type names, OpenAI's strict mode needs every property required and additionalProperties: false, and Claude needs additionalProperties: false on every object. Write the schema once and convert it per vendor, or use gemini_openai_claude, which does the conversion and returns the decoded JSON.

If your app reads a receipt, tags a note or fills a form from a photo, you want an object back, not prose you have to parse. This guide shows how to get that from all three vendors with one schema, and how to handle the failures that look like success.

One request, three dialects

Here is the same small schema the way you would write it:

{
  "type": "object",
  "properties": {
    "title": { "type": "string" },
    "mood": { "type": "string", "enum": ["calm", "busy"] }
  },
  "required": ["title"]
}
Enter fullscreen mode Exit fullscreen mode

And what each vendor actually needs:

Gemini OpenAI (strict) Claude
Where the schema goes generationConfig.responseSchema response_format.json_schema with strict: true output_config.format
Type names STRING, OBJECT string, object string, object
additionalProperties: false not needed on every object on every object
Optional fields as written not allowed: list all in required, make the optional ones nullable as written
Optional enum as written must also list null as written
Images inline_data parts after the text image_url with a data: URL base64 image blocks before the text

Without OpenAI's strict: true, the schema is only a hint and the reply may not match it. With it, an optional property makes the request fail with a 400. That is why mood above becomes "type": ["string", "null"] with null added to its enum before it goes to OpenAI.

The important design choice is where these conversions live. If you write the Claude conversion inside your Claude code path, the other two keep working, your tests against the default provider pass, and the feature is quietly broken for everyone who switched. Keep the conversions next to the schema so one change reaches all three.

The code

import 'package:gemini_openai_claude/gemini_openai_claude.dart';

final spec = PromptSpec(
  instruction: 'Suggest a short title and a mood for this note: "Ship the release before Friday."',
  schema: {
    'type': 'object',
    'properties': {
      'title': {'type': 'string'},
      'mood': {'type': 'string', 'enum': ['calm', 'busy']},
    },
    'required': ['title'],
  },
);

final AiAdapter ai = switch (provider) {
  'gemini' => GeminiProvider(),
  'openai' => OpenAiProvider(),
  _ => ClaudeProvider(),
};
final json = await ai.complete(spec: spec, apiKey: key, model: model);
// {"title": "Friday release", "mood": "busy"}
Enter fullscreen mode Exit fullscreen mode

The package is plain Dart with one dependency (http). It calls the REST APIs directly, so no vendor SDK update can break it. For a photo, add images: [PromptImage(mime: 'image/jpeg', base64: base64Encode(bytes))] to the spec.

Failures that arrive as HTTP 200

Checking the status code is not enough. All three vendors can return 200 with no usable answer:

  • Safety block or refusal. Gemini returns no content, with a blockReason or a finishReason such as SAFETY or RECITATION. OpenAI fills a refusal field or ends with content_filter. Claude returns stop_reason: "refusal".
  • Cut off at the token limit. Gemini says MAX_TOKENS, OpenAI finish_reason: "length", Claude stop_reason: "max_tokens". The JSON looks almost valid and then stops mid-token.
  • Empty answer. A 200 with an empty candidate list.

If you parse these as normal replies, your app reports "the API may have changed", which sends you to read code instead of retrying. The adapters turn each case into a ProviderException with its own kind: declined, truncated or emptyResult.

The HTTP errors also deserve separate handling:

Status Usually means What to tell the user
401, or 400 mentioning the key Bad key Check the key
403 on Gemini API not enabled on the project, or region not supported Not a key problem
404 Model name retired or misspelled Choose a current model
429 Rate limit or billing Wait, or switch provider
5xx, 529 on Claude Vendor overloaded Try again shortly

Model names are a setting

Vendors retire model names roughly once a year. If the name is a constant in your app, the feature stops working the day it is retired and stays broken until you ship an update. Make the model a setting or a remote config value, and treat a 404 as "choose a current model", not as a crash.

Where the API key lives

Anything compiled into an app can be extracted, so do not ship your own API key in the binary. Two patterns are safe:

  1. Users bring their own key. They paste it into settings, you store it in the Keychain or Keystore, and the app calls the vendor directly. Good for tools aimed at developers and power users.
  2. Your server holds the key. The app calls your backend, and the backend calls the vendor. This is the only option when you pay for the requests. Because the package is plain Dart, the same adapters can run on a Dart server.

Testing without a network

Every adapter accepts an http.Client, so tests use MockClient and never call a vendor. DemoProvider answers on the device from an offline answer you attach to the spec, which is useful for demo builds and screenshots:

final spec = PromptSpec(
  instruction: '...',
  schema: schema,
  demo: () => {'title': 'Friday release', 'mood': 'busy'},
);
await const DemoProvider().complete(spec: spec, apiKey: '');
Enter fullscreen mode Exit fullscreen mode

FAQ

How do I get structured JSON output from Gemini, OpenAI and Claude with the same schema?
Write one JSON Schema and convert it per vendor: upper-case types for Gemini, strict mode with every property required for OpenAI, and additionalProperties: false on every object for Claude. The gemini_openai_claude package does this for you.

Why does OpenAI reject my schema with a 400?
In strict mode every property must be listed in required, every object needs additionalProperties: false, and an optional field must be nullable, including null in its enum.

Why does Gemini return 403 with a valid API key?
The Generative Language API is not enabled on the key's project, or the region is not supported. It is a project setting, not the key.

Is it safe to put my OpenAI or Gemini API key in a Flutter app?
No. Let users bring their own key, or call the vendor from your own server.

Do I need the vendor SDKs?
No. All three have REST APIs, and a thin HTTP client is easier to keep working across SDK releases.


gemini_openai_claude is open source on GitHub. It comes from the AI apps in our Flutter app templates.

Originally published at coderqh.com.

Top comments (0)