diff --git a/packages/genkit_google_genai/lib/genkit_google_genai.dart b/packages/genkit_google_genai/lib/genkit_google_genai.dart index 2c89f205..395a0807 100644 --- a/packages/genkit_google_genai/lib/genkit_google_genai.dart +++ b/packages/genkit_google_genai/lib/genkit_google_genai.dart @@ -32,6 +32,10 @@ class GoogleGenAiPluginHandle { return modelRef('googleai/$name', customOptions: GeminiOptions.$schema); } + ModelRef gemma(String name) { + return modelRef('googleai/$name', customOptions: GemmaOptions.$schema); + } + EmbedderRef textEmbedding(String name) { return embedderRef( 'googleai/$name', diff --git a/packages/genkit_google_genai/lib/src/common_plugin.dart b/packages/genkit_google_genai/lib/src/common_plugin.dart index 0eebd543..491b2de5 100644 --- a/packages/genkit_google_genai/lib/src/common_plugin.dart +++ b/packages/genkit_google_genai/lib/src/common_plugin.dart @@ -21,6 +21,7 @@ import 'package:schemantic/schemantic.dart'; import 'aggregation.dart'; import 'api_client.dart'; +import 'gemma.dart'; import 'generated/generativelanguage.dart' as gcl; import 'model.dart'; @@ -40,11 +41,16 @@ final commonModelInfo = ModelInfo( abstract class CommonGoogleGenPlugin extends GenkitPlugin { Future getApiClient([String? requestApiKey]); - Model createModel(String modelName, SchemanticType customOptions) { + Model createModel( + String modelName, + SchemanticType customOptions, { + ModelInfo? modelInfo, + }) { + final isGemma = isGemmaModelName(modelName); return Model( name: '$name/$modelName', customOptions: customOptions, - metadata: {'model': commonModelInfo.toJson()}, + metadata: {'model': (modelInfo ?? commonModelInfo).toJson()}, fn: (req, ctx) async { gcl.GenerationConfig generationConfig; List? safetySettings; @@ -74,9 +80,9 @@ abstract class CommonGoogleGenPlugin extends GenkitPlugin { ); toolConfig = toGeminiToolConfig(options.functionCallingConfig); } else { - final options = req.config == null - ? GeminiOptions() - : GeminiOptions.$schema.parse(req.config!); + final options = isGemma + ? gemmaToGeminiOptions(GemmaOptions.fromJson(req.config ?? {})) + : GeminiOptions.fromJson(req.config ?? {}); apiKey = options.apiKey; generationConfig = toGeminiSettings( options, @@ -98,9 +104,19 @@ abstract class CommonGoogleGenPlugin extends GenkitPlugin { final systemMessage = req.messages .where((m) => m.role == Role.system) .firstOrNull; - final messages = req.messages + final nonSystemMessages = req.messages .where((m) => m.role != Role.system) .toList(); + final messages = isGemma + ? stripReasoningParts(nonSystemMessages) + : nonSystemMessages; + + if (isGemma && messages.isEmpty) { + throw GenkitException( + 'No valid messages found for the model request.', + status: StatusCodes.INVALID_ARGUMENT, + ); + } final generateRequest = gcl.GenerateContentRequest( contents: toGeminiContent(messages), @@ -189,6 +205,15 @@ abstract class CommonGoogleGenPlugin extends GenkitPlugin { return createEmbedder(name); } if (actionType == 'model') { + if (isGemmaModelName(name)) { + return createModel( + name, + GemmaOptions.$schema, + modelInfo: isGemma3ModelName(name) + ? gemma3ModelInfo + : commonGemmaModelInfo, + ); + } if (name.contains('-tts')) { return createModel(name, GeminiTtsOptions.$schema); } diff --git a/packages/genkit_google_genai/lib/src/gemma.dart b/packages/genkit_google_genai/lib/src/gemma.dart new file mode 100644 index 00000000..6ed78103 --- /dev/null +++ b/packages/genkit_google_genai/lib/src/gemma.dart @@ -0,0 +1,88 @@ +// 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 'package:genkit/plugin.dart'; + +import 'model.dart'; + +final commonGemmaModelInfo = ModelInfo( + supports: { + 'multiturn': true, + 'media': true, + 'tools': true, + 'toolChoice': true, + 'systemRole': true, + 'constrained': 'no-tools', + }, +); + +final gemma3ModelInfo = ModelInfo( + supports: {...?commonGemmaModelInfo.supports, 'systemRole': false}, +); + +bool isGemmaModelName(String name) => name.startsWith('gemma-'); + +bool isGemma3ModelName(String name) => + name.startsWith('gemma-3-') || name.startsWith('gemma-3n-'); + +/// Strips parts that the Gemma API rejects in history: reasoning parts +/// and any text/tool parts whose metadata carries a `thoughtSignature`. +/// Messages that become empty after filtering are dropped. +List stripReasoningParts(List messages) { + return messages + .map( + (m) => Message( + role: m.role, + content: m.content + .where( + (p) => + !p.isReasoning && p.metadata?['thoughtSignature'] == null, + ) + .toList(), + metadata: m.metadata, + ), + ) + .where((m) => m.content.isNotEmpty) + .toList(); +} + +/// Maps a [GemmaOptions] config to its [GeminiOptions] equivalent so the +/// shared `toGeminiSettings`/`toGeminiTools`/etc. helpers can be reused. +/// Every Gemma field has an identical Gemini twin; the only schema-level +/// difference is the tighter `temperature` cap on Gemma. +GeminiOptions gemmaToGeminiOptions(GemmaOptions o) { + return GeminiOptions( + apiKey: o.apiKey, + safetySettings: o.safetySettings, + codeExecution: o.codeExecution, + functionCallingConfig: o.functionCallingConfig, + thinkingConfig: o.thinkingConfig, + responseModalities: o.responseModalities, + googleSearch: o.googleSearch, + fileSearch: o.fileSearch, + temperature: o.temperature, + topP: o.topP, + topK: o.topK, + candidateCount: o.candidateCount, + stopSequences: o.stopSequences, + maxOutputTokens: o.maxOutputTokens, + responseMimeType: o.responseMimeType, + responseLogprobs: o.responseLogprobs, + logprobs: o.logprobs, + presencePenalty: o.presencePenalty, + frequencyPenalty: o.frequencyPenalty, + seed: o.seed, + speechConfig: o.speechConfig, + ); +} diff --git a/packages/genkit_google_genai/lib/src/google_api_client.dart b/packages/genkit_google_genai/lib/src/google_api_client.dart index 60d28184..3fed4122 100644 --- a/packages/genkit_google_genai/lib/src/google_api_client.dart +++ b/packages/genkit_google_genai/lib/src/google_api_client.dart @@ -17,6 +17,7 @@ import 'package:meta/meta.dart'; import 'api_client.dart'; import 'common_plugin.dart'; +import 'gemma.dart'; import 'generated/generativelanguage.dart' as gcl; import 'model.dart'; @@ -54,12 +55,23 @@ class GoogleGenAiPluginImpl extends CommonGoogleGenPlugin { final models = (modelsResponse.models ?? []) .where((model) { return model.name != null && - model.name!.startsWith('models/gemini-'); + (model.name!.startsWith('models/gemini-') || + model.name!.startsWith('models/gemma-')); }) .map((model) { - final isTts = model.name!.contains('-tts'); + final short = model.name!.split('/').last; + if (isGemmaModelName(short)) { + return modelMetadata( + '$name/$short', + customOptions: GemmaOptions.$schema, + modelInfo: isGemma3ModelName(short) + ? gemma3ModelInfo + : commonGemmaModelInfo, + ); + } + final isTts = short.contains('-tts'); return modelMetadata( - '$name/${model.name!.split('/').last}', + '$name/$short', customOptions: isTts ? GeminiTtsOptions.$schema : GeminiOptions.$schema, diff --git a/packages/genkit_google_genai/lib/src/model.dart b/packages/genkit_google_genai/lib/src/model.dart index ffb73826..43a81ba6 100644 --- a/packages/genkit_google_genai/lib/src/model.dart +++ b/packages/genkit_google_genai/lib/src/model.dart @@ -56,6 +56,42 @@ abstract class $GeminiOptions { $SpeechConfig? get speechConfig; } +@Schema() +abstract class $GemmaOptions { + String? get apiKey; + + List<$SafetySettings>? get safetySettings; + + bool? get codeExecution; + $FunctionCallingConfig? get functionCallingConfig; + $ThinkingConfig? get thinkingConfig; + List? get responseModalities; + + // Retrieval + $GoogleSearch? get googleSearch; + $FileSearch? get fileSearch; + + @DoubleField(minimum: 0.0, maximum: 1.0) + double? get temperature; + + @DoubleField(minimum: 0.0, maximum: 1.0) + double? get topP; + + int? get topK; + int? get candidateCount; + List? get stopSequences; + int? get maxOutputTokens; + + String? get responseMimeType; + bool? get responseLogprobs; + int? get logprobs; + double? get presencePenalty; + double? get frequencyPenalty; + int? get seed; + + $SpeechConfig? get speechConfig; +} + @Schema() abstract class $SafetySettings { @StringField( diff --git a/packages/genkit_google_genai/lib/src/model.g.dart b/packages/genkit_google_genai/lib/src/model.g.dart index a50ade23..7a6a0fd8 100644 --- a/packages/genkit_google_genai/lib/src/model.g.dart +++ b/packages/genkit_google_genai/lib/src/model.g.dart @@ -416,6 +416,401 @@ base class _GeminiOptionsTypeFactory extends SchemanticType { ); } +base class GemmaOptions { + /// Creates a [GemmaOptions] from a JSON map. + factory GemmaOptions.fromJson(Map json) => + $schema.parse(json); + + GemmaOptions._(this._json); + + GemmaOptions({ + String? apiKey, + List? safetySettings, + bool? codeExecution, + FunctionCallingConfig? functionCallingConfig, + ThinkingConfig? thinkingConfig, + List? responseModalities, + GoogleSearch? googleSearch, + FileSearch? fileSearch, + double? temperature, + double? topP, + int? topK, + int? candidateCount, + List? stopSequences, + int? maxOutputTokens, + String? responseMimeType, + bool? responseLogprobs, + int? logprobs, + double? presencePenalty, + double? frequencyPenalty, + int? seed, + SpeechConfig? speechConfig, + }) { + _json = { + 'apiKey': ?apiKey, + 'safetySettings': ?safetySettings?.map((e) => e.toJson()).toList(), + 'codeExecution': ?codeExecution, + 'functionCallingConfig': ?functionCallingConfig?.toJson(), + 'thinkingConfig': ?thinkingConfig?.toJson(), + 'responseModalities': ?responseModalities, + 'googleSearch': ?googleSearch?.toJson(), + 'fileSearch': ?fileSearch?.toJson(), + 'temperature': ?temperature, + 'topP': ?topP, + 'topK': ?topK, + 'candidateCount': ?candidateCount, + 'stopSequences': ?stopSequences, + 'maxOutputTokens': ?maxOutputTokens, + 'responseMimeType': ?responseMimeType, + 'responseLogprobs': ?responseLogprobs, + 'logprobs': ?logprobs, + 'presencePenalty': ?presencePenalty, + 'frequencyPenalty': ?frequencyPenalty, + 'seed': ?seed, + 'speechConfig': ?speechConfig?.toJson(), + }; + } + + late final Map _json; + + /// The JSON schema and type descriptor for [GemmaOptions]. + static const SchemanticType $schema = + _GemmaOptionsTypeFactory(); + + String? get apiKey { + return _json['apiKey'] as String?; + } + + set apiKey(String? value) { + if (value == null) { + _json.remove('apiKey'); + } else { + _json['apiKey'] = value; + } + } + + List? get safetySettings { + return (_json['safetySettings'] as List?) + ?.map((e) => SafetySettings.fromJson(e as Map)) + .toList(); + } + + set safetySettings(List? value) { + if (value == null) { + _json.remove('safetySettings'); + } else { + _json['safetySettings'] = value.toList(); + } + } + + bool? get codeExecution { + return _json['codeExecution'] as bool?; + } + + set codeExecution(bool? value) { + if (value == null) { + _json.remove('codeExecution'); + } else { + _json['codeExecution'] = value; + } + } + + FunctionCallingConfig? get functionCallingConfig { + return _json['functionCallingConfig'] == null + ? null + : FunctionCallingConfig.fromJson( + _json['functionCallingConfig'] as Map, + ); + } + + set functionCallingConfig(FunctionCallingConfig? value) { + if (value == null) { + _json.remove('functionCallingConfig'); + } else { + _json['functionCallingConfig'] = value; + } + } + + ThinkingConfig? get thinkingConfig { + return _json['thinkingConfig'] == null + ? null + : ThinkingConfig.fromJson( + _json['thinkingConfig'] as Map, + ); + } + + set thinkingConfig(ThinkingConfig? value) { + if (value == null) { + _json.remove('thinkingConfig'); + } else { + _json['thinkingConfig'] = value; + } + } + + List? get responseModalities { + return (_json['responseModalities'] as List?)?.cast(); + } + + set responseModalities(List? value) { + if (value == null) { + _json.remove('responseModalities'); + } else { + _json['responseModalities'] = value; + } + } + + GoogleSearch? get googleSearch { + return _json['googleSearch'] == null + ? null + : GoogleSearch.fromJson(_json['googleSearch'] as Map); + } + + set googleSearch(GoogleSearch? value) { + if (value == null) { + _json.remove('googleSearch'); + } else { + _json['googleSearch'] = value; + } + } + + FileSearch? get fileSearch { + return _json['fileSearch'] == null + ? null + : FileSearch.fromJson(_json['fileSearch'] as Map); + } + + set fileSearch(FileSearch? value) { + if (value == null) { + _json.remove('fileSearch'); + } else { + _json['fileSearch'] = value; + } + } + + double? get temperature { + return (_json['temperature'] as num?)?.toDouble(); + } + + set temperature(double? value) { + if (value == null) { + _json.remove('temperature'); + } else { + _json['temperature'] = value; + } + } + + double? get topP { + return (_json['topP'] as num?)?.toDouble(); + } + + set topP(double? value) { + if (value == null) { + _json.remove('topP'); + } else { + _json['topP'] = value; + } + } + + int? get topK { + return _json['topK'] as int?; + } + + set topK(int? value) { + if (value == null) { + _json.remove('topK'); + } else { + _json['topK'] = value; + } + } + + int? get candidateCount { + return _json['candidateCount'] as int?; + } + + set candidateCount(int? value) { + if (value == null) { + _json.remove('candidateCount'); + } else { + _json['candidateCount'] = value; + } + } + + List? get stopSequences { + return (_json['stopSequences'] as List?)?.cast(); + } + + set stopSequences(List? value) { + if (value == null) { + _json.remove('stopSequences'); + } else { + _json['stopSequences'] = value; + } + } + + int? get maxOutputTokens { + return _json['maxOutputTokens'] as int?; + } + + set maxOutputTokens(int? value) { + if (value == null) { + _json.remove('maxOutputTokens'); + } else { + _json['maxOutputTokens'] = value; + } + } + + String? get responseMimeType { + return _json['responseMimeType'] as String?; + } + + set responseMimeType(String? value) { + if (value == null) { + _json.remove('responseMimeType'); + } else { + _json['responseMimeType'] = value; + } + } + + bool? get responseLogprobs { + return _json['responseLogprobs'] as bool?; + } + + set responseLogprobs(bool? value) { + if (value == null) { + _json.remove('responseLogprobs'); + } else { + _json['responseLogprobs'] = value; + } + } + + int? get logprobs { + return _json['logprobs'] as int?; + } + + set logprobs(int? value) { + if (value == null) { + _json.remove('logprobs'); + } else { + _json['logprobs'] = value; + } + } + + double? get presencePenalty { + return (_json['presencePenalty'] as num?)?.toDouble(); + } + + set presencePenalty(double? value) { + if (value == null) { + _json.remove('presencePenalty'); + } else { + _json['presencePenalty'] = value; + } + } + + double? get frequencyPenalty { + return (_json['frequencyPenalty'] as num?)?.toDouble(); + } + + set frequencyPenalty(double? value) { + if (value == null) { + _json.remove('frequencyPenalty'); + } else { + _json['frequencyPenalty'] = value; + } + } + + int? get seed { + return _json['seed'] as int?; + } + + set seed(int? value) { + if (value == null) { + _json.remove('seed'); + } else { + _json['seed'] = value; + } + } + + SpeechConfig? get speechConfig { + return _json['speechConfig'] == null + ? null + : SpeechConfig.fromJson(_json['speechConfig'] as Map); + } + + set speechConfig(SpeechConfig? value) { + if (value == null) { + _json.remove('speechConfig'); + } else { + _json['speechConfig'] = value; + } + } + + @override + String toString() { + return _json.toString(); + } + + /// Serializes this [GemmaOptions] to a JSON map. + Map toJson() { + return _json; + } +} + +base class _GemmaOptionsTypeFactory extends SchemanticType { + const _GemmaOptionsTypeFactory(); + + @override + GemmaOptions parse(Object? json) { + return GemmaOptions._(json as Map); + } + + @override + JsonSchemaMetadata get schemaMetadata => JsonSchemaMetadata( + name: 'GemmaOptions', + definition: $Schema + .object( + properties: { + 'apiKey': $Schema.string(), + 'safetySettings': $Schema.list( + items: $Schema.fromMap({'\$ref': r'#/$defs/SafetySettings'}), + ), + 'codeExecution': $Schema.boolean(), + 'functionCallingConfig': $Schema.fromMap({ + '\$ref': r'#/$defs/FunctionCallingConfig', + }), + 'thinkingConfig': $Schema.fromMap({ + '\$ref': r'#/$defs/ThinkingConfig', + }), + 'responseModalities': $Schema.list(items: $Schema.string()), + 'googleSearch': $Schema.fromMap({'\$ref': r'#/$defs/GoogleSearch'}), + 'fileSearch': $Schema.fromMap({'\$ref': r'#/$defs/FileSearch'}), + 'temperature': $Schema.number(minimum: 0.0, maximum: 1.0), + 'topP': $Schema.number(minimum: 0.0, maximum: 1.0), + 'topK': $Schema.integer(), + 'candidateCount': $Schema.integer(), + 'stopSequences': $Schema.list(items: $Schema.string()), + 'maxOutputTokens': $Schema.integer(), + 'responseMimeType': $Schema.string(), + 'responseLogprobs': $Schema.boolean(), + 'logprobs': $Schema.integer(), + 'presencePenalty': $Schema.number(), + 'frequencyPenalty': $Schema.number(), + 'seed': $Schema.integer(), + 'speechConfig': $Schema.fromMap({'\$ref': r'#/$defs/SpeechConfig'}), + }, + ) + .value, + dependencies: [ + SafetySettings.$schema, + FunctionCallingConfig.$schema, + ThinkingConfig.$schema, + GoogleSearch.$schema, + FileSearch.$schema, + SpeechConfig.$schema, + ], + ); +} + base class SafetySettings { /// Creates a [SafetySettings] from a JSON map. factory SafetySettings.fromJson(Map json) => diff --git a/packages/genkit_google_genai/test/gemma_test.dart b/packages/genkit_google_genai/test/gemma_test.dart new file mode 100644 index 00000000..7620975c --- /dev/null +++ b/packages/genkit_google_genai/test/gemma_test.dart @@ -0,0 +1,158 @@ +// 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 'package:genkit/genkit.dart'; +import 'package:genkit/src/schema.dart' show toJsonSchema; +import 'package:genkit_google_genai/genkit_google_genai.dart'; +import 'package:genkit_google_genai/src/gemma.dart'; +import 'package:genkit_google_genai/src/google_api_client.dart'; +import 'package:test/test.dart'; + +void main() { + group('GemmaOptions schema', () { + test('round-trips temperature at cap', () { + final options = GemmaOptions.$schema.parse({'temperature': 1.0}); + expect(options.temperature, 1.0); + }); + + test('JSON schema caps temperature at 1.0', () { + final schema = toJsonSchema(type: GemmaOptions.$schema); + final defs = schema[r'$defs'] as Map; + final gemma = defs['GemmaOptions'] as Map; + final props = gemma['properties'] as Map; + final temp = props['temperature'] as Map; + expect(temp['maximum'], 1.0); + }); + }); + + group('model family predicates', () { + test('isGemmaModelName', () { + expect(isGemmaModelName('gemma-3-1b-it'), isTrue); + expect(isGemmaModelName('gemma-4-31b-it'), isTrue); + expect(isGemmaModelName('gemini-2.5-pro'), isFalse); + expect(isGemmaModelName('text-embedding-004'), isFalse); + }); + + test('isGemma3ModelName', () { + expect(isGemma3ModelName('gemma-3-1b-it'), isTrue); + expect(isGemma3ModelName('gemma-3-12b-it'), isTrue); + expect(isGemma3ModelName('gemma-3-27b-it'), isTrue); + expect(isGemma3ModelName('gemma-3-4b-it'), isTrue); + expect(isGemma3ModelName('gemma-3n-e4b-it'), isTrue); + expect(isGemma3ModelName('gemma-4-31b-it'), isFalse); + expect(isGemma3ModelName('gemini-2.5-pro'), isFalse); + }); + }); + + group('stripReasoningParts', () { + test('drops reasoning parts', () { + final messages = [ + Message( + role: Role.model, + content: [ + ReasoningPart(reasoning: 'thinking...'), + TextPart(text: 'answer'), + ], + ), + ]; + final stripped = stripReasoningParts(messages); + expect(stripped, hasLength(1)); + expect(stripped.first.content, hasLength(1)); + expect(stripped.first.content.first.text, 'answer'); + }); + + test('drops parts whose metadata carries thoughtSignature', () { + final messages = [ + Message( + role: Role.model, + content: [ + TextPart(text: 'hidden', metadata: {'thoughtSignature': 'sig'}), + TextPart(text: 'visible'), + ], + ), + ]; + final stripped = stripReasoningParts(messages); + expect(stripped.first.content, hasLength(1)); + expect(stripped.first.content.first.text, 'visible'); + }); + + test('drops messages that become empty', () { + final messages = [ + Message( + role: Role.model, + content: [ReasoningPart(reasoning: 'only thought')], + ), + Message( + role: Role.user, + content: [TextPart(text: 'hi')], + ), + ]; + final stripped = stripReasoningParts(messages); + expect(stripped, hasLength(1)); + expect(stripped.first.role, Role.user); + }); + + test('leaves non-gemma-affected parts alone', () { + final messages = [ + Message( + role: Role.user, + content: [TextPart(text: 'hello')], + ), + ]; + final stripped = stripReasoningParts(messages); + expect(stripped, hasLength(1)); + expect(stripped.first.content.first.text, 'hello'); + }); + }); + + group('plugin handle', () { + test('googleAI.gemma returns a ModelRef with GemmaOptions schema', () { + final ref = googleAI.gemma('gemma-3-1b-it'); + expect(ref.name, 'googleai/gemma-3-1b-it'); + expect(ref.customOptions, same(GemmaOptions.$schema)); + }); + }); + + group('GoogleGenAiPluginImpl.resolve for gemma models', () { + Map? supportsOf(String modelName) { + final plugin = GoogleGenAiPluginImpl(); + final action = plugin.resolve('model', modelName); + expect(action, isNotNull); + expect(action!.name, 'googleai/$modelName'); + final model = action.metadata['model'] as Map; + return model['supports'] as Map?; + } + + test('gemma-3 models advertise systemRole: false', () { + for (final name in const [ + 'gemma-3-1b-it', + 'gemma-3-4b-it', + 'gemma-3-12b-it', + 'gemma-3-27b-it', + 'gemma-3n-e4b-it', + ]) { + expect(supportsOf(name)?['systemRole'], isFalse, reason: name); + } + }); + + test('gemma-4 models keep systemRole: true', () { + expect(supportsOf('gemma-4-31b-it')?['systemRole'], isTrue); + expect(supportsOf('gemma-4-26b-a4b-it')?['systemRole'], isTrue); + }); + + test('gemma models advertise constrained: no-tools', () { + expect(supportsOf('gemma-3-1b-it')?['constrained'], 'no-tools'); + }); + }); +}