Skip to content

feat(firebaseai): handle unknown parts when parsing content #17522

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 6 commits into from
Aug 1, 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
32 changes: 26 additions & 6 deletions packages/firebase_ai/firebase_ai/lib/src/content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
// limitations under the License.

import 'dart:convert';
import 'dart:developer';
import 'dart:typed_data';

import 'error.dart';

/// The base structured datatype containing multi-part content of a message.
Expand Down Expand Up @@ -81,7 +83,14 @@ Content parseContent(Object jsonObject) {

/// Parse the [Part] from json object.
Part parsePart(Object? jsonObject) {
if (jsonObject is Map && jsonObject.containsKey('functionCall')) {
if (jsonObject is! Map<String, Object?>) {
log('Unhandled part format: $jsonObject');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are the logs necessary if returning the unknown part?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, since this is backend added some new feature / Part data that the client SDK might not able to catch up, which is an abnormal case. We would like this information is delivered to developers and the client SDKs would like to catch up the new features going forward.

return UnknownPart(<String, Object?>{
'unhandled': jsonObject,
});
}

if (jsonObject.containsKey('functionCall')) {
final functionCall = jsonObject['functionCall'];
if (functionCall is Map &&
functionCall.containsKey('name') &&
Expand All @@ -104,13 +113,12 @@ Part parsePart(Object? jsonObject) {
}
} =>
FileData(mimeType, fileUri),
{
'functionResponse': {'name': String _, 'response': Map<String, Object?> _}
} =>
throw UnimplementedError('FunctionResponse part not yet supported'),
{'inlineData': {'mimeType': String mimeType, 'data': String bytes}} =>
InlineDataPart(mimeType, base64Decode(bytes)),
_ => throw unhandledFormat('Part', jsonObject),
_ => () {
log('unhandled part format: $jsonObject');
return UnknownPart(jsonObject);
}(),
};
}

Expand All @@ -120,6 +128,18 @@ sealed class Part {
Object toJson();
}

/// A [Part] that contains unparsable data.
final class UnknownPart implements Part {
// ignore: public_member_api_docs
UnknownPart(this.data);

/// The unparsed data.
final Map<String, Object?> data;

@override
Object toJson() => data;
}

/// A [Part] with the text content.
final class TextPart implements Part {
// ignore: public_member_api_docs
Expand Down
29 changes: 20 additions & 9 deletions packages/firebase_ai/firebase_ai/test/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import 'dart:convert';
import 'dart:typed_data';

import 'package:firebase_ai/src/content.dart';
import 'package:firebase_ai/src/error.dart';
import 'package:flutter_test/flutter_test.dart';

// Mock google_ai classes (if needed)
Expand Down Expand Up @@ -192,24 +191,36 @@ void main() {
expect(inlineData.bytes, [1, 2, 3]);
});

test('throws UnimplementedError for functionResponse', () {
test('returns UnknownPart for functionResponse', () {
final json = {
'functionResponse': {'name': 'test', 'response': {}}
};
expect(() => parsePart(json), throwsA(isA<FirebaseAISdkException>()));
final result = parsePart(json);
expect(result, isA<UnknownPart>());
final unknownPart = result as UnknownPart;
expect(unknownPart.data, json);
});

test('throws unhandledFormat for invalid JSON', () {
test('returns UnknownPart for invalid JSON', () {
final json = {'invalid': 'data'};
expect(() => parsePart(json), throwsA(isA<Exception>()));
final result = parsePart(json);
expect(result, isA<UnknownPart>());
final unknownPart = result as UnknownPart;
expect(unknownPart.data, json);
});

test('throws unhandledFormat for null input', () {
expect(() => parsePart(null), throwsA(isA<Exception>()));
test('returns UnknownPart for null input', () {
final result = parsePart(null);
expect(result, isA<UnknownPart>());
final unknownPart = result as UnknownPart;
expect(unknownPart.data, {'unhandled': null});
});

test('throws unhandledFormat for empty map', () {
expect(() => parsePart({}), throwsA(isA<Exception>()));
test('returns UnknownPart for empty map', () {
final result = parsePart({});
expect(result, isA<UnknownPart>());
final unknownPart = result as UnknownPart;
expect(unknownPart.data, {'unhandled': {}});
});
});
}
3 changes: 3 additions & 0 deletions packages/firebase_ai/firebase_ai/test/utils/matchers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import 'package:firebase_ai/firebase_ai.dart';
import 'package:firebase_ai/src/content.dart';
import 'package:http/http.dart' as http;
import 'package:matcher/matcher.dart';

Expand All @@ -33,6 +34,8 @@ Matcher matchesPart(Part part) => switch (part) {
isA<FunctionResponse>()
.having((p) => p.name, 'name', name)
.having((p) => p.response, 'args', response),
UnknownPart(data: final data) =>
isA<UnknownPart>().having((p) => p.data, 'data', data),
};

Matcher matchesContent(Content content) => isA<Content>()
Expand Down
30 changes: 20 additions & 10 deletions packages/firebase_vertexai/firebase_vertexai/test/content_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ import 'dart:convert';
import 'dart:typed_data';

import 'package:firebase_ai/src/content.dart';
import 'package:firebase_vertexai/firebase_vertexai.dart'
show VertexAISdkException;
import 'package:flutter_test/flutter_test.dart';

// Mock google_ai classes (if needed)
Expand Down Expand Up @@ -193,24 +191,36 @@ void main() {
expect(inlineData.bytes, [1, 2, 3]);
});

test('throws UnimplementedError for functionResponse', () {
test('returns UnknownPart for functionResponse', () {
final json = {
'functionResponse': {'name': 'test', 'response': {}}
};
expect(() => parsePart(json), throwsA(isA<VertexAISdkException>()));
final result = parsePart(json);
expect(result, isA<UnknownPart>());
final unknownPart = result as UnknownPart;
expect(unknownPart.data, json);
});

test('throws unhandledFormat for invalid JSON', () {
test('returns UnknownPart for invalid JSON', () {
final json = {'invalid': 'data'};
expect(() => parsePart(json), throwsA(isA<Exception>()));
final result = parsePart(json);
expect(result, isA<UnknownPart>());
final unknownPart = result as UnknownPart;
expect(unknownPart.data, json);
});

test('throws unhandledFormat for null input', () {
expect(() => parsePart(null), throwsA(isA<Exception>()));
test('returns UnknownPart for null input', () {
final result = parsePart(null);
expect(result, isA<UnknownPart>());
final unknownPart = result as UnknownPart;
expect(unknownPart.data, {'unhandled': null});
});

test('throws unhandledFormat for empty map', () {
expect(() => parsePart({}), throwsA(isA<Exception>()));
test('returns UnknownPart for empty map', () {
final result = parsePart({});
expect(result, isA<UnknownPart>());
final unknownPart = result as UnknownPart;
expect(unknownPart.data, {'unhandled': {}});
});
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
// 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:firebase_ai/src/content.dart';
import 'package:firebase_vertexai/firebase_vertexai.dart';
import 'package:http/http.dart' as http;
import 'package:matcher/matcher.dart';
Expand All @@ -33,6 +34,8 @@ Matcher matchesPart(Part part) => switch (part) {
isA<FunctionResponse>()
.having((p) => p.name, 'name', name)
.having((p) => p.response, 'args', response),
UnknownPart(data: final data) =>
isA<UnknownPart>().having((p) => p.data, 'data', data),
};

Matcher matchesContent(Content content) => isA<Content>()
Expand Down
Loading