gemini_openai_claude
Structured JSON output from Gemini, OpenAI and Claude with one schema. Write the request once; each adapter converts it to its vendor's dialect and returns the decoded JSON object. Images supported. Errors come back as kinds your UI can act on. No vendor SDKs, one dependency (http). Works in Flutter and in plain Dart.
import 'package:gemini_openai_claude/gemini_openai_claude.dart';
final spec = PromptSpec(
instruction: 'Suggest a title and three tags for this note: "Fix the bike brakes before Sunday."',
schema: {
'type': 'object',
'properties': {
'title': {'type': 'string'},
'tags': {'type': 'array', 'items': {'type': 'string'}},
},
'required': ['title', 'tags'],
},
);
final AiAdapter ai = GeminiProvider(); // or OpenAiProvider(), ClaudeProvider()
final json = await ai.complete(spec: spec, apiKey: myKey);
print(json['title']);
Unofficial. Not affiliated with Google, OpenAI or Anthropic.
Why
The three vendors all support structured output, but each wants the same schema in a different dialect, and each fails differently:
| Gemini | OpenAI | Claude | |
|---|---|---|---|
| Schema types | STRING, OBJECT (upper case) |
lower case | lower case |
| Objects | as written | additionalProperties: false on every object |
additionalProperties: false on every object |
| Optional fields | as written | not allowed in strict mode: every property required, optional ones nullable (and null added to any enum) |
as written |
| Images | inline_data after the text |
image_url data URL |
base64 image blocks before the text |
PromptSpec holds the transforms (geminiSchema, sealedSchema, strictSchema, and the content shapes), so a fix lands for all three at once. The adapters stay thin.
The other half is failures that arrive as HTTP 200: a safety block, an answer cut off at the token limit, a refusal, an empty candidate list. Each adapter names them instead of reporting "the API may have changed".
Install
dart pub add gemini_openai_claude # or: flutter pub add gemini_openai_claude
Images
final spec = PromptSpec(
instruction: 'Read the total and the date on this receipt.',
schema: {
'type': 'object',
'properties': {
'total': {'type': 'number'},
'date': {'type': 'string'},
},
},
images: [
PromptImage(mime: 'image/jpeg', base64: base64Encode(bytes), label: 'receipt'),
],
);
Use JPEG or PNG. Convert HEIC first.
Errors
Every failure is a ProviderException with a kind, a user-facing message and the vendor's raw detail:
try {
final json = await ai.complete(spec: spec, apiKey: key, model: model);
} on ProviderException catch (e) {
switch (e.kind) {
case ProviderErrorKind.modelNotFound: // the model name was retired: ask for a current one
case ProviderErrorKind.invalidKey:
case ProviderErrorKind.missingKey:
openSettings();
case ProviderErrorKind.truncated: // hit the output limit: retrying usually fits
case ProviderErrorKind.busy: // vendor overloaded (5xx): wait and retry
showRetry(e.message);
default:
showError(e.message, details: e.detail);
}
}
| Kind | Typical cause |
|---|---|
missingKey · invalidKey |
No key, or the vendor rejected it |
forbidden |
Key is fine, but the API is not enabled on the project, or the region is blocked (Gemini answers 403) |
modelNotFound |
Model name retired or misspelled |
quota |
Rate limit or billing (429) |
busy |
Vendor overloaded (5xx, Claude 529) |
declined |
Safety block or refusal, reported as 200 |
truncated |
Output token limit reached |
emptyResult · badResponse |
Nothing usable came back |
network |
No connection, or no answer within providerTimeout (120 s) |
e.pointsAtSettings is true when the fix is a setting (key, model, account), so you know when to show a Settings button.
Messages are English. To translate:
ProviderException.localize = (kind, args) => switch (kind) {
ProviderErrorKind.missingKey => 'Ajoutez votre clé ${args['name']}.',
_ => null, // keep the English default
};
Models are a setting
Vendors retire model names roughly once a year. Each adapter has a defaultModel (GeminiProvider.defaultModel, …) used when you pass model: '', but let your users or your remote config change it. When a name stops working you get modelNotFound, not a crash.
Demo mode
DemoProvider answers on the device with no key and no network. Give the spec an offline answer built from the same inputs:
final spec = PromptSpec(
instruction: '...',
schema: schema,
demo: () => {'title': 'Bike brakes', 'tags': ['bike', 'repair', 'weekend']},
);
await const DemoProvider().complete(spec: spec, apiKey: '');
Use it for demo builds, screenshots and UI work.
Do not ship your API key in the app
Anything in an app binary can be extracted. Letting users paste their own key (stored in the Keychain / Keystore) is fine, and that is what these adapters are built for. If your key pays for the requests, put it on a server and have the app call your server; run these adapters there (the package is plain Dart) or use the vendor's server SDK.
Testing
Every adapter takes an http.Client, so tests run without a network:
final ai = GeminiProvider(
client: MockClient((req) async => http.Response(jsonEncode({
'candidates': [
{'content': {'parts': [{'text': '{"title":"ok","tags":[]}'}]}},
],
}), 200)),
);
FAQ
How do I get structured JSON output from Gemini, OpenAI and Claude with the same schema?
Write one JSON Schema in a PromptSpec. GeminiProvider sends it as responseSchema with upper-case types, OpenAiProvider as a strict json_schema with every property required, ClaudeProvider with additionalProperties: false on every object.
Why does OpenAI reject my schema with a 400?
Strict structured output requires every property in required and additionalProperties: false. Optional properties must be nullable, and a nullable enum must list null. strictSchema does all of that.
Why does Gemini return 403 with a valid key?
The Generative Language API is not enabled on the key's project, or the region is not supported. You get forbidden, not invalidKey.
Is this a chat SDK? No. One request, one structured answer. For chat and streaming use the vendor SDKs.
About
Extracted from the AI apps in the coderqh Flutter app templates. Issues and pull requests are welcome.
MIT licensed.
Libraries
- gemini_openai_claude
- Structured JSON output from Gemini, OpenAI and Claude with one schema.