Skip to content

fix(ai): harden handling of unexpected server response parts - #16756

Open
paulb777 wants to merge 4 commits into
mainfrom
pb-harden-ai-templates
Open

paulb777 wants to merge 4 commits into
mainfrom
pb-harden-ai-templates

Conversation

@paulb777

@paulb777 paulb777 commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Context: #16728. A malformed server payload crashed every released version of
another SDK. Server prompt templates keep the model, prompt, schema and config
on the server, so the client has to handle any response shape. This PR audits
response decoding for traps and fixes the three crashes it found. It also
removes the remaining fatalError() paths in ModelContent.init(role:parts:).

Hardened code paths

  • InternalPart.encode(to:). Parts with missing or unrecognized data
    (for example, a part type the backend adds later) decode with data == nil.
    TemplateChat (unary and streaming) and Chat (streaming) keep these parts
    in history. On the next turn, encoding the history ran
    Optional.none.encode(to:) and then encoder.container(keyedBy:), which
    hits a preconditionFailure in JSONEncoder. The fix skips encoding
    data when it is nil, so thought and thoughtSignature still
    round-trip.
  • ModelContent.init(role:parts:). Every part type it didn't handle fell
    through to fatalError():
    • ExecutableCodePart and CodeExecutionResultPart. Chat.sendMessage
      rebuilds the model turn with this initializer, so a unary chat response
      containing code execution parts crashed. The fix adds the two missing
      cases.
    • A Part conformance defined by the app. It is now logged
      (modelContentUnsupportedPartType) and skipped.
    • The internal ErrorPart that an image produces when it can't be converted
      to JPEG (for example, UIImage() or CIImage.empty()). It is now kept as
      an internal .error case, so throwIfError() works again:
      generateContent, generateContentStream, Chat, countTokens and
      TemplateChat throw
      GenerateContentError.internalError(underlying: ImageConversionError…)
      without sending a request. throwIfError() wasn't called for template
      requests or countTokens before. Encoding an ErrorPart now throws an
      EncodingError, and == no longer traps, since ModelContent.parts can
      return one.
  • ProtoDuration decoding (Live GoAway.timeLeft). The fractional part
    went through Double("0.\(nanos)"), which accepts exponents. A value like
    "1.5e10s" then overflowed Int32(...). The fix requires the fractional
    part to be digits only; anything else throws the existing DecodingError.

The audit also checked these paths and found no trap: unknown enum values (they
already degrade to raw values), empty or missing candidates and parts
(accessors use .first), integer overflow and wrong types (they throw
DecodingError), malformed SSE lines and malformed error bodies (they throw).

Tests

These tests trapped before the fix (verified locally):

  • TemplateChatTests.testSendMessage_unrecognizedPartInResponse_nextTurnSucceeds
  • TemplateChatTests.testSendMessageStream_unrecognizedPartInResponse_nextTurnSucceeds
  • InternalPartTests.testEncodeUnsupportedPart_doesNotCrash
  • ChatTests.testSendMessage_unary_codeExecution_appendsHistory
  • ProtoDurationTests.testDecodeProtoDuration_nonDigitNanoseconds

These tests cover the other ModelContent changes. On main, each one hits a
fatalError() in ModelContent.init(role:parts:) or in ErrorPart:

  • PartTests.testModelContent_unsupportedPartType_isSkippedAndLogged
  • PartTests.testModelContent_errorPart_isKeptAndThrowIfErrorThrows
  • PartTests.testModelContent_errorPart_encodingThrows
  • PartTests.testModelContent_errorPart_equality
  • PartTests.testErrorPart_encodingAndDecodingThrow
  • GenerativeModelVertexAITests.testGenerateContent_failure_invalidImage
  • GenerativeModelVertexAITests.testGenerateContentStream_failure_imageConversionError
  • GenerativeModelVertexAITests.testCountTokens_failure_imageConversionError
  • TemplateChatTests.testSendMessage_imageConversionError_throwsWithoutSending
  • TemplateChatTests.testSendMessageStream_imageConversionError_throwsWithoutSending

These are coverage-only tests in TemplateGenerativeModelTests; they already
pass on main:

  • unrecognized enum values and fields
  • empty or missing candidate content
  • malformed JSON and wrong types, including token counts that overflow or
    are non-integer
  • malformed error bodies
  • malformed SSE lines after valid chunks
  • non-SSE stream bodies

A small GenerativeModelTestUtil.httpRequestHandler(body:statusCode:) helper
serves inline response bodies.

Testing

xcodebuild -scheme FirebaseAILogicUnit test -destination 'platform=macOS'
with mock responses from scripts/update_vertexai_responses.sh: 413
tests, 0 failures, 15 skipped.

Notes

  • After this change, a history part whose data is unrecognized is sent back
    as {} (or {"thoughtSignature": ...}) instead of crashing. The backend
    may reject an empty part with an error. A follow-up could drop such parts
    from outgoing history; this PR leaves that out to keep the change minimal.
  • LiveSession.sendContent can't throw. A message containing an image that
    fails to convert now fails to encode and is logged
    (liveSessionFailedToEncodeClientMessage) and dropped, instead of crashing.

Encoding chat history that contained a part with missing or unrecognized
data received from the server trapped in JSONEncoder on the next turn
(TemplateChat unary and streaming, Chat streaming). Chat.sendMessage hit
fatalError in ModelContent.init when the response contained code
execution parts. A proto duration with an exponent in its fractional
part (e.g. "1.5e10s") overflowed Int32 while decoding.

Also add tests feeding malformed and unexpected template responses.

Refs #16728
@gemini-code-assist

Copy link
Copy Markdown
Contributor
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

@paulb777

paulb777 commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request addresses crashes related to unrecognized data or code execution parts in chat history, as well as malformed duration decoding. It adds support for ExecutableCodePart and CodeExecutionResultPart in ModelContent, prevents crashes when encoding nil data in InternalPart, and restricts ProtoDuration nanoseconds to ASCII digits to avoid overflow. The review feedback suggests replacing fatalError() with assertionFailure and logging when encountering unrecognized Part types to prevent production crashes.

Comment thread FirebaseAI/Sources/ModelContent.swift Outdated
Addresses review feedback: replace the fatalError() in ModelContent.init(role:parts:) for unrecognized Part types with an AILog error and skip the part. Part is a public protocol that apps can conform to, and the internal ErrorPart (returned for failed image conversions) also reached this branch, so both are runtime-reachable paths. ErrorPart is handled explicitly with its own message code.
@paulb777

paulb777 commented Oct 1, 2026

Copy link
Copy Markdown
Member Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request enhances the SDK's robustness against unexpected or malformed server responses. It prevents crashes by safely skipping and logging unsupported or failed part types (such as code execution parts) during ModelContent conversion, and avoids JSONEncoder traps by conditionally encoding optional data in InternalPart. Additionally, it hardens ProtoDuration decoding by validating that nanoseconds consist only of digits to prevent integer overflow. Comprehensive unit tests are added to verify these behaviors. The feedback suggests optimizing the digit validation check in ProtoDuration.swift to use a direct range check, avoiding the overhead of Unicode property lookups.

Comment thread FirebaseAI/Sources/Types/Internal/ProtoDuration.swift
Addresses review feedback on #16756: adds test cases for non-ASCII numerics (Arabic-Indic digit, vulgar fraction, superscript) and an ASCII digit with a combining mark, and clarifies that the existing isASCII && isNumber check only admits ASCII 0-9. The suggested ("0"..."9").contains($0) range check was not adopted because it also accepts grapheme clusters such as "1\u{301}".
@paulb777
paulb777 requested a review from andrewheard October 1, 2026 18:59
@paulb777 paulb777 added this to the 13.1.0 - M188 milestone Oct 1, 2026
Comment thread FirebaseAI/Sources/ModelContent.swift Outdated
Comment on lines +163 to +167
case let errorPart as ErrorPart:
AILog.error(
code: .modelContentPartConversionFailed,
"Skipping a part that failed to convert to model content: \(errorPart.error)"
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This was already broken before this PR but now has a different problem. Before it would call fatalError on images that can't be converted. I think the original approach (before InternalPart was added for thought signatures) was to call [ModelContent].throwIfError().

I think now passing an invalid image (e.g., UIImage() or CIImage.empty(), whose partsValue returns [ErrorPart(ImageConversionError...)] in PartsRepresentable+Image.swift) to model.generateContent("Describe this", invalidImage) silently drops the image and only sends the text prompt to the backend, or send an empty parts: [] array if the image was the only part. That's probably better than crashing but depends on whether it happens during development or in production.

Potentially we could keep the ErrorPart (something like a .error(ErrorPart) case on InternalPart.OneOfData) so throwIfError() can still throw ImageConversionError when making a request. I see it's also not called in TemplateGenerativeModel so that's another hole.

Feel free to add a TODO since it'd probably balloon the PR to address it here. As a stop-gap solution, could maybe add an assertionFailure so it'll still crash during development but only log during production (I'd need to double check that assertionFailure gets compiled out of release builds though).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch. It turned out smaller than expected, so I fixed it here in ccf2d7d:

  • OneOfData is only exhaustively switched on in the parts getter and encode(to:), so I added an internal .error(ErrorPart) case. ModelContent.init keeps the ErrorPart again, so throwIfError() works.
  • throwIfError() is now also called when building template requests (which covers TemplateChat) and in countTokens. It wraps the error, so generateContent("Describe this", UIImage()) throws GenerateContentError.internalError(underlying: ImageConversionError.couldNotConvertToJPEG) without sending a request.
  • Since ModelContent.parts can now return an ErrorPart, encoding one throws an EncodingError, and == compares error descriptions instead of calling fatalError.
  • LiveSession.sendContent can't throw, so a message with an invalid image now fails to encode and is logged and dropped as a whole, instead of being sent without the image.

On assertionFailure: it has no effect at -O, so release builds would only log. But SPM and CocoaPods compile FirebaseAI with the app's configuration, so it would trap in developers' Debug builds and unit tests on a failure that depends on app input rather than an SDK invariant. With the error thrown again, I left it out.

An image that can't be converted to JPEG (for example, `UIImage()` or
`CIImage.empty()`) produces an internal `ErrorPart`. Skipping it in
`ModelContent.init` meant `generateContent` silently sent the prompt
without the image, or an empty `parts` array.

Keep the `ErrorPart` as an internal `InternalPart.OneOfData.error` case
so `throwIfError()` rejects the request again, wrapped in
`GenerateContentError.internalError(underlying:)` to match the documented
`Throws:`. Call `throwIfError()` when building template requests, which
covers `TemplateChat`, and in `countTokens`; neither checked before.

`ModelContent.parts` can now return the `ErrorPart`, so make its encoding
throw an `EncodingError` and its `==` compare error descriptions instead
of calling `fatalError()`. A `LiveSession` message with such a part now
fails to encode and is logged instead of being sent without the image.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants