Skip to content

feat(firebaseai): add responseJsonSchema to GenerationConfig #17564

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions packages/firebase_ai/firebase_ai/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,17 @@ import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';

import 'pages/audio_page.dart';
import 'pages/bidi_page.dart';
// Import after file is generated through flutterfire_cli.
// import 'package:firebase_ai_example/firebase_options.dart';

import 'pages/audio_page.dart';
import 'pages/bidi_page.dart';
import 'pages/chat_page.dart';
import 'pages/document.dart';
import 'pages/function_calling_page.dart';
import 'pages/image_prompt_page.dart';
import 'pages/imagen_page.dart';
import 'pages/json_schema_page.dart';
import 'pages/schema_page.dart';
import 'pages/token_count_page.dart';
import 'pages/video_page.dart';
Expand Down Expand Up @@ -64,11 +65,11 @@ class _GenerativeAISampleState extends State<GenerativeAISample> {
void _initializeModel(bool useVertexBackend) {
if (useVertexBackend) {
final vertexInstance = FirebaseAI.vertexAI(auth: FirebaseAuth.instance);
_currentModel = vertexInstance.generativeModel(model: 'gemini-1.5-flash');
_currentModel = vertexInstance.generativeModel(model: 'gemini-2.5-flash');
_currentImagenModel = _initializeImagenModel(vertexInstance);
} else {
final googleAI = FirebaseAI.googleAI(auth: FirebaseAuth.instance);
_currentModel = googleAI.generativeModel(model: 'gemini-2.0-flash');
_currentModel = googleAI.generativeModel(model: 'gemini-2.5-flash');
_currentImagenModel = _initializeImagenModel(googleAI);
}
}
Expand Down Expand Up @@ -184,10 +185,12 @@ class _HomeScreenState extends State<HomeScreen> {
case 6:
return SchemaPromptPage(title: 'Schema Prompt', model: currentModel);
case 7:
return DocumentPage(title: 'Document Prompt', model: currentModel);
return JsonSchemaPage(title: 'JSON Schema', model: currentModel);
case 8:
return VideoPage(title: 'Video Prompt', model: currentModel);
return DocumentPage(title: 'Document Prompt', model: currentModel);
case 9:
return VideoPage(title: 'Video Prompt', model: currentModel);
case 10:
return BidiPage(
title: 'Live Stream',
model: currentModel,
Expand Down Expand Up @@ -302,6 +305,11 @@ class _HomeScreenState extends State<HomeScreen> {
label: 'Schema',
tooltip: 'Schema Prompt',
),
BottomNavigationBarItem(
icon: Icon(Icons.data_object),
label: 'JSON',
tooltip: 'JSON Schema',
),
BottomNavigationBarItem(
icon: Icon(Icons.edit_document),
label: 'Document',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// Copyright 2025 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:firebase_ai/firebase_ai.dart';
import '../widgets/message_widget.dart';

class JsonSchemaPage extends StatefulWidget {
const JsonSchemaPage({super.key, required this.title, required this.model});

final String title;
final GenerativeModel model;

@override
State<JsonSchemaPage> createState() => _JsonSchemaPageState();
}

class _JsonSchemaPageState extends State<JsonSchemaPage> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _textController = TextEditingController();
final FocusNode _textFieldFocus = FocusNode();
final List<MessageData> _messages = <MessageData>[];
bool _loading = false;

void _scrollDown() {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(
milliseconds: 750,
),
curve: Curves.easeOutCirc,
),
);
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ListView.builder(
controller: _scrollController,
itemBuilder: (context, idx) {
return MessageWidget(
text: _messages[idx].text,
isFromUser: _messages[idx].fromUser ?? false,
);
},
itemCount: _messages.length,
),
),
Padding(
padding: const EdgeInsets.symmetric(
vertical: 25,
horizontal: 15,
),
child: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: !_loading
? () async {
await _promptJsonSchemaTest();
}
: null,
child: const Text('JSON Schema Prompt'),
),
),
],
),
),
],
),
),
);
}

Future<void> _promptJsonSchemaTest() async {
setState(() {
_loading = true;
});
try {
final content = [
Content.text(
'Generate a widget hierarchy with a column containing two text widgets ',
),
];

final jsonSchema = {
r'$defs': {
'text_widget': {
r'$anchor': 'text_widget',
'type': 'object',
'properties': {
'type': {'const': 'Text'},
'text': {'type': 'string'},
},
'required': ['type', 'text'],
},
},
'type': 'object',
'properties': {
'type': {'const': 'Column'},
'children': {
'type': 'array',
'items': {
'anyOf': [
{r'$ref': '#text_widget'},
{
'type': 'object',
'properties': {
'type': {'const': 'Row'},
'children': {
'type': 'array',
'items': {r'$ref': '#text_widget'},
},
},
'required': ['type', 'children'],
}
],
},
},
},
'required': ['type', 'children'],
};

final response = await widget.model.generateContent(
content,
generationConfig: GenerationConfig(
responseMimeType: 'application/json',
responseJsonSchema: jsonSchema,
),
);

var text = const JsonEncoder.withIndent(' ')
.convert(json.decode(response.text ?? '') as Object?);
_messages.add(MessageData(text: '```json$text```', fromUser: false));

setState(() {
_loading = false;
_scrollDown();
});
} catch (e) {
_showError(e.toString());
setState(() {
_loading = false;
});
} finally {
_textController.clear();
setState(() {
_loading = false;
});
_textFieldFocus.requestFocus();
}
}

void _showError(String message) {
showDialog<void>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Something went wrong'),
content: SingleChildScrollView(
child: SelectableText(message),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text('OK'),
),
],
);
},
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and
// limitations under the License.

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:firebase_ai/firebase_ai.dart';
import '../widgets/message_widget.dart';
Expand Down Expand Up @@ -132,13 +134,14 @@ class _SchemaPromptPageState extends State<SchemaPromptPage> {
),
);

var text = response.text;
_messages.add(MessageData(text: text, fromUser: false));

if (text == null) {
if (response.text == null) {
_showError('No response from API.');
return;
} else {
final text = const JsonEncoder.withIndent(' ')
.convert(json.decode(response.text!) as Object?);
_messages
.add(MessageData(text: '```json\n$text\n```', fromUser: false));
setState(() {
_loading = false;
_scrollDown();
Expand Down
26 changes: 25 additions & 1 deletion packages/firebase_ai/firebase_ai/lib/src/api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -998,8 +998,10 @@ final class GenerationConfig extends BaseGenerationConfig {
super.responseModalities,
this.responseMimeType,
this.responseSchema,
this.responseJsonSchema,
this.thinkingConfig,
});
}) : assert(responseSchema == null || responseJsonSchema == null,
'responseSchema and responseJsonSchema cannot both be set.');

/// The set of character sequences (up to 5) that will stop output generation.
///
Expand All @@ -1018,8 +1020,28 @@ final class GenerationConfig extends BaseGenerationConfig {
///
/// - Note: This only applies when the [responseMimeType] supports
/// a schema; currently this is limited to `application/json`.
///
/// Only one of [responseSchema] or [responseJsonSchema] may be specified at
/// the same time.
final Schema? responseSchema;

/// The response schema as a JSON-compatible map.
///
/// - Note: This only applies when the [responseMimeType] supports a schema;
/// currently this is limited to `application/json`.
///
/// This schema can include more advanced features of JSON than the [Schema]
/// class taken by [responseSchema] supports. See the [Gemini
/// documentation](https://ai.google.dev/api/generate-content#FIELDS.response_json_schema)
/// about the limitations of this feature.
///
/// Notably, this feature is only supported on Gemini 2.5 and later. Use
/// [responseSchema] for earlier models.
///
/// Only one of [responseSchema] or [responseJsonSchema] may be specified at
/// the same time.
final Map<String, Object?>? responseJsonSchema;

/// Config for thinking features.
///
/// An error will be returned if this field is set for models that don't
Expand All @@ -1036,6 +1058,8 @@ final class GenerationConfig extends BaseGenerationConfig {
'responseMimeType': responseMimeType,
if (responseSchema case final responseSchema?)
'responseSchema': responseSchema.toJson(),
if (responseJsonSchema case final responseJsonSchema?)
'responseJsonSchema': responseJsonSchema,
if (thinkingConfig case final thinkingConfig?)
'thinkingConfig': thinkingConfig.toJson(),
};
Expand Down
35 changes: 35 additions & 0 deletions packages/firebase_ai/firebase_ai/test/api_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import 'dart:convert';

import 'package:firebase_ai/firebase_ai.dart';
import 'package:firebase_ai/src/api.dart';

Expand Down Expand Up @@ -442,6 +445,38 @@ void main() {
});
});

test('GenerationConfig toJson with responseJsonSchema', () {
final jsonSchema = {
'type': 'object',
'properties': {
'recipeName': {'type': 'string'}
},
'required': ['recipeName']
};
final config = GenerationConfig(
responseMimeType: 'application/json',
responseJsonSchema: jsonSchema,
);
final json = config.toJson();
expect(json['responseMimeType'], 'application/json');
final dynamic responseSchema = json['responseJsonSchema'];
expect(responseSchema, isA<Map<String, Object?>>());
expect(responseSchema, equals(jsonSchema));
});

test(
'throws assertion if both responseSchema and responseJsonSchema are provided',
() {
final schema = Schema.object(properties: {});
final jsonSchema =
(json.decode('{"type": "string", "title": "MyString"}') as Map)
.cast<String, Object?>();
expect(
() => GenerationConfig(
responseSchema: schema, responseJsonSchema: jsonSchema),
throwsA(isA<AssertionError>()));
});

test('GenerationConfig toJson with empty stopSequences (omitted)', () {
final config = GenerationConfig(stopSequences: []);
expect(config.toJson(), {}); // Empty list for stopSequences is omitted
Expand Down
Loading