Skip to content

Commit 3b34157

Browse files
committed
add responseJsonSchema to GenerationConfig
1 parent b0ce252 commit 3b34157

File tree

5 files changed

+281
-11
lines changed

5 files changed

+281
-11
lines changed

packages/firebase_ai/firebase_ai/example/lib/main.dart

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,17 @@ import 'package:firebase_auth/firebase_auth.dart';
1717
import 'package:firebase_core/firebase_core.dart';
1818
import 'package:flutter/material.dart';
1919

20-
import 'pages/audio_page.dart';
21-
import 'pages/bidi_page.dart';
2220
// Import after file is generated through flutterfire_cli.
2321
// import 'package:firebase_ai_example/firebase_options.dart';
2422

23+
import 'pages/audio_page.dart';
24+
import 'pages/bidi_page.dart';
2525
import 'pages/chat_page.dart';
2626
import 'pages/document.dart';
2727
import 'pages/function_calling_page.dart';
2828
import 'pages/image_prompt_page.dart';
2929
import 'pages/imagen_page.dart';
30+
import 'pages/json_schema_page.dart';
3031
import 'pages/schema_page.dart';
3132
import 'pages/token_count_page.dart';
3233
import 'pages/video_page.dart';
@@ -64,11 +65,11 @@ class _GenerativeAISampleState extends State<GenerativeAISample> {
6465
void _initializeModel(bool useVertexBackend) {
6566
if (useVertexBackend) {
6667
final vertexInstance = FirebaseAI.vertexAI(auth: FirebaseAuth.instance);
67-
_currentModel = vertexInstance.generativeModel(model: 'gemini-1.5-flash');
68+
_currentModel = vertexInstance.generativeModel(model: 'gemini-2.5-flash');
6869
_currentImagenModel = _initializeImagenModel(vertexInstance);
6970
} else {
7071
final googleAI = FirebaseAI.googleAI(auth: FirebaseAuth.instance);
71-
_currentModel = googleAI.generativeModel(model: 'gemini-2.0-flash');
72+
_currentModel = googleAI.generativeModel(model: 'gemini-2.5-flash');
7273
_currentImagenModel = _initializeImagenModel(googleAI);
7374
}
7475
}
@@ -184,10 +185,12 @@ class _HomeScreenState extends State<HomeScreen> {
184185
case 6:
185186
return SchemaPromptPage(title: 'Schema Prompt', model: currentModel);
186187
case 7:
187-
return DocumentPage(title: 'Document Prompt', model: currentModel);
188+
return JsonSchemaPage(title: 'JSON Schema', model: currentModel);
188189
case 8:
189-
return VideoPage(title: 'Video Prompt', model: currentModel);
190+
return DocumentPage(title: 'Document Prompt', model: currentModel);
190191
case 9:
192+
return VideoPage(title: 'Video Prompt', model: currentModel);
193+
case 10:
191194
return BidiPage(
192195
title: 'Live Stream',
193196
model: currentModel,
@@ -302,6 +305,11 @@ class _HomeScreenState extends State<HomeScreen> {
302305
label: 'Schema',
303306
tooltip: 'Schema Prompt',
304307
),
308+
BottomNavigationBarItem(
309+
icon: Icon(Icons.data_object),
310+
label: 'JSON',
311+
tooltip: 'JSON Schema',
312+
),
305313
BottomNavigationBarItem(
306314
icon: Icon(Icons.edit_document),
307315
label: 'Document',
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import 'dart:convert';
16+
17+
import 'package:flutter/material.dart';
18+
import 'package:firebase_ai/firebase_ai.dart';
19+
import '../widgets/message_widget.dart';
20+
21+
class JsonSchemaPage extends StatefulWidget {
22+
const JsonSchemaPage({super.key, required this.title, required this.model});
23+
24+
final String title;
25+
final GenerativeModel model;
26+
27+
@override
28+
State<JsonSchemaPage> createState() => _JsonSchemaPageState();
29+
}
30+
31+
class _JsonSchemaPageState extends State<JsonSchemaPage> {
32+
final ScrollController _scrollController = ScrollController();
33+
final TextEditingController _textController = TextEditingController();
34+
final FocusNode _textFieldFocus = FocusNode();
35+
final List<MessageData> _messages = <MessageData>[];
36+
bool _loading = false;
37+
38+
void _scrollDown() {
39+
WidgetsBinding.instance.addPostFrameCallback(
40+
(_) => _scrollController.animateTo(
41+
_scrollController.position.maxScrollExtent,
42+
duration: const Duration(
43+
milliseconds: 750,
44+
),
45+
curve: Curves.easeOutCirc,
46+
),
47+
);
48+
}
49+
50+
@override
51+
Widget build(BuildContext context) {
52+
return Scaffold(
53+
appBar: AppBar(
54+
title: Text(widget.title),
55+
),
56+
body: Padding(
57+
padding: const EdgeInsets.all(8),
58+
child: Column(
59+
mainAxisAlignment: MainAxisAlignment.center,
60+
crossAxisAlignment: CrossAxisAlignment.start,
61+
children: [
62+
Expanded(
63+
child: ListView.builder(
64+
controller: _scrollController,
65+
itemBuilder: (context, idx) {
66+
return MessageWidget(
67+
text: _messages[idx].text,
68+
isFromUser: _messages[idx].fromUser ?? false,
69+
);
70+
},
71+
itemCount: _messages.length,
72+
),
73+
),
74+
Padding(
75+
padding: const EdgeInsets.symmetric(
76+
vertical: 25,
77+
horizontal: 15,
78+
),
79+
child: Row(
80+
children: [
81+
Expanded(
82+
child: ElevatedButton(
83+
onPressed: !_loading
84+
? () async {
85+
await _promptJsonSchemaTest();
86+
}
87+
: null,
88+
child: const Text('JSON Schema Prompt'),
89+
),
90+
),
91+
],
92+
),
93+
),
94+
],
95+
),
96+
),
97+
);
98+
}
99+
100+
Future<void> _promptJsonSchemaTest() async {
101+
setState(() {
102+
_loading = true;
103+
});
104+
try {
105+
final content = [
106+
Content.text(
107+
'Generate a widget hierarchy with a column containing two text widgets ',
108+
),
109+
];
110+
111+
final jsonSchema = {
112+
r'$defs': {
113+
'text_widget': {
114+
r'$anchor': 'text_widget',
115+
'type': 'object',
116+
'properties': {
117+
'type': {'const': 'Text'},
118+
'text': {'type': 'string'},
119+
},
120+
'required': ['type', 'text'],
121+
},
122+
},
123+
'type': 'object',
124+
'properties': {
125+
'type': {'const': 'Column'},
126+
'children': {
127+
'type': 'array',
128+
'items': {
129+
'anyOf': [
130+
{r'$ref': '#text_widget'},
131+
{
132+
'type': 'object',
133+
'properties': {
134+
'type': {'const': 'Row'},
135+
'children': {
136+
'type': 'array',
137+
'items': {r'$ref': '#text_widget'},
138+
},
139+
},
140+
'required': ['type', 'children'],
141+
}
142+
],
143+
},
144+
},
145+
},
146+
'required': ['type', 'children'],
147+
};
148+
149+
final response = await widget.model.generateContent(
150+
content,
151+
generationConfig: GenerationConfig(
152+
responseMimeType: 'application/json',
153+
responseJsonSchema: jsonSchema,
154+
),
155+
);
156+
157+
var text = const JsonEncoder.withIndent(' ')
158+
.convert(json.decode(response.text ?? '') as Object?);
159+
_messages.add(MessageData(text: '```json$text```', fromUser: false));
160+
161+
setState(() {
162+
_loading = false;
163+
_scrollDown();
164+
});
165+
} catch (e) {
166+
_showError(e.toString());
167+
setState(() {
168+
_loading = false;
169+
});
170+
} finally {
171+
_textController.clear();
172+
setState(() {
173+
_loading = false;
174+
});
175+
_textFieldFocus.requestFocus();
176+
}
177+
}
178+
179+
void _showError(String message) {
180+
showDialog<void>(
181+
context: context,
182+
builder: (context) {
183+
return AlertDialog(
184+
title: const Text('Something went wrong'),
185+
content: SingleChildScrollView(
186+
child: SelectableText(message),
187+
),
188+
actions: [
189+
TextButton(
190+
onPressed: () {
191+
Navigator.of(context).pop();
192+
},
193+
child: const Text('OK'),
194+
),
195+
],
196+
);
197+
},
198+
);
199+
}
200+
}

packages/firebase_ai/firebase_ai/example/lib/pages/schema_page.dart

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
1414

15+
import 'dart:convert';
16+
1517
import 'package:flutter/material.dart';
1618
import 'package:firebase_ai/firebase_ai.dart';
1719
import '../widgets/message_widget.dart';
@@ -132,13 +134,14 @@ class _SchemaPromptPageState extends State<SchemaPromptPage> {
132134
),
133135
);
134136

135-
var text = response.text;
136-
_messages.add(MessageData(text: text, fromUser: false));
137-
138-
if (text == null) {
137+
if (response.text == null) {
139138
_showError('No response from API.');
140139
return;
141140
} else {
141+
final text = const JsonEncoder.withIndent(' ')
142+
.convert(json.decode(response.text!) as Object?);
143+
_messages
144+
.add(MessageData(text: '```json\n$text\n```', fromUser: false));
142145
setState(() {
143146
_loading = false;
144147
_scrollDown();

packages/firebase_ai/firebase_ai/lib/src/api.dart

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -998,8 +998,10 @@ final class GenerationConfig extends BaseGenerationConfig {
998998
super.responseModalities,
999999
this.responseMimeType,
10001000
this.responseSchema,
1001+
this.responseJsonSchema,
10011002
this.thinkingConfig,
1002-
});
1003+
}) : assert(responseSchema == null || responseJsonSchema == null,
1004+
'responseSchema and responseJsonSchema cannot both be set.');
10031005

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

1028+
/// The response schema as a JSON-compatible map.
1029+
///
1030+
/// - Note: This only applies when the [responseMimeType] supports a schema;
1031+
/// currently this is limited to `application/json`.
1032+
///
1033+
/// This schema can include more advanced features of JSON than the [Schema]
1034+
/// class taken by [responseSchema] supports. See the [Gemini
1035+
/// documentation](https://ai.google.dev/api/generate-content#FIELDS.response_json_schema)
1036+
/// about the limitations of this feature.
1037+
///
1038+
/// Notably, this feature is only supported on Gemini 2.5 and later. Use
1039+
/// [responseSchema] for earlier models.
1040+
///
1041+
/// Only one of [responseSchema] or [responseJsonSchema] may be specified at
1042+
/// the same time.
1043+
final Map<String, Object?>? responseJsonSchema;
1044+
10231045
/// Config for thinking features.
10241046
///
10251047
/// An error will be returned if this field is set for models that don't
@@ -1036,6 +1058,8 @@ final class GenerationConfig extends BaseGenerationConfig {
10361058
'responseMimeType': responseMimeType,
10371059
if (responseSchema case final responseSchema?)
10381060
'responseSchema': responseSchema.toJson(),
1061+
if (responseJsonSchema case final responseJsonSchema?)
1062+
'responseJsonSchema': responseJsonSchema,
10391063
if (thinkingConfig case final thinkingConfig?)
10401064
'thinkingConfig': thinkingConfig.toJson(),
10411065
};

packages/firebase_ai/firebase_ai/test/api_test.dart

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
// See the License for the specific language governing permissions and
1313
// limitations under the License.
14+
15+
import 'dart:convert';
16+
1417
import 'package:firebase_ai/firebase_ai.dart';
1518
import 'package:firebase_ai/src/api.dart';
1619

@@ -442,6 +445,38 @@ void main() {
442445
});
443446
});
444447

448+
test('GenerationConfig toJson with responseJsonSchema', () {
449+
final jsonSchema = {
450+
'type': 'object',
451+
'properties': {
452+
'recipeName': {'type': 'string'}
453+
},
454+
'required': ['recipeName']
455+
};
456+
final config = GenerationConfig(
457+
responseMimeType: 'application/json',
458+
responseJsonSchema: jsonSchema,
459+
);
460+
final json = config.toJson();
461+
expect(json['responseMimeType'], 'application/json');
462+
final dynamic responseSchema = json['responseJsonSchema'];
463+
expect(responseSchema, isA<Map<String, Object?>>());
464+
expect(responseSchema, equals(jsonSchema));
465+
});
466+
467+
test(
468+
'throws assertion if both responseSchema and responseJsonSchema are provided',
469+
() {
470+
final schema = Schema.object(properties: {});
471+
final jsonSchema =
472+
(json.decode('{"type": "string", "title": "MyString"}') as Map)
473+
.cast<String, Object?>();
474+
expect(
475+
() => GenerationConfig(
476+
responseSchema: schema, responseJsonSchema: jsonSchema),
477+
throwsA(isA<AssertionError>()));
478+
});
479+
445480
test('GenerationConfig toJson with empty stopSequences (omitted)', () {
446481
final config = GenerationConfig(stopSequences: []);
447482
expect(config.toJson(), {}); // Empty list for stopSequences is omitted

0 commit comments

Comments
 (0)