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"]
}
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"}
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 ablockReasonor afinishReasonsuch asSAFETYorRECITATION. OpenAI fills arefusalfield or ends withcontent_filter. Claude returnsstop_reason: "refusal". -
Cut off at the token limit. Gemini says
MAX_TOKENS, OpenAIfinish_reason: "length", Claudestop_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:
- 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.
- 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: '');
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)