diff --git a/packages/firebase_ai/firebase_ai/example/lib/schema/generable_example.dart b/packages/firebase_ai/firebase_ai/example/lib/schema/generable_example.dart new file mode 100644 index 000000000000..d40e224eac96 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/schema/generable_example.dart @@ -0,0 +1,24 @@ +import 'package:firebase_ai/firebase_ai.dart'; + +part 'generable_example.g.dart'; + +@Generable(description: 'A mock user for testing') +class MockUser { + @Guide(description: 'The user name', pattern: r'^[a-zA-Z]+$') + final String name; + + @Guide(description: 'The user age', minimum: 0, maximum: 120) + final int age; + + MockUser({required this.name, required this.age}); + + Map toJson() => { + 'name': name, + 'age': age, + }; +} + +@GenerateTool(name: 'get_mock_user') +Future getMockUser(String name) async { + return MockUser(name: name, age: 30); +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/schema/generable_example.g.dart b/packages/firebase_ai/firebase_ai/example/lib/schema/generable_example.g.dart new file mode 100644 index 000000000000..87a08f43e35f --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/schema/generable_example.g.dart @@ -0,0 +1,52 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'generable_example.dart'; + +// ************************************************************************** +// SchemaGenerator +// ************************************************************************** + +/// Auto-generated schema for MockUser. +final MockUserSchema = AutoSchema( + schemaMap: const { + 'type': 'OBJECT', + 'properties': { + 'name': { + 'description': 'The user name', + 'type': 'STRING', + 'pattern': '^[a-zA-Z]+\$' + }, + 'age': { + 'description': 'The user age', + 'type': 'INTEGER', + 'minimum': 0, + 'maximum': 120 + } + } + }, + fromJson: (json) => MockUser( + name: json['name'] as String, + age: json['age'] as int, + ), +); + +// ************************************************************************** +// ToolGenerator +// ************************************************************************** + +/// Auto-generated tool wrapper for getMockUser. +final getMockUserTool = AutoFunctionDeclaration( + name: 'get_mock_user', + description: 'Auto-generated tool for getMockUser', + parameters: { + 'name': Schema.string(), + }, + callable: (args) async { + // Extract arguments + final _name = args['name'] as String; + final result = await getMockUser( + _name, + ); + return result.toJson(); // Assumes result has toJson + }, +); diff --git a/packages/firebase_ai/firebase_ai/example/pubspec.yaml b/packages/firebase_ai/firebase_ai/example/pubspec.yaml index 1dd1ee5ce2db..627a79db01ea 100644 --- a/packages/firebase_ai/firebase_ai/example/pubspec.yaml +++ b/packages/firebase_ai/firebase_ai/example/pubspec.yaml @@ -37,10 +37,13 @@ dependencies: waveform_flutter: ^1.2.0 dev_dependencies: + build_runner: ^2.4.8 + firebase_ai_generator: ^0.1.0 flutter_lints: ^4.0.0 flutter_test: sdk: flutter + flutter: # The following line ensures that the Material Icons font is diff --git a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart index 730c8516c045..068ce3f4f602 100644 --- a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart +++ b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +export 'src/annotations.dart' show Generable, Guide, GenerateTool; export 'src/api.dart' show BlockReason, @@ -33,6 +34,7 @@ export 'src/api.dart' SafetyRating, SafetySetting, UsageMetadata; +export 'src/auto_schema.dart' show AutoSchema; export 'src/base_model.dart' show GenerativeModel, diff --git a/packages/firebase_ai/firebase_ai/lib/src/annotations.dart b/packages/firebase_ai/firebase_ai/lib/src/annotations.dart new file mode 100644 index 000000000000..58f4512be44a --- /dev/null +++ b/packages/firebase_ai/firebase_ai/lib/src/annotations.dart @@ -0,0 +1,49 @@ +// Copyright 2026 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. + +/// Annotation for classes that can be used to generate a JSON Schema. +class Generable { + /// A brief description of the generated object. + final String? description; + + /// Creates a [Generable] annotation. + const Generable({this.description}); +} + +/// Annotation for fields to provide constraints and documentation for the schema. +class Guide { + /// Description of the field. + final String? description; + + /// Minimum value for numeric fields. + final num? minimum; + + /// Maximum value for numeric fields. + final num? maximum; + + /// Regular expression pattern for string fields. + final String? pattern; + + /// Creates a [Guide] annotation. + const Guide({this.description, this.minimum, this.maximum, this.pattern}); +} + +/// Annotation for functions that can be used as tools by the model. +class GenerateTool { + /// The name of the tool. If omitted, the function name is used. + final String? name; + + /// Creates a [GenerateTool] annotation. + const GenerateTool({this.name}); +} diff --git a/packages/firebase_ai/firebase_ai/lib/src/auto_schema.dart b/packages/firebase_ai/firebase_ai/lib/src/auto_schema.dart new file mode 100644 index 000000000000..3d38d0bf95a9 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/lib/src/auto_schema.dart @@ -0,0 +1,29 @@ +// Copyright 2026 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. + +/// A class that holds the JSON Schema representation and a factory to +/// create a typed object from a JSON map. +/// +/// This serves as a bridge between the generated schema and the SDK's +/// type-safe methods for object generation and function calling. +class AutoSchema { + /// The raw JSON Schema map for the model. + final Map schemaMap; + + /// A function that creates an instance of [T] from a JSON map. + final T Function(Map) fromJson; + + /// Creates an [AutoSchema]. + const AutoSchema({required this.schemaMap, required this.fromJson}); +} diff --git a/packages/firebase_ai/firebase_ai/lib/src/base_model.dart b/packages/firebase_ai/firebase_ai/lib/src/base_model.dart index edd5a9a7c7b2..607e26e69e77 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/base_model.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/base_model.dart @@ -39,6 +39,7 @@ import 'live_session.dart'; import 'platform_header_helper.dart'; import 'server_template/template_tool.dart'; import 'tool.dart'; +import 'auto_schema.dart'; part 'generative_model.dart'; part 'imagen/imagen_model.dart'; diff --git a/packages/firebase_ai/firebase_ai/lib/src/generative_model.dart b/packages/firebase_ai/firebase_ai/lib/src/generative_model.dart index 2bf58351eafe..195821d4c1bc 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/generative_model.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/generative_model.dart @@ -163,6 +163,54 @@ final class GenerativeModel extends BaseApiClientModel { return response.map(_serializationStrategy.parseGenerateContentResponse); } + /// Generates an object of type [T] responding to [prompt]. + /// + /// Uses the provided [schema] to constrain the output to match the JSON + /// schema and uses [schema.fromJson] to parse the result. + Future generateObject( + AutoSchema schema, + Iterable prompt, { + List? safetySettings, + GenerationConfig? generationConfig, + List? tools, + ToolConfig? toolConfig, + }) async { + final config = GenerationConfig( + candidateCount: generationConfig?.candidateCount, + stopSequences: generationConfig?.stopSequences, + maxOutputTokens: generationConfig?.maxOutputTokens, + temperature: generationConfig?.temperature, + topP: generationConfig?.topP, + topK: generationConfig?.topK, + presencePenalty: generationConfig?.presencePenalty, + frequencyPenalty: generationConfig?.frequencyPenalty, + responseModalities: generationConfig?.responseModalities, + thinkingConfig: generationConfig?.thinkingConfig, + responseMimeType: 'application/json', + responseJsonSchema: schema.schemaMap, + ); + + final response = await generateContent( + prompt, + safetySettings: safetySettings, + generationConfig: config, + tools: tools, + toolConfig: toolConfig, + ); + + final text = response.text; + if (text == null) { + throw FirebaseAIException('No text returned from model'); + } + + final json = jsonDecode(text); + if (json is! Map) { + throw FirebaseAIException('Expected JSON map from model, got: $text'); + } + + return schema.fromJson(json); + } + /// Counts the total number of tokens in [contents]. /// /// Sends a "countTokens" API request for the configured model, diff --git a/packages/firebase_ai/firebase_ai/test/model_test.dart b/packages/firebase_ai/firebase_ai/test/model_test.dart index c14cf5ca3b95..506eab70d1c3 100644 --- a/packages/firebase_ai/firebase_ai/test/model_test.dart +++ b/packages/firebase_ai/firebase_ai/test/model_test.dart @@ -356,6 +356,55 @@ void main() { }); }); + group('generate object', () { + test('can make successful request and parse object', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + const result = '{"name": "John", "age": 30}'; + + final schema = AutoSchema>( + schemaMap: const { + 'type': 'OBJECT', + 'properties': { + 'name': {'type': 'STRING'}, + 'age': {'type': 'INTEGER'}, + }, + }, + fromJson: (json) => json, + ); + + final response = await client.checkRequest( + () => model.generateObject(schema, [Content.text(prompt)]), + verifyRequest: (uri, request) { + expect(request['generationConfig'], { + 'responseMimeType': 'application/json', + 'responseJsonSchema': { + 'type': 'OBJECT', + 'properties': { + 'name': {'type': 'STRING'}, + 'age': {'type': 'INTEGER'}, + }, + }, + }); + }, + response: { + 'candidates': [ + { + 'content': { + 'role': 'model', + 'parts': [ + {'text': result}, + ], + }, + }, + ], + }, + ); + expect(response['name'], 'John'); + expect(response['age'], 30); + }); + }); + group('generate content stream', () { test('can make successful request', () async { final (client, model) = createModel(); diff --git a/packages/firebase_ai/firebase_ai_generator/build.yaml b/packages/firebase_ai/firebase_ai_generator/build.yaml new file mode 100644 index 000000000000..b54badc97530 --- /dev/null +++ b/packages/firebase_ai/firebase_ai_generator/build.yaml @@ -0,0 +1,7 @@ +builders: + firebase_ai_generator: + import: "package:firebase_ai_generator/firebase_ai_generator.dart" + builder_factories: ["firebaseAiBuilder"] + build_extensions: {".dart": [".g.dart"]} + auto_apply: dependents + build_to: source diff --git a/packages/firebase_ai/firebase_ai_generator/lib/firebase_ai_generator.dart b/packages/firebase_ai/firebase_ai_generator/lib/firebase_ai_generator.dart new file mode 100644 index 000000000000..5ed4476fb43f --- /dev/null +++ b/packages/firebase_ai/firebase_ai_generator/lib/firebase_ai_generator.dart @@ -0,0 +1,23 @@ +// Copyright 2026 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 'package:build/build.dart'; +import 'package:source_gen/source_gen.dart'; +import 'src/schema_generator.dart'; +import 'src/tool_generator.dart'; + +/// Builder factory for Firebase AI. +Builder firebaseAiBuilder(BuilderOptions options) { + return PartBuilder([SchemaGenerator(), ToolGenerator()], '.g.dart'); +} diff --git a/packages/firebase_ai/firebase_ai_generator/lib/src/schema_generator.dart b/packages/firebase_ai/firebase_ai_generator/lib/src/schema_generator.dart new file mode 100644 index 000000000000..846242f52b40 --- /dev/null +++ b/packages/firebase_ai/firebase_ai_generator/lib/src/schema_generator.dart @@ -0,0 +1,141 @@ +// Copyright 2026 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 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:build/build.dart'; +import 'dart:convert'; +import 'package:source_gen/source_gen.dart'; +import 'package:firebase_ai/src/annotations.dart'; // Import annotations + +/// Generator for [Generable] annotation. +class SchemaGenerator extends GeneratorForAnnotation { + @override + String generateForAnnotatedElement( + Element element, ConstantReader annotation, BuildStep buildStep) { + if (element is! ClassElement) { + throw InvalidGenerationSourceError( + '@Generable can only be applied to classes.', + element: element); + } + + final className = element.name; + final fields = element.fields; + + final schemaMap = { + 'type': 'OBJECT', + 'properties': {}, + }; + + final fromJsonBuffer = StringBuffer(); + fromJsonBuffer.writeln('(json) => $className('); + + for (final field in fields) { + if (field.isSynthetic) continue; // Skip getters/setters + + final fieldName = field.name; + final fieldType = field.type; + + // Check for @Guide annotation + final guideAnnotation = + TypeChecker.fromRuntime(Guide).firstAnnotationOf(field); + String? description; + num? minimum; + num? maximum; + String? pattern; + + if (guideAnnotation != null) { + final reader = ConstantReader(guideAnnotation); + description = reader.read('description').literalValue as String?; + minimum = reader.read('minimum').literalValue as num?; + maximum = reader.read('maximum').literalValue as num?; + pattern = reader.read('pattern').literalValue as String?; + } + + final fieldSchema = {}; + if (description != null) fieldSchema['description'] = description; + + if (fieldType.isDartCoreString) { + fieldSchema['type'] = 'STRING'; + if (pattern != null) fieldSchema['pattern'] = pattern; + } else if (fieldType.isDartCoreInt) { + fieldSchema['type'] = 'INTEGER'; + if (minimum != null) fieldSchema['minimum'] = minimum; + if (maximum != null) fieldSchema['maximum'] = maximum; + } else if (fieldType.isDartCoreDouble) { + fieldSchema['type'] = 'NUMBER'; + if (minimum != null) fieldSchema['minimum'] = minimum; + if (maximum != null) fieldSchema['maximum'] = maximum; + } else if (fieldType.isDartCoreBool) { + fieldSchema['type'] = 'BOOLEAN'; + } else if (fieldType.isDartCoreList) { + fieldSchema['type'] = 'ARRAY'; + // Handle list item type if possible + final typeArgs = (fieldType as ParameterizedType).typeArguments; + if (typeArgs.isNotEmpty) { + final itemType = typeArgs.first; + fieldSchema['items'] = _mapPrimitiveType(itemType); + } + } else { + // Handle nested objects or enums + fieldSchema['type'] = 'OBJECT'; // Fallback + } + + (schemaMap['properties'] as Map)[fieldName] = fieldSchema; + + fromJsonBuffer.writeln(' $fieldName: json[\'$fieldName\'] as ${_mapDartType(fieldType)},'); + } + + fromJsonBuffer.write(')'); + + return ''' +/// Auto-generated schema for $className. +final ${className}Schema = AutoSchema<$className>( + schemaMap: const ${_mapToDartCode(schemaMap)}, + fromJson: $fromJsonBuffer, +); +'''; + } + + Map _mapPrimitiveType(DartType type) { + if (type.isDartCoreString) return {'type': 'STRING'}; + if (type.isDartCoreInt) return {'type': 'INTEGER'}; + if (type.isDartCoreDouble) return {'type': 'NUMBER'}; + if (type.isDartCoreBool) return {'type': 'BOOLEAN'}; + return {'type': 'OBJECT'}; + } + + String _mapDartType(DartType type) { + if (type.isDartCoreString) return 'String'; + if (type.isDartCoreInt) return 'int'; + if (type.isDartCoreDouble) return 'double'; + if (type.isDartCoreBool) return 'bool'; + if (type.isDartCoreList) { + final typeArgs = (type as ParameterizedType).typeArguments; + if (typeArgs.isNotEmpty) { + return 'List<${_mapDartType(typeArgs.first)}>'; + } + return 'List'; + } + return 'Map'; + } + + String _mapToDartCode(Map map) { + return jsonEncode(map) + .replaceAll('"', "'") + .replaceAll(r'$', r'\$') + .replaceAll(':', ': ') + .replaceAll(',', ', '); + } +} diff --git a/packages/firebase_ai/firebase_ai_generator/lib/src/tool_generator.dart b/packages/firebase_ai/firebase_ai_generator/lib/src/tool_generator.dart new file mode 100644 index 000000000000..01b4030b0b26 --- /dev/null +++ b/packages/firebase_ai/firebase_ai_generator/lib/src/tool_generator.dart @@ -0,0 +1,102 @@ +// Copyright 2026 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 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:build/build.dart'; +import 'dart:convert'; +import 'package:source_gen/source_gen.dart'; +import 'package:firebase_ai/src/annotations.dart'; // Import annotations + +/// Generator for [GenerateTool] annotation. +class ToolGenerator extends GeneratorForAnnotation { + @override + String generateForAnnotatedElement( + Element element, ConstantReader annotation, BuildStep buildStep) { + if (element is! FunctionElement) { + throw InvalidGenerationSourceError( + '@GenerateTool can only be applied to functions.', + element: element); + } + + final functionName = element.name; + final parameters = element.parameters; + final toolName = annotation.read('name').literalValue as String? ?? functionName; + final description = element.documentationComment ?? 'Auto-generated tool for $functionName'; + + final propertiesBuffer = StringBuffer(); + propertiesBuffer.writeln('{'); + + final callableBuffer = StringBuffer(); + callableBuffer.writeln('(args) async {'); + callableBuffer.writeln(' // Extract arguments'); + + for (final param in parameters) { + final paramName = param.name; + final paramType = param.type; + + propertiesBuffer.write(" '$paramName': "); + if (paramType.isDartCoreString) { + propertiesBuffer.writeln('Schema.string(),'); + } else if (paramType.isDartCoreInt) { + propertiesBuffer.writeln('Schema.integer(),'); + } else if (paramType.isDartCoreDouble) { + propertiesBuffer.writeln('Schema.number(),'); + } else if (paramType.isDartCoreBool) { + propertiesBuffer.writeln('Schema.boolean(),'); + } else { + propertiesBuffer.writeln('Schema.object(properties: {}), // TODO: Handle complex types'); + } + + callableBuffer.writeln(" final _$paramName = args['$paramName'] as ${_mapDartType(paramType)};"); + } + + propertiesBuffer.write(' }'); + + callableBuffer.writeln(' final result = await $functionName('); + for (final param in parameters) { + final paramName = param.name; + callableBuffer.writeln(' _$paramName,'); + } + callableBuffer.writeln(' );'); + callableBuffer.writeln(' return result.toJson(); // Assumes result has toJson'); + callableBuffer.writeln('}'); + + return ''' +/// Auto-generated tool wrapper for $functionName. +final ${functionName}Tool = AutoFunctionDeclaration( + name: '$toolName', + description: '$description', + parameters: $propertiesBuffer, + callable: $callableBuffer, +); +'''; + } + + String _mapDartType(DartType type) { + if (type.isDartCoreString) return 'String'; + if (type.isDartCoreInt) return 'int'; + if (type.isDartCoreDouble) return 'double'; + if (type.isDartCoreBool) return 'bool'; + return 'Map'; + } + + String _mapToDartCode(Map map) { + return jsonEncode(map) + .replaceAll('"', "'") + .replaceAll(r'$', r'\$') + .replaceAll(':', ': ') + .replaceAll(',', ', '); + } +} diff --git a/packages/firebase_ai/firebase_ai_generator/pubspec.yaml b/packages/firebase_ai/firebase_ai_generator/pubspec.yaml new file mode 100644 index 000000000000..bdb5802729cc --- /dev/null +++ b/packages/firebase_ai/firebase_ai_generator/pubspec.yaml @@ -0,0 +1,14 @@ +name: firebase_ai_generator +description: Code generator for Firebase AI. +version: 0.1.0 +environment: + sdk: '>=3.2.0 <4.0.0' + +dependencies: + analyzer: ^6.4.1 + build: ^2.4.1 + source_gen: ^1.5.0 + firebase_ai: ^3.10.0 + +dev_dependencies: + build_runner: ^2.4.8