Repository: firebase/firebase-ios-sdk (language: cpp)

Issue:
[Firestore CI] cmake tests migration to macos-14
### Description

The cmake tests fail to migrate to macos-14 because Python 3.7 is not available.

### Reproducing the issue

Update firestore.yml to macos-14 and see the failure

### Firebase SDK Version

10.24.0

### Xcode Version

15.3

### Installation Method

N/A

### Firebase Product(s)

Firestore

### Targeted Platforms

All

### Relevant Log Output

_No response_

### If using Swift Package Manager, the project's Package.resolved

<!--- Look below for instructions on how to share your Package.resolved. --->

<details>
<summary>Expand <code>Package.resolved</code> snippet</summary>
<br>

```json

Replace this line with the contents of your Package.resolved.

```

</details>


### If using CocoaPods, the project's Podfile.lock

<!--- Look below for instructions on how to share your Podfile.lock. --->

<details>
<summary>Expand <code>Podfile.lock</code> snippet</summary>
<br>

```yml

Replace this line with the contents of your Podfile.lock!

```

</details>



Discussion / hints:

Fix patch:
diff --git a/.github/actions/notices_generation/Gemfile.lock b/.github/actions/notices_generation/Gemfile.lock
index 294d40f5067..a99250bd561 100644
--- a/.github/actions/notices_generation/Gemfile.lock
+++ b/.github/actions/notices_generation/Gemfile.lock
@@ -10,28 +10,85 @@ GEM
       tzinfo (~> 2.0)
     addressable (2.8.0)
       public_suffix (>= 2.0.2, < 5.0)
+    algoliasearch (1.27.5)
+      httpclient (~> 2.8, >= 2.8.3)
+      json (>= 1.5.1)
     atomos (0.1.3)
     claide (1.1.0)
+    cocoapods (1.12.1)
+      addressable (~> 2.8)
+      claide (>= 1.0.2, < 2.0)
+      cocoapods-core (= 1.12.1)
+      cocoapods-deintegrate (>= 1.0.3, < 2.0)
+      cocoapods-downloader (>= 1.6.0, < 2.0)
+      cocoapods-plugins (>= 1.0.0, < 2.0)
+      cocoapods-search (>= 1.0.0, < 2.0)
+      cocoapods-trunk (>= 1.6.0, < 2.0)
+      cocoapods-try (>= 1.1.0, < 2.0)
+      colored2 (~> 3.1)
+      escape (~> 0.0.4)
+      fourflusher (>= 2.3.0, < 3.0)
+      gh_inspector (~> 1.0)
+      molinillo (~> 0.8.0)
+      nap (~> 1.0)
+      ruby-macho (>= 2.3.0, < 3.0)
+      xcodeproj (>= 1.21.0, < 2.0)
+    cocoapods-core (1.12.1)
+      activesupport (>= 5.0, < 8)
+      addressable (~> 2.8)
+      algoliasearch (~> 1.0)
+      concurrent-ruby (~> 1.1)
+      fuzzy_match (~> 2.0.4)
+      nap (~> 1.0)
+      netrc (~> 0.11)
+      public_suffix (~> 4.0)
+      typhoeus (~> 1.0)
+    cocoapods-deintegrate (1.0.5)
+    cocoapods-downloader (1.6.3)
+    cocoapods-plugins (1.0.0)
+      nap
+    cocoapods-search (1.0.1)
+    cocoapods-trunk (1.6.0)
+      nap (>= 0.8, < 2.0)
+      netrc (~> 0.11)
+    cocoapods-try (1.2.0)
     colored2 (3.1.2)
     concurrent-ruby (1.2.2)
+    escape (0.0.4)
+    ethon (0.16.0)
+      ffi (>= 1.15.0)
     faraday (1.1.0)
       multipart-post (>= 1.2, < 3)
       ruby2_keywords
+    ffi (1.16.3)
+    fourflusher (2.3.1)
+    fuzzy_match (2.0.4)
+    gh_inspector (1.1.3)
+    httpclient (2.8.3)
     i18n (1.14.1)
       concurrent-ruby (~> 1.0)
+    json (2.7.2)
     minitest (5.20.0)
+    molinillo (0.8.0)
     multipart-post (2.1.1)
     nanaimo (0.3.0)
+    nap (1.1.0)
+    netrc (0.11.0)
     octokit (4.19.0)
       faraday (>= 0.9)
       sawyer (~> 0.8.0, >= 0.5.3)
     plist (3.6.0)
     public_suffix (4.0.6)
-    rexml (3.2.5)
+    rexml (3.2.8)
+      strscan (>= 3.0.9)
+    ruby-macho (2.5.1)
     ruby2_keywords (0.0.2)
     sawyer (0.8.2)
       addressable (>= 2.3.5)
       faraday (> 0.8, < 2.0)
+    strscan (3.1.0)
+    typhoeus (1.4.1)
+      ethon (>= 0.9.0)
     tzinfo (2.0.6)
       concurrent-ruby (~> 1.0)
     xcodeproj (1.21.0)
@@ -43,10 +100,11 @@ GEM
       rexml (~> 3.2.4)
 
 PLATFORMS
-  ruby
+  x86_64-linux
 
 DEPENDENCIES
   activesupport (= 7.0.8)
+  cocoapods
   octokit (~> 4.19)
   plist
   xcodeproj (~> 1.21)
diff --git a/.github/workflows/crashlytics.yml b/.github/workflows/crashlytics.yml
index 39fb5aee43a..a0e308d1941 100644
--- a/.github/workflows/crashlytics.yml
+++ b/.github/workflows/crashlytics.yml
@@ -102,7 +102,7 @@ jobs:
     - uses: nick-fields/retry@v3
       with:
         timeout_minutes: 120
-        max_attempts: 3
+        max_attempts: 5
         retry_on: error
         retry_wait_seconds: 120
         command: scripts/test_catalyst.sh FirebaseCrashlytics test FirebaseCrashlytics-Unit-unit
@@ -198,5 +198,10 @@ jobs:
     - uses: ruby/setup-ruby@v1
     - name: Setup Bundler
       run: scripts/setup_bundler.sh
-    - name: PodLibLint Auth Cron
-      run: scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseCrashlytics.podspec --platforms=${{ matrix.target }} ${{ matrix.flags }}
+    - uses: nick-fields/retry@v3
+      with:
+        timeout_minutes: 120
+        max_attempts: 3
+        retry_on: error
+        retry_wait_seconds: 120
+        command: scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseCrashlytics.podspec --platforms=${{ matrix.target }} ${{ matrix.flags }}
diff --git a/.github/workflows/dynamiclinks.yml b/.github/workflows/dynamiclinks.yml
index a04cefe68b1..f2679cc3fda 100644
--- a/.github/workflows/dynamiclinks.yml
+++ b/.github/workflows/dynamiclinks.yml
@@ -37,7 +37,7 @@ jobs:
     - name: Xcode
       run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer
     - name: FirebaseDynamicLinks
-      run: scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseDynamicLinks.podspec
+      run: scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseDynamicLinks.podspec --allow-warnings
 
   spm:
     # Don't run on private repo unless it is a PR.
@@ -80,7 +80,7 @@ jobs:
     - name: Setup Bundler
       run: scripts/setup_bundler.sh
     - name: PodLibLint Storage Cron
-      run: scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseDynamicLinks.podspec --platforms=ios ${{ matrix.flags }}
+      run: scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseDynamicLinks.podspec --platforms=ios ${{ matrix.flags }}  --allow-warnings
 
   quickstart:
     # Don't run on private repo unless it is a PR.
diff --git a/.github/workflows/firestore.yml b/.github/workflows/firestore.yml
index d74f6585955..a752bc57f5c 100644
--- a/.github/workflows/firestore.yml
+++ b/.github/workflows/firestore.yml
@@ -105,8 +105,7 @@ jobs:
       (github.event_name == 'pull_request' && needs.changes.outputs.changed == 'true')
     strategy:
       matrix:
-        # TODO(#12769): Update to macos-14 which doesn't include Python 3.7
-        os: [macos-12, ubuntu-latest]
+        os: [macos-14, ubuntu-latest]
 
     env:
       MINT_PATH: ${{ github.workspace }}/mint
@@ -132,7 +131,7 @@ jobs:
 
     - uses: actions/setup-python@v4
       with:
-        python-version: '3.7'
+        python-version: '3.10'
 
     - name: Setup build
       run:  scripts/install_prereqs.sh Firestore ${{ runner.os }} cmake
@@ -153,8 +152,7 @@ jobs:
 
     strategy:
       matrix:
-        # TODO(#12769): Update to macos-14 which doesn't include Python 3.7
-        os: [macos-12]
+        os: [macos-14]
         databaseId: [(default), test-db]
 
     env:
@@ -183,7 +181,7 @@ jobs:
 
     - uses: actions/setup-python@v4
       with:
-        python-version: '3.7'
+        python-version: '3.10'
 
     - name: Install Secret GoogleService-Info.plist
       run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/firestore.plist.gpg \
@@ -241,8 +239,7 @@ jobs:
 
     strategy:
       matrix:
-        # TODO(#12769): Update to macos-14 which doesn't include Python 3.7
-        os: [macos-12]
+        os: [macos-14]
         sanitizer: [asan, tsan]
 
     runs-on: ${{ matrix.os }}
@@ -263,7 +260,7 @@ jobs:
 
     - uses: actions/setup-python@v4
       with:
-        python-version: '3.7'
+        python-version: '3.10'
 
     - name: Setup build
       run:  scripts/install_prereqs.sh Firestore ${{ runner.os }} cmake
diff --git a/.github/workflows/sessions.yml b/.github/workflows/sessions.yml
index 1439b4f1d15..11ffd2f942d 100644
--- a/.github/workflows/sessions.yml
+++ b/.github/workflows/sessions.yml
@@ -41,10 +41,13 @@ jobs:
       run: scripts/setup_bundler.sh
     - name: Xcode
       run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer
-    - name: Build and test
-      run: |
-        scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseSessions.podspec \
-            --platforms=${{ matrix.target }} ${{ matrix.tests }}
+    - uses: nick-fields/retry@v3
+      with:
+        timeout_minutes: 120
+        max_attempts: 3
+        retry_on: error
+        retry_wait_seconds: 120
+        command: scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb FirebaseSessions.podspec --platforms=${{ matrix.target }} ${{ matrix.tests }}
 
   spm:
     # Don't run on private repo unless it is a PR.
@@ -71,8 +74,13 @@ jobs:
       run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer
     - name: Initialize xcodebuild
       run: scripts/setup_spm_tests.sh
-    - name: Unit Tests
-      run: scripts/third_party/travis/retry.sh ./scripts/build.sh FirebaseSessionsUnit ${{ matrix.target }} spm
+    - uses: nick-fields/retry@v3
+      with:
+        timeout_minutes: 120
+        max_attempts: 3
+        retry_on: error
+        retry_wait_seconds: 120
+        command: scripts/third_party/travis/retry.sh ./scripts/build.sh FirebaseSessionsUnit ${{ matrix.target }} spm
 
   catalyst:
     # Don't run on private repo unless it is a PR.
@@ -87,5 +95,10 @@ jobs:
     - uses: ruby/setup-ruby@v1
     - name: Setup Bundler
       run: scripts/setup_bundler.sh
-    - name: Setup project and Build for Catalyst
-      run: scripts/test_catalyst.sh FirebaseSessions test FirebaseSessions-Unit-unit
+    - uses: nick-fields/retry@v3
+      with:
+        timeout_minutes: 120
+        max_attempts: 3
+        retry_on: error
+        retry_wait_seconds: 120
+        command: scripts/test_catalyst.sh FirebaseSessions test FirebaseSessions-Unit-unit
diff --git a/.github/workflows/spm.yml b/.github/workflows/spm.yml
index 2628cde075c..7a180ebb240 100644
--- a/.github/workflows/spm.yml
+++ b/.github/workflows/spm.yml
@@ -88,6 +88,8 @@ jobs:
     strategy:
       matrix:
         # Full set of Firebase-Package tests only run on iOS. Run subset on other platforms.
+        # visionOS isn't buildable from here (even with Firestore source) because the test
+        # targets need Analytics.
         target: [tvOS, macOS, catalyst]
         os: [macos-13, macos-14]
         include:
@@ -95,9 +97,6 @@ jobs:
             xcode: Xcode_15.2
           - os: macos-14
             xcode: Xcode_15.3
-          - os: macos-14
-            target: visionOS
-            xcode: Xcode_15.3
     runs-on: ${{ matrix.os }}
     steps:
     - uses: actions/checkout@v4
diff --git a/.github/workflows/vertexai.yml b/.github/workflows/vertexai.yml
new file mode 100644
index 00000000000..da827b50912
--- /dev/null
+++ b/.github/workflows/vertexai.yml
@@ -0,0 +1,70 @@
+name: vertexai
+
+on:
+  pull_request:
+    paths:
+    - 'FirebaseVertexAI**'
+    - '.github/workflows/vertexai.yml'
+    - 'Gemfile*'
+  schedule:
+    # Run every day at 11pm (PST) - cron uses UTC times
+    - cron:  '0 7 * * *'
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  spm:
+    strategy:
+      matrix:
+        target: [iOS, macOS, catalyst]
+        os: [macos-13]
+        include:
+          - os: macos-13
+            xcode: Xcode_15.2
+    runs-on: ${{ matrix.os }}
+    env:
+      FIREBASECI_USE_LATEST_GOOGLEAPPMEASUREMENT: 1
+    steps:
+    - uses: actions/checkout@v4
+    - name: Xcode
+      run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer
+    - name: Initialize xcodebuild
+      run: scripts/setup_spm_tests.sh
+    - uses: nick-fields/retry@v3
+      with:
+        timeout_minutes: 120
+        max_attempts: 3
+        retry_on: error
+        retry_wait_seconds: 120
+        command: scripts/build.sh FirebaseVertexAIUnit ${{ matrix.target }} spm
+
+  sample:
+    strategy:
+      matrix:
+        # Test build with debug and release configs (whether or not DEBUG is set and optimization level)
+        build: [build]
+        include:
+          - os: macos-13
+            xcode: Xcode_15.0.1
+          - os: macos-14
+            xcode: Xcode_15.2
+    runs-on: ${{ matrix.os }}
+    env:
+      FIREBASECI_USE_LATEST_GOOGLEAPPMEASUREMENT: 1
+    steps:
+    - uses: actions/checkout@v4
+    - name: Xcode
+      run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer
+    - name: Initialize xcodebuild
+      run: xcodebuild -list
+    - name: Placeholder GoogleService-Info.plist for build testing
+      run: cp FirebaseCore/Tests/Unit/Resources/GoogleService-Info.plist FirebaseVertexAI/Sample/
+    - uses: nick-fields/retry@v3
+      with:
+        timeout_minutes: 120
+        max_attempts: 3
+        retry_on: error
+        retry_wait_seconds: 120
+        command: scripts/build.sh VertexSample iOS
diff --git a/.github/workflows/zip.yml b/.github/workflows/zip.yml
index 606a1acdc39..fb4d056edfe 100644
--- a/.github/workflows/zip.yml
+++ b/.github/workflows/zip.yml
@@ -354,9 +354,6 @@ jobs:
         os: [macos-13]
         xcode: [Xcode_15.2]
         artifact: [Firebase-actions-dir, Firebase-actions-dir-dynamic]
-          # TODO: Building FirebaseUI fails on Xcode 15 because it needs to sign the resources.
-          # - os: macos-13
-          #   xcode: Xcode_15.2
     runs-on: ${{ matrix.os }}
     steps:
     - uses: actions/checkout@v4
@@ -456,56 +453,59 @@ jobs:
         name: quickstart_artifacts_dynamiclinks
         path: quickstart-ios/
 
-  # quickstart_framework_firestore:
-  #   # Don't run on private repo.
-  #   if: (github.repository == 'Firebase/firebase-ios-sdk' && github.event_name == 'schedule') || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
-  #   needs: package-head
-  #   env:
-  #     plist_secret: ${{ secrets.GHASecretsGPGPassphrase1 }}
-  #     signin_secret: ${{ secrets.GHASecretsGPGPassphrase1 }}
-  #     SDK: "Firestore"
-  #   strategy:
-  #     matrix:
-  #       # TODO: Building FirebaseUI fails on Xcode 15 because it needs to sign the resources.
-  #       os: [macos-13]
-  #       artifact: [Firebase-actions-dir, Firebase-actions-dir-dynamic]
-  #       xcode: [Xcode_15.2]
-  #   runs-on: ${{ matrix.os }}
-  #   steps:
-  #   - uses: actions/checkout@v4
-  #   - name: Get framework dir
-  #     uses: actions/download-artifact@v4
-  #     with:
-  #       name: ${{ matrix.artifact }}
-  #   - uses: ruby/setup-ruby@v1
-  #   - name: Setup Bundler
-  #     run: ./scripts/setup_bundler.sh
-  #   - name: Move frameworks
-  #     run: |
-  #       mkdir -p "${HOME}"/ios_frameworks/
-  #       find "${GITHUB_WORKSPACE}" -name "Firebase*latest.zip" -exec unzip -d "${HOME}"/ios_frameworks/ {} +
-  #   - uses: actions/checkout@v4
-  #   - name: Setup quickstart
-  #     run: SAMPLE="$SDK" TARGET="${SDK}Example" NON_FIREBASE_SDKS="SDWebImage FirebaseAuthUI FirebaseEmailAuthUI" scripts/setup_quickstart_framework.sh \
-  #                                              "${HOME}"/ios_frameworks/Firebase/NonFirebaseSDKs/* \
-  #                                              "${HOME}"/ios_frameworks/Firebase/FirebaseFirestore/* \
-  #                                              "${HOME}"/ios_frameworks/Firebase/FirebaseAuth/* \
-  #                                              "${HOME}"/ios_frameworks/Firebase/FirebaseAnalytics/*
-  #   - name: Xcode
-  #     run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer
-  #   - name: Install Secret GoogleService-Info.plist
-  #     run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/qs-firestore.plist.gpg \
-  #       quickstart-ios/firestore/GoogleService-Info.plist "$plist_secret"
-  #   - name: Test Quickstart
-  #     run: ([ -z $plist_secret ] || scripts/third_party/travis/retry.sh scripts/test_quickstart_framework.sh "${SDK}")
-  #   - name: Remove data before upload
-  #     if: ${{ failure() }}
-  #     run: scripts/remove_data.sh firestore
-  #   - uses: actions/upload-artifact@v4
-  #     if: ${{ failure() }}
-  #     with:
-  #       name: quickstart_artifacts_firestore
-  #       path: quickstart-ios/
+  quickstart_framework_firestore:
+    # Don't run on private repo.
+    if: (github.repository == 'Firebase/firebase-ios-sdk' && github.event_name == 'schedule') || github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
+    needs: package-head
+    env:
+      plist_secret: ${{ secrets.GHASecretsGPGPassphrase1 }}
+      signin_secret: ${{ secrets.GHASecretsGPGPassphrase1 }}
+      SDK: "Firestore"
+    strategy:
+      matrix:
+        os: [macos-13, macos-14]
+        artifact: [Firebase-actions-dir, Firebase-actions-dir-dynamic]
+        include:
+          - os: macos-13
+            xcode: Xcode_15.2
+          - os: macos-14
+            xcode: Xcode_15.3
+    runs-on: ${{ matrix.os }}
+    steps:
+    - uses: actions/checkout@v4
+    - name: Get framework dir
+      uses: actions/download-artifact@v4
+      with:
+        name: ${{ matrix.artifact }}
+    - uses: ruby/setup-ruby@v1
+    - name: Setup Bundler
+      run: ./scripts/setup_bundler.sh
+    - name: Move frameworks
+      run: |
+        mkdir -p "${HOME}"/ios_frameworks/
+        find "${GITHUB_WORKSPACE}" -name "Firebase*latest.zip" -exec unzip -d "${HOME}"/ios_frameworks/ {} +
+    - uses: actions/checkout@v4
+    - name: Setup quickstart
+      run: SAMPLE="$SDK" TARGET="${SDK}Example" NON_FIREBASE_SDKS="SDWebImage FirebaseAuthUI FirebaseEmailAuthUI" scripts/setup_quickstart_framework.sh \
+                                               "${HOME}"/ios_frameworks/Firebase/NonFirebaseSDKs/* \
+                                               "${HOME}"/ios_frameworks/Firebase/FirebaseFirestore/* \
+                                               "${HOME}"/ios_frameworks/Firebase/FirebaseAuth/* \
+                                               "${HOME}"/ios_frameworks/Firebase/FirebaseAnalytics/*
+    - name: Xcode
+      run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer
+    - name: Install Secret GoogleService-Info.plist
+      run: scripts/decrypt_gha_secret.sh scripts/gha-encrypted/qs-firestore.plist.gpg \
+        quickstart-ios/firestore/GoogleService-Info.plist "$plist_secret"
+    - name: Test Quickstart
+      run: ([ -z $plist_secret ] || scripts/third_party/travis/retry.sh scripts/test_quickstart_framework.sh "${SDK}")
+    - name: Remove data before upload
+      if: ${{ failure() }}
+      run: scripts/remove_data.sh firestore
+    - uses: actions/upload-artifact@v4
+      if: ${{ failure() }}
+      with:
+        name: quickstart_artifacts_firestore
+        path: quickstart-ios/
 
   check_framework_firestore_symbols:
     # Don't run on private repo.
diff --git a/Crashlytics/CHANGELOG.md b/Crashlytics/CHANGELOG.md
index f65bacfc228..6e6fbb5def5 100644
--- a/Crashlytics/CHANGELOG.md
+++ b/Crashlytics/CHANGELOG.md
@@ -1,5 +1,6 @@
-# Unreleased
+# 10.27.0
 - [added] Added support for catching the SIGTERM signal (#12881).
+- [fixed] Fixed a hang when persisting Remote Config Rollouts to disk (#12913).
 
 # 10.25.0
 - [changed] Removed usages of user defaults API from internal Firebase Sessions
diff --git a/Crashlytics/Crashlytics/Controllers/FIRCLSRolloutsPersistenceManager.m b/Crashlytics/Crashlytics/Controllers/FIRCLSRolloutsPersistenceManager.m
index defd9d68a52..57993a965f3 100644
--- a/Crashlytics/Crashlytics/Controllers/FIRCLSRolloutsPersistenceManager.m
+++ b/Crashlytics/Crashlytics/Controllers/FIRCLSRolloutsPersistenceManager.m
@@ -57,12 +57,13 @@ - (void)updateRolloutsStateToPersistenceWithRollouts:(NSData *_Nonnull)rollouts
 
   NSFileHandle *rolloutsFile = [NSFileHandle fileHandleForUpdatingAtPath:rolloutsPath];
 
-  dispatch_sync(FIRCLSGetLoggingQueue(), ^{
+  dispatch_async(FIRCLSGetLoggingQueue(), ^{
     @try {
       [rolloutsFile seekToEndOfFile];
       NSMutableData *rolloutsWithNewLineData = [rollouts mutableCopy];
       [rolloutsWithNewLineData appendData:[@"\n" dataUsingEncoding:NSUTF8StringEncoding]];
       [rolloutsFile writeData:rolloutsWithNewLineData];
+      [rolloutsFile closeFile];
     } @catch (NSException *exception) {
       FIRCLSDebugLog(@"Failed to write new rollouts. Exception name: %s - message: %s",
                      exception.name, exception.reason);
diff --git a/Crashlytics/UnitTests/FIRCLSRolloutsPersistenceManagerTests.m b/Crashlytics/UnitTests/FIRCLSRolloutsPersistenceManagerTests.m
index aec030d7538..4caceb242b1 100644
--- a/Crashlytics/UnitTests/FIRCLSRolloutsPersistenceManagerTests.m
+++ b/Crashlytics/UnitTests/FIRCLSRolloutsPersistenceManagerTests.m
@@ -16,6 +16,7 @@
 #import <XCTest/XCTest.h>
 
 #import "Crashlytics/Crashlytics/Components/FIRCLSContext.h"
+#include "Crashlytics/Crashlytics/Components/FIRCLSGlobals.h"
 #import "Crashlytics/Crashlytics/Controllers/FIRCLSRolloutsPersistenceManager.h"
 #import "Crashlytics/Crashlytics/Models/FIRCLSInternalReport.h"
 #import "Crashlytics/UnitTests/Mocks/FIRCLSTempMockFileManager.h"
@@ -51,6 +52,9 @@ - (void)tearDown {
 }
 
 - (void)testUpdateRolloutsStateToPersistenceWithRollouts {
+  XCTestExpectation *expectation = [[XCTestExpectation alloc]
+      initWithDescription:@"Expect updating rollouts to finish writing."];
+
   NSString *encodedStateString =
       @"{rollouts:[{\"parameter_key\":\"6d795f66656174757265\",\"parameter_value\":"
       @"\"e8bf99e698af7468656d6973e79a84e6b58be8af95e695b0e68daeefbc8ce8be93e585a5e4b8ade69687\","
@@ -64,7 +68,43 @@ - (void)testUpdateRolloutsStateToPersistenceWithRollouts {
 
   [self.rolloutsPersistenceManager updateRolloutsStateToPersistenceWithRollouts:data
                                                                        reportID:reportId];
+
+  // Wait for the logging queue to finish.
+  dispatch_async(FIRCLSGetLoggingQueue(), ^{
+    [expectation fulfill];
+  });
+
+  [self waitForExpectations:@[ expectation ] timeout:3];
+
   XCTAssertTrue([[NSFileManager defaultManager] fileExistsAtPath:rolloutsFilePath]);
 }
 
+- (void)testUpdateRolloutsStateToPersistenceEnsureNoHang {
+  dispatch_queue_t testQueue = dispatch_queue_create("TestQueue", DISPATCH_QUEUE_SERIAL);
+  XCTestExpectation *expectation =
+      [[XCTestExpectation alloc] initWithDescription:@"Expect updating rollouts to return."];
+  NSString *encodedStateString =
+      @"{rollouts:[{\"parameter_key\":\"6d795f66656174757265\",\"parameter_value\":"
+      @"\"e8bf99e698af7468656d6973e79a84e6b58be8af95e695b0e68daeefbc8ce8be93e585a5e4b8ade69687\","
+      @"\"rollout_id\":\"726f6c6c6f75745f31\",\"template_version\":1,\"variant_id\":"
+      @"\"636f6e74726f6c\"}]}";
+
+  NSData *data = [encodedStateString dataUsingEncoding:NSUTF8StringEncoding];
+
+  // Clog up the queue with a long running operation. This sleep time
+  // must be longer than the expectation timeout.
+  dispatch_async(FIRCLSGetLoggingQueue(), ^{
+    sleep(10);
+  });
+
+  dispatch_async(testQueue, ^{
+    // Ensure that calling this returns quickly so we don't hang
+    [self.rolloutsPersistenceManager updateRolloutsStateToPersistenceWithRollouts:data
+                                                                         reportID:reportId];
+    [expectation fulfill];
+  });
+
+  [self waitForExpectations:@[ expectation ] timeout:3];
+}
+
 @end
diff --git a/Dangerfile b/Dangerfile
index 9579b574add..87b999c2c86 100644
--- a/Dangerfile
+++ b/Dangerfile
@@ -64,6 +64,7 @@ def labelsForModifiedFiles()
   labels.push("api: performance") if @has_performance_changes
   labels.push("api: remoteconfig") if @has_remoteconfig_changes
   labels.push("api: storage") if @has_storage_changes
+  labels.push("api: vertexai") if @has_vertexai_changes
   labels.push("release-tooling") if @has_releasetooling_changes
   labels.push("public-api-change") if @has_api_changes
   return labels
@@ -100,7 +101,8 @@ has_license_changes = didModify(["LICENSE"])
   "Messaging",
   "Performance",
   "RemoteConfig",
-  "Storage"
+  "Storage",
+  "VertexAI"
 ]
 
 ## Product directories
@@ -149,6 +151,7 @@ has_license_changes = didModify(["LICENSE"])
 @has_remoteconfig_changes = hasChangesIn("FirebaseRemoteConfig")
 @has_remoteconfig_api_changes = hasChangesIn("FirebaseRemoteConfig/Sources/Public/")
 @has_storage_changes = hasChangesIn("FirebaseStorage")
+@has_vertexai_changes = hasChangesIn("FirebaseVertexAI")
 
 @has_releasetooling_changes = hasChangesIn("ReleaseTooling/")
 @has_public_additions = hasAdditionsIn("Public/")
diff --git a/Firebase.podspec b/Firebase.podspec
index dfbed0c2b8d..b037baaa82f 100644
--- a/Firebase.podspec
+++ b/Firebase.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'Firebase'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Firebase'
 
   s.description      = <<-DESC
@@ -36,14 +36,14 @@ Simplify your app development, grow your user base, and monetize more effectivel
     ss.ios.deployment_target = '10.0'
     ss.osx.deployment_target = '10.13'
     ss.tvos.deployment_target = '12.0'
-    ss.ios.dependency 'FirebaseAnalytics', '~> 10.27.0'
-    ss.osx.dependency 'FirebaseAnalytics', '~> 10.27.0'
-    ss.tvos.dependency 'FirebaseAnalytics', '~> 10.27.0'
+    ss.ios.dependency 'FirebaseAnalytics', '~> 10.28.0'
+    ss.osx.dependency 'FirebaseAnalytics', '~> 10.28.0'
+    ss.tvos.dependency 'FirebaseAnalytics', '~> 10.28.0'
     ss.dependency 'Firebase/CoreOnly'
   end
 
   s.subspec 'CoreOnly' do |ss|
-    ss.dependency 'FirebaseCore', '10.27.0'
+    ss.dependency 'FirebaseCore', '10.28.0'
     ss.source_files = 'CoreOnly/Sources/Firebase.h'
     ss.preserve_paths = 'CoreOnly/Sources/module.modulemap'
     if ENV['FIREBASE_POD_REPO_FOR_DEV_POD'] then
@@ -79,13 +79,13 @@ Simplify your app development, grow your user base, and monetize more effectivel
     ss.ios.deployment_target = '10.0'
     ss.osx.deployment_target = '10.13'
     ss.tvos.deployment_target = '12.0'
-    ss.dependency 'FirebaseAnalytics/WithoutAdIdSupport', '~> 10.27.0'
+    ss.dependency 'FirebaseAnalytics/WithoutAdIdSupport', '~> 10.28.0'
     ss.dependency 'Firebase/CoreOnly'
   end
 
   s.subspec 'ABTesting' do |ss|
     ss.dependency 'Firebase/CoreOnly'
-    ss.dependency 'FirebaseABTesting', '~> 10.27.0'
+    ss.dependency 'FirebaseABTesting', '~> 10.28.0'
     # Standard platforms PLUS watchOS.
     ss.ios.deployment_target = '11.0'
     ss.osx.deployment_target = '10.13'
@@ -95,13 +95,13 @@ Simplify your app development, grow your user base, and monetize more effectivel
 
   s.subspec 'AppDistribution' do |ss|
     ss.dependency 'Firebase/CoreOnly'
-    ss.ios.dependency 'FirebaseAppDistribution', '~> 10.27.0-beta'
+    ss.ios.dependency 'FirebaseAppDistribution', '~> 10.28.0-beta'
     ss.ios.deployment_target = '11.0'
   end
 
   s.subspec 'AppCheck' do |ss|
     ss.dependency 'Firebase/CoreOnly'
-    ss.dependency 'FirebaseAppCheck', '~> 10.27.0'
+    ss.dependency 'FirebaseAppCheck', '~> 10.28.0'
     ss.ios.deployment_target = '11.0'
     ss.osx.deployment_target = '10.13'
     ss.tvos.deployment_target = '12.0'
@@ -110,7 +110,7 @@ Simplify your app development, grow your user base, and monetize more effectivel
 
   s.subspec 'Auth' do |ss|
     ss.dependency 'Firebase/CoreOnly'
-    ss.dependency 'FirebaseAuth', '~> 10.27.0'
+    ss.dependency 'FirebaseAuth', '~> 10.28.0'
     # Standard platforms PLUS watchOS.
     ss.ios.deployment_target = '11.0'
     ss.osx.deployment_target = '10.13'
@@ -120,7 +120,7 @@ Simplify your app development, grow your user base, and monetize more effectivel
 
   s.subspec 'Crashlytics' do |ss|
     ss.dependency 'Firebase/CoreOnly'
-    ss.dependency 'FirebaseCrashlytics', '~> 10.27.0'
+    ss.dependency 'FirebaseCrashlytics', '~> 10.28.0'
     # Standard platforms PLUS watchOS.
     ss.ios.deployment_target = '11.0'
     ss.osx.deployment_target = '10.13'
@@ -130,7 +130,7 @@ Simplify your app development, grow your user base, and monetize more effectivel
 
   s.subspec 'Database' do |ss|
     ss.dependency 'Firebase/CoreOnly'
-    ss.dependency 'FirebaseDatabase', '~> 10.27.0'
+    ss.dependency 'FirebaseDatabase', '~> 10.28.0'
     # Standard platforms PLUS watchOS 7.
     ss.ios.deployment_target = '11.0'
     ss.osx.deployment_target = '10.13'
@@ -140,13 +140,13 @@ Simplify your app development, grow your user base, and monetize more effectivel
 
   s.subspec 'DynamicLinks' do |ss|
     ss.dependency 'Firebase/CoreOnly'
-    ss.ios.dependency 'FirebaseDynamicLinks', '~> 10.27.0'
+    ss.ios.dependency 'FirebaseDynamicLinks', '~> 10.28.0'
     ss.ios.deployment_target = '11.0'
   end
 
   s.subspec 'Firestore' do |ss|
     ss.dependency 'Firebase/CoreOnly'
-    ss.dependency 'FirebaseFirestore', '~> 10.27.0'
+    ss.dependency 'FirebaseFirestore', '~> 10.28.0'
     ss.ios.deployment_target = '11.0'
     ss.osx.deployment_target = '10.13'
     ss.tvos.deployment_target = '12.0'
@@ -154,7 +154,7 @@ Simplify your app development, grow your user base, and monetize more effectivel
 
   s.subspec 'Functions' do |ss|
     ss.dependency 'Firebase/CoreOnly'
-    ss.dependency 'FirebaseFunctions', '~> 10.27.0'
+    ss.dependency 'FirebaseFunctions', '~> 10.28.0'
     # Standard platforms PLUS watchOS.
     ss.ios.deployment_target = '11.0'
     ss.osx.deployment_target = '10.13'
@@ -164,20 +164,20 @@ Simplify your app development, grow your user base, and monetize more effectivel
 
   s.subspec 'InAppMessaging' do |ss|
     ss.dependency 'Firebase/CoreOnly'
-    ss.ios.dependency 'FirebaseInAppMessaging', '~> 10.27.0-beta'
-    ss.tvos.dependency 'FirebaseInAppMessaging', '~> 10.27.0-beta'
+    ss.ios.dependency 'FirebaseInAppMessaging', '~> 10.28.0-beta'
+    ss.tvos.dependency 'FirebaseInAppMessaging', '~> 10.28.0-beta'
     ss.ios.deployment_target = '11.0'
     ss.tvos.deployment_target = '12.0'
   end
 
   s.subspec 'Installations' do |ss|
     ss.dependency 'Firebase/CoreOnly'
-    ss.dependency 'FirebaseInstallations', '~> 10.27.0'
+    ss.dependency 'FirebaseInstallations', '~> 10.28.0'
   end
 
   s.subspec 'Messaging' do |ss|
     ss.dependency 'Firebase/CoreOnly'
-    ss.dependency 'FirebaseMessaging', '~> 10.27.0'
+    ss.dependency 'FirebaseMessaging', '~> 10.28.0'
     # Standard platforms PLUS watchOS.
     ss.ios.deployment_target = '11.0'
     ss.osx.deployment_target = '10.13'
@@ -187,7 +187,7 @@ Simplify your app development, grow your user base, and monetize more effectivel
 
   s.subspec 'MLModelDownloader' do |ss|
     ss.dependency 'Firebase/CoreOnly'
-    ss.dependency 'FirebaseMLModelDownloader', '~> 10.27.0-beta'
+    ss.dependency 'FirebaseMLModelDownloader', '~> 10.28.0-beta'
     # Standard platforms PLUS watchOS.
     ss.ios.deployment_target = '11.0'
     ss.osx.deployment_target = '10.13'
@@ -197,15 +197,15 @@ Simplify your app development, grow your user base, and monetize more effectivel
 
   s.subspec 'Performance' do |ss|
     ss.dependency 'Firebase/CoreOnly'
-    ss.ios.dependency 'FirebasePerformance', '~> 10.27.0'
-    ss.tvos.dependency 'FirebasePerformance', '~> 10.27.0'
+    ss.ios.dependency 'FirebasePerformance', '~> 10.28.0'
+    ss.tvos.dependency 'FirebasePerformance', '~> 10.28.0'
     ss.ios.deployment_target = '11.0'
     ss.tvos.deployment_target = '12.0'
   end
 
   s.subspec 'RemoteConfig' do |ss|
     ss.dependency 'Firebase/CoreOnly'
-    ss.dependency 'FirebaseRemoteConfig', '~> 10.27.0'
+    ss.dependency 'FirebaseRemoteConfig', '~> 10.28.0'
     # Standard platforms PLUS watchOS.
     ss.ios.deployment_target = '11.0'
     ss.osx.deployment_target = '10.13'
@@ -215,7 +215,7 @@ Simplify your app development, grow your user base, and monetize more effectivel
 
   s.subspec 'Storage' do |ss|
     ss.dependency 'Firebase/CoreOnly'
-    ss.dependency 'FirebaseStorage', '~> 10.27.0'
+    ss.dependency 'FirebaseStorage', '~> 10.28.0'
     # Standard platforms PLUS watchOS.
     ss.ios.deployment_target = '11.0'
     ss.osx.deployment_target = '10.13'
diff --git a/FirebaseABTesting.podspec b/FirebaseABTesting.podspec
index 46ae80fe177..e8cc306f46c 100644
--- a/FirebaseABTesting.podspec
+++ b/FirebaseABTesting.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseABTesting'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Firebase ABTesting'
 
   s.description      = <<-DESC
diff --git a/FirebaseAnalytics.podspec b/FirebaseAnalytics.podspec
index 1d9c0b5bfb7..73b53744aa3 100644
--- a/FirebaseAnalytics.podspec
+++ b/FirebaseAnalytics.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
     s.name             = 'FirebaseAnalytics'
-    s.version          = '10.27.0'
+    s.version          = '10.28.0'
     s.summary          = 'Firebase Analytics for iOS'
 
     s.description      = <<-DESC
@@ -13,7 +13,7 @@ Pod::Spec.new do |s|
     s.authors          = 'Google, Inc.'
 
     s.source           = {
-        :http => 'https://dl.google.com/firebase/ios/analytics/4b9dbef45221ba96/FirebaseAnalytics-10.25.0.tar.gz'
+        :http => 'https://dl.google.com/firebase/ios/analytics/069659a8d012c35e/FirebaseAnalytics-10.27.0.tar.gz'
     }
 
     s.cocoapods_version = '>= 1.12.0'
@@ -37,12 +37,12 @@ Pod::Spec.new do |s|
     s.default_subspecs = 'AdIdSupport'
 
     s.subspec 'AdIdSupport' do |ss|
-        ss.dependency 'GoogleAppMeasurement', '10.27.0'
+        ss.dependency 'GoogleAppMeasurement', '10.28.0'
         ss.vendored_frameworks = 'Frameworks/FirebaseAnalytics.xcframework'
     end
 
     s.subspec 'WithoutAdIdSupport' do |ss|
-        ss.dependency 'GoogleAppMeasurement/WithoutAdIdSupport', '10.27.0'
+        ss.dependency 'GoogleAppMeasurement/WithoutAdIdSupport', '10.28.0'
         ss.vendored_frameworks = 'Frameworks/FirebaseAnalytics.xcframework'
     end
 
diff --git a/FirebaseAnalyticsOnDeviceConversion.podspec b/FirebaseAnalyticsOnDeviceConversion.podspec
index 03dd329904b..136ce4b8846 100644
--- a/FirebaseAnalyticsOnDeviceConversion.podspec
+++ b/FirebaseAnalyticsOnDeviceConversion.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
     s.name             = 'FirebaseAnalyticsOnDeviceConversion'
-    s.version          = '10.27.0'
+    s.version          = '10.28.0'
     s.summary          = 'On device conversion measurement plugin for FirebaseAnalytics. Not intended for direct use.'
 
     s.description      = <<-DESC
@@ -18,7 +18,7 @@ Pod::Spec.new do |s|
 
     s.cocoapods_version = '>= 1.12.0'
 
-    s.dependency 'GoogleAppMeasurementOnDeviceConversion', '10.27.0'
+    s.dependency 'GoogleAppMeasurementOnDeviceConversion', '10.28.0'
 
     s.static_framework = true
 
diff --git a/FirebaseAppCheck.podspec b/FirebaseAppCheck.podspec
index 00168979f17..b47e53b176c 100644
--- a/FirebaseAppCheck.podspec
+++ b/FirebaseAppCheck.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseAppCheck'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Firebase App Check SDK.'
 
   s.description      = <<-DESC
@@ -46,7 +46,7 @@ Pod::Spec.new do |s|
 
   s.dependency 'AppCheckCore', '~> 10.19'
   s.dependency 'FirebaseAppCheckInterop', '~> 10.17'
-  s.dependency 'FirebaseCore', '~> 10.0'
+  s.dependency 'FirebaseCore', '~> 10.18'
   s.dependency 'PromisesObjC', '~> 2.1'
   s.dependency 'GoogleUtilities/Environment', '~> 7.13'
   s.dependency 'GoogleUtilities/UserDefaults', '~> 7.13'
diff --git a/FirebaseAppCheck/CHANGELOG.md b/FirebaseAppCheck/CHANGELOG.md
index 6876bfbaf4d..84f16f1468f 100644
--- a/FirebaseAppCheck/CHANGELOG.md
+++ b/FirebaseAppCheck/CHANGELOG.md
@@ -1,3 +1,6 @@
+# 10.27.0
+- [fixed] [CocoaPods] missing symbol error for FIRGetLoggerLevel. (#12899)
+
 # 10.25.0
 - [changed] Removed usages of user defaults API to eliminate required reason impact.
 
diff --git a/FirebaseAppCheckInterop.podspec b/FirebaseAppCheckInterop.podspec
index a957d166efb..8301d166818 100644
--- a/FirebaseAppCheckInterop.podspec
+++ b/FirebaseAppCheckInterop.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseAppCheckInterop'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Interfaces that allow other Firebase SDKs to use AppCheck functionality.'
 
   s.description      = <<-DESC
diff --git a/FirebaseAppDistribution.podspec b/FirebaseAppDistribution.podspec
index 2fad9cead8d..4d588e1075c 100644
--- a/FirebaseAppDistribution.podspec
+++ b/FirebaseAppDistribution.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseAppDistribution'
-  s.version          = '10.27.0-beta'
+  s.version          = '10.28.0-beta'
   s.summary          = 'App Distribution for Firebase iOS SDK.'
 
   s.description      = <<-DESC
diff --git a/FirebaseAppDistribution/Sources/FIRAppDistributionUIService.m b/FirebaseAppDistribution/Sources/FIRAppDistributionUIService.m
index f99fd752a80..2dc8abf4945 100644
--- a/FirebaseAppDistribution/Sources/FIRAppDistributionUIService.m
+++ b/FirebaseAppDistribution/Sources/FIRAppDistributionUIService.m
@@ -23,13 +23,10 @@
 
 @implementation FIRAppDistributionUIService
 
-API_AVAILABLE(ios(9.0))
-SFSafariViewController *_safariVC;
-
 API_AVAILABLE(ios(12.0))
 ASWebAuthenticationSession *_webAuthenticationVC;
 
-API_AVAILABLE(ios(11.0))
+// TODO: Remove this when the deployment target becomes iOS 12+
 SFAuthenticationSession *_safariAuthenticationVC;
 
 - (instancetype)init {
@@ -74,7 +71,7 @@ + (NSError *_Nullable)mapErrorToAppDistributionError:(NSError *_Nullable)error {
     if ([error code] == ASWebAuthenticationSessionErrorCodeCanceledLogin) {
       return [self getAppDistributionError:FIRAppDistributionErrorAuthenticationCancelled];
     }
-  } else if (@available(iOS 11.0, *)) {
+  } else {
     if ([error code] == SFAuthenticationErrorCanceledLogin) {
       return [self getAppDistributionError:FIRAppDistributionErrorAuthenticationCancelled];
     }
@@ -110,7 +107,7 @@ - (void)appDistributionRegistrationFlow:(NSURL *)URL
     _webAuthenticationVC = authenticationVC;
 
     [authenticationVC start];
-  } else if (@available(iOS 11.0, *)) {
+  } else {
     _safariAuthenticationVC = [[SFAuthenticationSession alloc]
               initWithURL:URL
         callbackURLScheme:callbackURL
@@ -123,13 +120,6 @@ - (void)appDistributionRegistrationFlow:(NSURL *)URL
         }];
 
     [_safariAuthenticationVC start];
-  } else if (@available(iOS 9.0, *)) {
-    SFSafariViewController *safariVC = [[SFSafariViewController alloc] initWithURL:URL];
-
-    safariVC.delegate = self;
-    _safariVC = safariVC;
-    [self->_safariHostingViewController presentViewController:safariVC animated:YES completion:nil];
-    self.registrationFlowCompletion = completion;
   }
 }
 
@@ -244,16 +234,16 @@ - (void)resetUIState {
 
   self.registrationFlowCompletion = nil;
 
-  if (@available(iOS 11.0, *)) {
-    _safariAuthenticationVC = nil;
-  } else if (@available(iOS 12.0, *)) {
+  if (@available(iOS 12.0, *)) {
     _webAuthenticationVC = nil;
-  } else if (@available(iOS 9.0, *)) {
-    _safariVC = nil;
   }
+  // TODO: Remove this when the deployment target becomes iOS 12+
+  // `_safariAuthenticationVC` is cleared unconditionally just in case;
+  // It’s supposed to be assigned only when run on iOS 11.
+  _safariAuthenticationVC = nil;
 }
 
-- (void)safariViewControllerDidFinish:(SFSafariViewController *)controller NS_AVAILABLE_IOS(9.0) {
+- (void)safariViewControllerDidFinish:(SFSafariViewController *)controller {
   NSError *error =
       [[self class] getAppDistributionError:FIRAppDistributionErrorAuthenticationCancelled];
   [self logRegistrationCompletion:error authType:[SFSafariViewController description]];
diff --git a/FirebaseAuth.podspec b/FirebaseAuth.podspec
index 76366902fee..8357e9df8d1 100644
--- a/FirebaseAuth.podspec
+++ b/FirebaseAuth.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseAuth'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Apple platform client for Firebase Authentication'
 
   s.description      = <<-DESC
diff --git a/FirebaseAuthInterop.podspec b/FirebaseAuthInterop.podspec
index debe09cdfc4..e311bfcf2dd 100644
--- a/FirebaseAuthInterop.podspec
+++ b/FirebaseAuthInterop.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseAuthInterop'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Interfaces that allow other Firebase SDKs to use Auth functionality.'
 
   s.description      = <<-DESC
diff --git a/FirebaseCore.podspec b/FirebaseCore.podspec
index 517ba503d23..a665008438c 100644
--- a/FirebaseCore.podspec
+++ b/FirebaseCore.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseCore'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Firebase Core'
 
   s.description      = <<-DESC
diff --git a/FirebaseCore/Sources/FIRApp.m b/FirebaseCore/Sources/FIRApp.m
index 9b03f8f901c..930c7c1a99d 100644
--- a/FirebaseCore/Sources/FIRApp.m
+++ b/FirebaseCore/Sources/FIRApp.m
@@ -830,6 +830,7 @@ + (void)registerSwiftComponents {
     @"FIRAuthComponent" : @"fire-auth",
     @"FIRFunctionsComponent" : @"fire-fun",
     @"FIRStorageComponent" : @"fire-str",
+    @"FIRVertexAIComponent" : @"fire-vertex",
   };
   for (NSString *className in swiftComponents.allKeys) {
     Class klass = NSClassFromString(className);
@@ -856,7 +857,7 @@ + (void)registerSwiftComponents {
 #pragma mark - App Life Cycle
 
 - (void)subscribeForAppDidBecomeActiveNotifications {
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
   NSNotificationName notificationName = UIApplicationDidBecomeActiveNotification;
 #elif TARGET_OS_OSX
   NSNotificationName notificationName = NSApplicationDidBecomeActiveNotification;
diff --git a/FirebaseCore/Sources/FIROptions.m b/FirebaseCore/Sources/FIROptions.m
index d46b657501c..c0625a8bac9 100644
--- a/FirebaseCore/Sources/FIROptions.m
+++ b/FirebaseCore/Sources/FIROptions.m
@@ -284,7 +284,7 @@ - (NSString *)libraryVersionID {
     // The unit tests are set up to catch anything that does not properly convert.
     NSString *version = FIRFirebaseVersion();
     NSArray *components = [version componentsSeparatedByString:@"."];
-    NSString *major = [components objectAtIndex:0];
+    NSString *major = [NSString stringWithFormat:@"%02d", [[components objectAtIndex:0] intValue]];
     NSString *minor = [NSString stringWithFormat:@"%02d", [[components objectAtIndex:1] intValue]];
     NSString *patch = [NSString stringWithFormat:@"%02d", [[components objectAtIndex:2] intValue]];
     kFIRLibraryVersionID = [NSString stringWithFormat:@"%@%@%@000", major, minor, patch];
diff --git a/FirebaseCore/Tests/Unit/FIRAppTest.m b/FirebaseCore/Tests/Unit/FIRAppTest.m
index 285c76500c3..b231e608b92 100644
--- a/FirebaseCore/Tests/Unit/FIRAppTest.m
+++ b/FirebaseCore/Tests/Unit/FIRAppTest.m
@@ -862,7 +862,7 @@ - (XCTestExpectation *)expectNotificationNamed:(NSNotificationName)name
 }
 
 - (NSNotificationName)appDidBecomeActiveNotificationName {
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
   return UIApplicationDidBecomeActiveNotification;
 #elif TARGET_OS_OSX
   return NSApplicationDidBecomeActiveNotification;
diff --git a/FirebaseCore/Tests/Unit/FIROptionsTest.m b/FirebaseCore/Tests/Unit/FIROptionsTest.m
index 3de059f4191..fd5c901598f 100644
--- a/FirebaseCore/Tests/Unit/FIROptionsTest.m
+++ b/FirebaseCore/Tests/Unit/FIROptionsTest.m
@@ -624,7 +624,7 @@ - (void)testVersionConsistency {
   int minor = (versionString[2] - '0') * 10 + versionString[3] - '0';
   int patch = (versionString[4] - '0') * 10 + versionString[5] - '0';
   NSString *str = [NSString stringWithFormat:@"%d.%d.%d", major, minor, patch];
-  XCTAssertEqualObjects(str, FIRFirebaseVersion());
+  XCTAssertTrue([FIRFirebaseVersion() hasPrefix:str]);
 }
 
 // Repeat test with more Objective-C.
@@ -638,11 +638,11 @@ - (void)testVersionConsistency2 {
   NSRange major = NSMakeRange(0, 2);
   NSRange minor = NSMakeRange(2, 2);
   NSRange patch = NSMakeRange(4, 2);
-  NSString *str =
-      [NSString stringWithFormat:@"%@.%d.%d", [kFIRLibraryVersionID substringWithRange:major],
-                                 [[kFIRLibraryVersionID substringWithRange:minor] intValue],
-                                 [[kFIRLibraryVersionID substringWithRange:patch] intValue]];
-  XCTAssertEqualObjects(str, FIRFirebaseVersion());
+  NSString *str = [NSString
+      stringWithFormat:@"%d.%d.%d", [[kFIRLibraryVersionID substringWithRange:major] intValue],
+                       [[kFIRLibraryVersionID substringWithRange:minor] intValue],
+                       [[kFIRLibraryVersionID substringWithRange:patch] intValue]];
+  XCTAssertTrue([FIRFirebaseVersion() hasPrefix:str]);
 }
 
 #pragma mark - Helpers
diff --git a/FirebaseCoreExtension.podspec b/FirebaseCoreExtension.podspec
index 0b1480dd9c9..cdb11be7837 100644
--- a/FirebaseCoreExtension.podspec
+++ b/FirebaseCoreExtension.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
     s.name             = 'FirebaseCoreExtension'
-    s.version          = '10.27.0'
+    s.version          = '10.28.0'
     s.summary          = 'Extended FirebaseCore APIs for Firebase product SDKs'
 
     s.description      = <<-DESC
diff --git a/FirebaseCoreInternal.podspec b/FirebaseCoreInternal.podspec
index 10ff13f66db..1aaad594c31 100644
--- a/FirebaseCoreInternal.podspec
+++ b/FirebaseCoreInternal.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseCoreInternal'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'APIs for internal FirebaseCore usage.'
 
   s.description      = <<-DESC
diff --git a/FirebaseCrashlytics.podspec b/FirebaseCrashlytics.podspec
index 38d5a760312..25c2f7b09dd 100644
--- a/FirebaseCrashlytics.podspec
+++ b/FirebaseCrashlytics.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseCrashlytics'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Best and lightest-weight crash reporting for mobile, desktop and tvOS.'
   s.description      = 'Firebase Crashlytics helps you track, prioritize, and fix stability issues that erode app quality.'
   s.homepage         = 'https://firebase.google.com/'
diff --git a/FirebaseDatabase.podspec b/FirebaseDatabase.podspec
index 12af9ce7905..e9d692accb7 100644
--- a/FirebaseDatabase.podspec
+++ b/FirebaseDatabase.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseDatabase'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Firebase Realtime Database'
 
   s.description      = <<-DESC
diff --git a/FirebaseDatabase/CHANGELOG.md b/FirebaseDatabase/CHANGELOG.md
index 9a24f6b1306..2763dc0c9f9 100644
--- a/FirebaseDatabase/CHANGELOG.md
+++ b/FirebaseDatabase/CHANGELOG.md
@@ -1,4 +1,4 @@
-# Unreleased
+# 10.27.0
 - [changed] Update internal socket implementation to use `NSURLSessionWebSocket` where
   available. (#12883)
 
diff --git a/FirebaseDatabase/Sources/Core/FPersistentConnection.m b/FirebaseDatabase/Sources/Core/FPersistentConnection.m
index 3d1e8af9053..da2820d5deb 100644
--- a/FirebaseDatabase/Sources/Core/FPersistentConnection.m
+++ b/FirebaseDatabase/Sources/Core/FPersistentConnection.m
@@ -1256,8 +1256,7 @@ - (void)sendStats:(NSDictionary *)stats {
 - (void)sendConnectStats {
     NSMutableDictionary *stats = [NSMutableDictionary dictionary];
 
-#if TARGET_OS_IOS || TARGET_OS_TV ||                                           \
-    (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
     if (self.config.persistenceEnabled) {
         stats[@"persistence.ios.enabled"] = @1;
     }
diff --git a/FirebaseDatabase/Sources/Core/FRepo.m b/FirebaseDatabase/Sources/Core/FRepo.m
index 00b0850cd17..628de6a719a 100644
--- a/FirebaseDatabase/Sources/Core/FRepo.m
+++ b/FirebaseDatabase/Sources/Core/FRepo.m
@@ -52,8 +52,7 @@
 #import "FirebaseDatabase/Sources/Utilities/Tuples/FTupleTransaction.h"
 #import <dlfcn.h>
 
-#if TARGET_OS_IOS || TARGET_OS_TV ||                                           \
-    (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 #import <UIKit/UIKit.h>
 #endif
 
@@ -816,8 +815,7 @@ - (void)didEnterBackground {
 
 // Targetted compilation is ONLY for testing. UIKit is weak-linked in actual
 // release build.
-#if TARGET_OS_IOS || TARGET_OS_TV ||                                           \
-    (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
     // The idea is to wait until any outstanding sets get written to disk. Since
     // the sets might still be in our dispatch queue, we wait for the dispatch
     // queue to catch up and for persistence to catch up. This may be
diff --git a/FirebaseDatabase/Sources/Persistence/FLevelDBStorageEngine.m b/FirebaseDatabase/Sources/Persistence/FLevelDBStorageEngine.m
index 6516baa43c7..4dd808da3a1 100644
--- a/FirebaseDatabase/Sources/Persistence/FLevelDBStorageEngine.m
+++ b/FirebaseDatabase/Sources/Persistence/FLevelDBStorageEngine.m
@@ -275,8 +275,7 @@ - (void)close {
 }
 
 + (NSString *)firebaseDir {
-#if TARGET_OS_IOS || TARGET_OS_WATCH ||                                        \
-    (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_WATCH || TARGET_OS_VISION
     NSArray *dirPaths = NSSearchPathForDirectoriesInDomains(
         NSDocumentDirectory, NSUserDomainMask, YES);
     NSString *documentsDir = [dirPaths objectAtIndex:0];
diff --git a/FirebaseDatabase/Sources/Realtime/FWebSocketConnection.m b/FirebaseDatabase/Sources/Realtime/FWebSocketConnection.m
index 7edd0abb3e6..171b75156a9 100644
--- a/FirebaseDatabase/Sources/Realtime/FWebSocketConnection.m
+++ b/FirebaseDatabase/Sources/Realtime/FWebSocketConnection.m
@@ -164,8 +164,7 @@ - (NSString *)userAgent {
 
 // Targetted compilation is ONLY for testing. UIKit is weak-linked in actual
 // release build.
-#if TARGET_OS_IOS || TARGET_OS_TV ||                                           \
-    (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
     Class uiDeviceClass = NSClassFromString(@"UIDevice");
     if (uiDeviceClass) {
         systemVersion = [uiDeviceClass currentDevice].systemVersion;
diff --git a/FirebaseDatabase/Tests/Integration/FIRDatabaseTests.m b/FirebaseDatabase/Tests/Integration/FIRDatabaseTests.m
index 612491a6559..9fdc13fec41 100644
--- a/FirebaseDatabase/Tests/Integration/FIRDatabaseTests.m
+++ b/FirebaseDatabase/Tests/Integration/FIRDatabaseTests.m
@@ -453,11 +453,7 @@ - (void)testCallbackQueue {
   __block BOOL done = NO;
   [database.reference.childByAutoId observeSingleEventOfType:FIRDataEventTypeValue
                                                    withBlock:^(FIRDataSnapshot *snapshot) {
-                                                     if (@available(iOS 10.0, macOS 10.12, *)) {
-                                                       dispatch_assert_queue(callbackQueue);
-                                                     } else {
-                                                       NSAssert(YES, @"Test requires iOS 10");
-                                                     }
+                                                     dispatch_assert_queue(callbackQueue);
                                                      done = YES;
                                                    }];
   WAIT_FOR(done);
diff --git a/FirebaseDynamicLinks.podspec b/FirebaseDynamicLinks.podspec
index aecb9a93884..48efe770eee 100644
--- a/FirebaseDynamicLinks.podspec
+++ b/FirebaseDynamicLinks.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseDynamicLinks'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Firebase Dynamic Links'
 
   s.description      = <<-DESC
diff --git a/FirebaseDynamicLinks/CHANGELOG.md b/FirebaseDynamicLinks/CHANGELOG.md
index 20226346a37..80a0a4105db 100644
--- a/FirebaseDynamicLinks/CHANGELOG.md
+++ b/FirebaseDynamicLinks/CHANGELOG.md
@@ -1,3 +1,6 @@
+# 10.27.0
+- [added] Added deprecation warning in advance of August 25, 2025 Dynamic Links service shutdown. (#12995)
+
 # 10.3.0
 - [fixed] Fixes issue where `utmParametersDictionary` / `minimumAppVersion` were not provided and their value were set to `[NSNull null]` instead of `nil`.
 
diff --git a/FirebaseDynamicLinks/Sources/FIRDLDefaultRetrievalProcessV2.m b/FirebaseDynamicLinks/Sources/FIRDLDefaultRetrievalProcessV2.m
index 63754444cba..15224d061c4 100644
--- a/FirebaseDynamicLinks/Sources/FIRDLDefaultRetrievalProcessV2.m
+++ b/FirebaseDynamicLinks/Sources/FIRDLDefaultRetrievalProcessV2.m
@@ -176,6 +176,9 @@ - (void)handleRetrievalProcessWithResult:(FIRDLRetrievalProcessResult *)result {
 - (nullable NSURL *)uniqueMatchLinkToCheck {
   _clipboardContentAtMatchProcessStart = nil;
   NSString *pasteboardContents = [self retrievePasteboardContents];
+  if (!pasteboardContents) {
+    return nil;
+  }
   NSInteger linkStringMinimumLength =
       expectedCopiedLinkStringSuffix.length + /* ? or & */ 1 + /* http:// */ 7;
   if ((pasteboardContents.length >= linkStringMinimumLength) &&
@@ -201,21 +204,17 @@ - (nullable NSURL *)uniqueMatchLinkToCheck {
   return nil;
 }
 
-- (NSString *)retrievePasteboardContents {
+- (nullable NSString *)retrievePasteboardContents {
   if (![self isPasteboardRetrievalEnabled]) {
     // Pasteboard check for dynamic link is disabled by user.
-    return @"";
+    return nil;
   }
 
-  NSString *pasteboardContents = @"";
-  if (@available(iOS 10.0, *)) {
-    if ([[UIPasteboard generalPasteboard] hasURLs]) {
-      pasteboardContents = [UIPasteboard generalPasteboard].string;
-    }
+  if ([[UIPasteboard generalPasteboard] hasURLs]) {
+    return [UIPasteboard generalPasteboard].string;
   } else {
-    pasteboardContents = [UIPasteboard generalPasteboard].string;
+    return nil;
   }
-  return pasteboardContents;
 }
 
 /**
diff --git a/FirebaseDynamicLinks/Sources/Public/FirebaseDynamicLinks/FIRDynamicLinks.h b/FirebaseDynamicLinks/Sources/Public/FirebaseDynamicLinks/FIRDynamicLinks.h
index 78c8f5d0e81..a23dea44780 100644
--- a/FirebaseDynamicLinks/Sources/Public/FirebaseDynamicLinks/FIRDynamicLinks.h
+++ b/FirebaseDynamicLinks/Sources/Public/FirebaseDynamicLinks/FIRDynamicLinks.h
@@ -31,7 +31,8 @@ NS_ASSUME_NONNULL_BEGIN
  * @abstract A class that checks for pending Dynamic Links and parses URLs.
  *     This class is available on iOS only.
  */
-
+DEPRECATED_MSG_ATTRIBUTE(
+    "Firebase Dynamic Links is deprecated and the service will shut down on August 25, 2025.")
 NS_EXTENSION_UNAVAILABLE_IOS("Firebase Dynamic Links is not supported for iOS extensions.")
 API_UNAVAILABLE(macos, tvos, watchos)
 NS_SWIFT_NAME(DynamicLinks)
diff --git a/FirebaseFirestore.podspec b/FirebaseFirestore.podspec
index edeca1ced9a..4f17857820d 100644
--- a/FirebaseFirestore.podspec
+++ b/FirebaseFirestore.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseFirestore'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Google Cloud Firestore'
   s.description      = <<-DESC
 Google Cloud Firestore is a NoSQL document database built for automatic scaling, high performance, and ease of application development.
@@ -37,7 +37,7 @@ Google Cloud Firestore is a NoSQL document database built for automatic scaling,
 
   s.dependency 'FirebaseCore', '~> 10.0'
   s.dependency 'FirebaseCoreExtension', '~> 10.0'
-  s.dependency 'FirebaseFirestoreInternal', '10.27.0'
+  s.dependency 'FirebaseFirestoreInternal', '10.28.0'
   s.dependency 'FirebaseSharedSwift', '~> 10.0'
 
 end
diff --git a/FirebaseFirestoreInternal.podspec b/FirebaseFirestoreInternal.podspec
index 0ab90c8b970..387b5efabf4 100644
--- a/FirebaseFirestoreInternal.podspec
+++ b/FirebaseFirestoreInternal.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseFirestoreInternal'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Google Cloud Firestore'
 
   s.description      = <<-DESC
diff --git a/FirebaseFunctions.podspec b/FirebaseFunctions.podspec
index 95e1c491303..cdc96eb2aac 100644
--- a/FirebaseFunctions.podspec
+++ b/FirebaseFunctions.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseFunctions'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Cloud Functions for Firebase'
 
   s.description      = <<-DESC
diff --git a/FirebaseInAppMessaging.podspec b/FirebaseInAppMessaging.podspec
index 4a0b346600c..82ee836e18b 100644
--- a/FirebaseInAppMessaging.podspec
+++ b/FirebaseInAppMessaging.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseInAppMessaging'
-  s.version          = '10.27.0-beta'
+  s.version          = '10.28.0-beta'
   s.summary          = 'Firebase In-App Messaging for iOS'
 
   s.description      = <<-DESC
diff --git a/FirebaseInAppMessaging/CHANGELOG.md b/FirebaseInAppMessaging/CHANGELOG.md
index 9c5a31b6fe5..03b535caa4a 100644
--- a/FirebaseInAppMessaging/CHANGELOG.md
+++ b/FirebaseInAppMessaging/CHANGELOG.md
@@ -1,4 +1,4 @@
-# Unreleased
+# 10.27.0
 - [fixed] Fixed crash at app start that affected CocoaPods users using static
   frameworks (#12882).
 
diff --git a/FirebaseInAppMessaging/Sources/Analytics/FIRIAMAnalyticsEventLoggerImpl.m b/FirebaseInAppMessaging/Sources/Analytics/FIRIAMAnalyticsEventLoggerImpl.m
index 8176cb83c68..f4f8a8bc577 100644
--- a/FirebaseInAppMessaging/Sources/Analytics/FIRIAMAnalyticsEventLoggerImpl.m
+++ b/FirebaseInAppMessaging/Sources/Analytics/FIRIAMAnalyticsEventLoggerImpl.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import <GoogleUtilities/GULUserDefaults.h>
 
@@ -176,4 +176,4 @@ - (void)logAnalyticsEventForType:(FIRIAMAnalyticsLogEventType)eventType
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutHttpRequestSender.m b/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutHttpRequestSender.m
index 881621eb258..955aab345d4 100644
--- a/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutHttpRequestSender.m
+++ b/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutHttpRequestSender.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 
@@ -204,4 +204,4 @@ - (void)sendClearcutHttpRequestForLogs:(NSArray<FIRIAMClearcutLogRecord *> *)log
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutLogStorage.m b/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutLogStorage.m
index 1d94a2de83b..0a98ce0f34c 100644
--- a/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutLogStorage.m
+++ b/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutLogStorage.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import <UIKit/UIKit.h>
 
@@ -95,14 +95,12 @@ - (instancetype)initWithExpireAfterInSeconds:(NSInteger)expireInSeconds
                                              selector:@selector(appWillBecomeInactive:)
                                                  name:UIApplicationWillResignActiveNotification
                                                object:nil];
-#if defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
     if (@available(iOS 13.0, tvOS 13.0, *)) {
       [[NSNotificationCenter defaultCenter] addObserver:self
                                                selector:@selector(appWillBecomeInactive:)
                                                    name:UISceneWillDeactivateNotification
                                                  object:nil];
     }
-#endif  // defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
 
     @try {
       [self loadFromCachePath:cachePath];
@@ -169,19 +167,11 @@ - (void)loadFromCachePath:(NSString *)cacheFilePath {
   id fetchedClearcutRetryRecords;
   NSData *data = [NSData dataWithContentsOfFile:filePath];
   if (data) {
-    if (@available(macOS 10.13, iOS 11.0, tvOS 11.0, *)) {
-      fetchedClearcutRetryRecords = [NSKeyedUnarchiver
-          unarchivedObjectOfClasses:[NSSet setWithObjects:[FIRIAMClearcutLogRecord class],
-                                                          [NSMutableArray class], nil]
-                           fromData:data
-                              error:nil];
-    } else {
-      // Fallback on earlier versions
-#pragma clang diagnostic push
-#pragma clang diagnostic ignored "-Wdeprecated-declarations"
-      fetchedClearcutRetryRecords = [NSKeyedUnarchiver unarchiveObjectWithFile:filePath];
-#pragma clang diagnostic pop
-    }
+    fetchedClearcutRetryRecords = [NSKeyedUnarchiver
+        unarchivedObjectOfClasses:[NSSet setWithObjects:[FIRIAMClearcutLogRecord class],
+                                                        [NSMutableArray class], nil]
+                         fromData:data
+                            error:nil];
   }
   if (fetchedClearcutRetryRecords) {
     @synchronized(self) {
@@ -209,4 +199,4 @@ - (BOOL)saveIntoCacheWithPath:(NSString *)cacheFilePath {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutLogger.m b/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutLogger.m
index ea00175ebd0..be16272c896 100644
--- a/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutLogger.m
+++ b/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutLogger.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 
@@ -214,4 +214,4 @@ - (void)logAnalyticsEventForType:(FIRIAMAnalyticsLogEventType)eventType
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutUploader.m b/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutUploader.m
index c520f21616a..f424f51f878 100644
--- a/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutUploader.m
+++ b/FirebaseInAppMessaging/Sources/Analytics/FIRIAMClearcutUploader.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import <GoogleUtilities/GULUserDefaults.h>
 #import <UIKit/UIKit.h>
@@ -102,14 +102,12 @@ - (instancetype)initWithRequestSender:(FIRIAMClearcutHttpRequestSender *)request
                                              selector:@selector(scheduleNextSendFromForeground:)
                                                  name:UIApplicationWillEnterForegroundNotification
                                                object:nil];
-#if defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
     if (@available(iOS 13.0, tvOS 13.0, *)) {
       [[NSNotificationCenter defaultCenter] addObserver:self
                                                selector:@selector(scheduleNextSendFromForeground:)
                                                    name:UISceneWillEnterForegroundNotification
                                                  object:nil];
     }
-#endif  // defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
     _userDefaults = userDefaults ? userDefaults : [GULUserDefaults standardUserDefaults];
     // it would be 0 if it does not exist, which is equvilent to saying that
     // you can send now
@@ -246,4 +244,4 @@ - (void)scheduleNextSend {
 
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Data/FIRIAMFetchResponseParser.m b/FirebaseInAppMessaging/Sources/Data/FIRIAMFetchResponseParser.m
index 8a85321de6e..7becace2949 100644
--- a/FirebaseInAppMessaging/Sources/Data/FIRIAMFetchResponseParser.m
+++ b/FirebaseInAppMessaging/Sources/Data/FIRIAMFetchResponseParser.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 
@@ -399,4 +399,4 @@ - (NSString *)sanitizedURLStringFromString:(NSString *)string {
 
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Data/FIRIAMMessageContentDataWithImageURL.m b/FirebaseInAppMessaging/Sources/Data/FIRIAMMessageContentDataWithImageURL.m
index 60b0b78bbcc..abe867bf6d6 100644
--- a/FirebaseInAppMessaging/Sources/Data/FIRIAMMessageContentDataWithImageURL.m
+++ b/FirebaseInAppMessaging/Sources/Data/FIRIAMMessageContentDataWithImageURL.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 
@@ -206,4 +206,4 @@ - (void)fetchImageFromURL:(NSURL *)imageURL
 
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Data/FIRIAMMessageDefinition.m b/FirebaseInAppMessaging/Sources/Data/FIRIAMMessageDefinition.m
index fb564630bca..4d72b82ac20 100644
--- a/FirebaseInAppMessaging/Sources/Data/FIRIAMMessageDefinition.m
+++ b/FirebaseInAppMessaging/Sources/Data/FIRIAMMessageDefinition.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseInAppMessaging/Sources/Private/Data/FIRIAMMessageDefinition.h"
 
@@ -109,4 +109,4 @@ - (BOOL)messageHasStarted {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Data/FIRIAMRenderingEffectSetting.m b/FirebaseInAppMessaging/Sources/Data/FIRIAMRenderingEffectSetting.m
index eeefee2d24e..1530049c6ba 100644
--- a/FirebaseInAppMessaging/Sources/Data/FIRIAMRenderingEffectSetting.m
+++ b/FirebaseInAppMessaging/Sources/Data/FIRIAMRenderingEffectSetting.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseInAppMessaging/Sources/Private/Data/FIRIAMRenderingEffectSetting.h"
 
@@ -34,4 +34,4 @@ + (instancetype)getDefaultRenderingEffectSetting {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/DefaultUI/Banner/FIRIAMBannerViewController.m b/FirebaseInAppMessaging/Sources/DefaultUI/Banner/FIRIAMBannerViewController.m
index 08952e2321a..ad7a95252aa 100644
--- a/FirebaseInAppMessaging/Sources/DefaultUI/Banner/FIRIAMBannerViewController.m
+++ b/FirebaseInAppMessaging/Sources/DefaultUI/Banner/FIRIAMBannerViewController.m
@@ -161,18 +161,14 @@ - (void)viewDidLoad {
 
   // Calculate status bar height.
   CGFloat statusBarHeight = 0;
-#if defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
   if (@available(iOS 13.0, tvOS 13.0, *)) {
     UIStatusBarManager *manager =
         [UIApplication sharedApplication].keyWindow.windowScene.statusBarManager;
 
     statusBarHeight = manager.statusBarFrame.size.height;
   } else {
-#endif
     statusBarHeight = [[UIApplication sharedApplication] statusBarFrame].size.height;
-#if defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
   }
-#endif
 
   // Pin title label below status bar with cushion.
   [[self.titleLabel.topAnchor constraintEqualToAnchor:self.view.topAnchor
diff --git a/FirebaseInAppMessaging/Sources/DefaultUI/FIRIAMBaseRenderingViewController.m b/FirebaseInAppMessaging/Sources/DefaultUI/FIRIAMBaseRenderingViewController.m
index f05fe050e3d..460fe9f60a2 100644
--- a/FirebaseInAppMessaging/Sources/DefaultUI/FIRIAMBaseRenderingViewController.m
+++ b/FirebaseInAppMessaging/Sources/DefaultUI/FIRIAMBaseRenderingViewController.m
@@ -55,7 +55,6 @@ - (void)viewDidLoad {
                                            selector:@selector(appDidBecomeActive:)
                                                name:UIApplicationDidBecomeActiveNotification
                                              object:nil];
-#if defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
   if (@available(iOS 13.0, tvOS 13.0, *)) {
     [[NSNotificationCenter defaultCenter] addObserver:self
                                              selector:@selector(appWillBecomeInactive:)
@@ -67,7 +66,6 @@ - (void)viewDidLoad {
                                                  name:UISceneDidActivateNotification
                                                object:nil];
   }
-#endif  // defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
   self.aggregateImpressionTimeInSeconds = 0;
 }
 
diff --git a/FirebaseInAppMessaging/Sources/DefaultUI/FIRIAMRenderingWindowHelper.m b/FirebaseInAppMessaging/Sources/DefaultUI/FIRIAMRenderingWindowHelper.m
index e920a32e63c..2d42847474b 100644
--- a/FirebaseInAppMessaging/Sources/DefaultUI/FIRIAMRenderingWindowHelper.m
+++ b/FirebaseInAppMessaging/Sources/DefaultUI/FIRIAMRenderingWindowHelper.m
@@ -28,15 +28,11 @@ + (UIWindow *)windowForBlockingView {
   static dispatch_once_t onceToken;
 
   dispatch_once(&onceToken, ^{
-#if defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
     if (@available(iOS 13.0, tvOS 13.0, *)) {
       UIWindowForModal = [[self class] iOS13PlusWindow];
     } else {
-#endif  // defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
       UIWindowForModal = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
-#if defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
     }
-#endif  // defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
     UIWindowForModal.windowLevel = UIWindowLevelNormal;
   });
   return UIWindowForModal;
@@ -47,23 +43,18 @@ + (UIWindow *)windowForNonBlockingView {
   static dispatch_once_t onceToken;
 
   dispatch_once(&onceToken, ^{
-#if defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
     if (@available(iOS 13.0, tvOS 13.0, *)) {
       UIWindowForBanner = [[self class] iOS13PlusBannerWindow];
     } else {
-#endif  // defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
       UIWindowForBanner =
           [[FIRIAMBannerViewUIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
-#if defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
     }
-#endif
     UIWindowForBanner.windowLevel = UIWindowLevelNormal;
   });
 
   return UIWindowForBanner;
 }
 
-#if defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
 + (UIWindow *)iOS13PlusWindow API_AVAILABLE(ios(13.0)) {
   UIWindowScene *foregroundedScene = [[UIApplication sharedApplication] fir_foregroundWindowScene];
   if (foregroundedScene.delegate) {
@@ -82,7 +73,6 @@ + (FIRIAMBannerViewUIWindow *)iOS13PlusBannerWindow API_AVAILABLE(ios(13.0)) {
   }
 }
 
-#endif
 @end
 
 #endif  // TARGET_OS_IOS
diff --git a/FirebaseInAppMessaging/Sources/DefaultUI/ImageOnly/FIRIAMImageOnlyViewController.m b/FirebaseInAppMessaging/Sources/DefaultUI/ImageOnly/FIRIAMImageOnlyViewController.m
index ffc37a408ec..6f57f6e0d0a 100644
--- a/FirebaseInAppMessaging/Sources/DefaultUI/ImageOnly/FIRIAMImageOnlyViewController.m
+++ b/FirebaseInAppMessaging/Sources/DefaultUI/ImageOnly/FIRIAMImageOnlyViewController.m
@@ -124,11 +124,7 @@ - (void)viewDidLayoutSubviews {
   CGFloat maxImageViewHeight = self.view.window.frame.size.height - minimalMargine * 2;
 
   // Factor in space for the top notch on iPhone X*.
-#if defined(__IPHONE_11_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000
-  if (@available(iOS 11.0, *)) {
-    maxImageViewHeight -= self.view.safeAreaInsets.top;
-  }
-#endif  // defined(__IPHONE_11_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000
+  maxImageViewHeight -= self.view.safeAreaInsets.top;
 
   CGFloat adjustedImageViewHeight = self.imageOriginalSize.height;
   CGFloat adjustedImageViewWidth = self.imageOriginalSize.width;
diff --git a/FirebaseInAppMessaging/Sources/DefaultUI/Modal/FIRIAMModalViewController.m b/FirebaseInAppMessaging/Sources/DefaultUI/Modal/FIRIAMModalViewController.m
index e605e239cc6..efad4f62396 100644
--- a/FirebaseInAppMessaging/Sources/DefaultUI/Modal/FIRIAMModalViewController.m
+++ b/FirebaseInAppMessaging/Sources/DefaultUI/Modal/FIRIAMModalViewController.m
@@ -288,11 +288,7 @@ - (void)layoutFineTuneInPortraitMode {
                           TopBottomPaddingAroundMsgCard * 2;
 
     // Factor in space for the top notch on iPhone X*.
-#if defined(__IPHONE_11_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000
-    if (@available(iOS 11.0, *)) {
-      heightCalcReference -= self.view.safeAreaInsets.top;
-    }
-#endif  // defined(__IPHONE_11_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 110000
+    heightCalcReference -= self.view.safeAreaInsets.top;
   }
 
   FIRLogDebug(kFIRLoggerInAppMessagingDisplay, @"I-FID300004",
diff --git a/FirebaseInAppMessaging/Sources/DisplayTrigger/FIRIAMDisplayTriggerDefinition.m b/FirebaseInAppMessaging/Sources/DisplayTrigger/FIRIAMDisplayTriggerDefinition.m
index 766ec4cc4cf..9de6b8a804b 100644
--- a/FirebaseInAppMessaging/Sources/DisplayTrigger/FIRIAMDisplayTriggerDefinition.m
+++ b/FirebaseInAppMessaging/Sources/DisplayTrigger/FIRIAMDisplayTriggerDefinition.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseInAppMessaging/Sources/Private/DisplayTrigger/FIRIAMDisplayTriggerDefinition.h"
 
@@ -43,4 +43,4 @@ - (instancetype)initWithFirebaseAnalyticEvent:(NSString *)title {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/FIRCore+InAppMessaging.m b/FirebaseInAppMessaging/Sources/FIRCore+InAppMessaging.m
index 58c3a741cc3..db5ae0816e0 100644
--- a/FirebaseInAppMessaging/Sources/FIRCore+InAppMessaging.m
+++ b/FirebaseInAppMessaging/Sources/FIRCore+InAppMessaging.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import <Foundation/Foundation.h>
 #import "FirebaseInAppMessaging/Sources/FIRCore+InAppMessaging.h"
@@ -23,4 +23,4 @@
 NSString *const kFirebaseInAppMessagingErrorDomain = @"com.firebase.inappmessaging";
 FIRLoggerService kFIRLoggerInAppMessaging = @"[FirebaseInAppMessaging]";
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/FIRInAppMessaging.m b/FirebaseInAppMessaging/Sources/FIRInAppMessaging.m
index 3f9595582ac..8b65d0a4efa 100644
--- a/FirebaseInAppMessaging/Sources/FIRInAppMessaging.m
+++ b/FirebaseInAppMessaging/Sources/FIRInAppMessaging.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseInAppMessaging/Sources/Public/FirebaseInAppMessaging/FIRInAppMessaging.h"
 
@@ -145,4 +145,4 @@ - (void)triggerEvent:(NSString *)eventName {
 
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Flows/FIRIAMActivityLogger.m b/FirebaseInAppMessaging/Sources/Flows/FIRIAMActivityLogger.m
index 20862570659..a89c03ba911 100644
--- a/FirebaseInAppMessaging/Sources/Flows/FIRIAMActivityLogger.m
+++ b/FirebaseInAppMessaging/Sources/Flows/FIRIAMActivityLogger.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import <UIKit/UIKit.h>
 
@@ -113,14 +113,12 @@ - (instancetype)initWithMaxCountBeforeReduce:(NSInteger)maxBeforeReduce
                                              selector:@selector(appWillBecomeInactive:)
                                                  name:UIApplicationWillResignActiveNotification
                                                object:nil];
-#if defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
     if (@available(iOS 13.0, tvOS 13.0, *)) {
       [[NSNotificationCenter defaultCenter] addObserver:self
                                                selector:@selector(appWillBecomeInactive:)
                                                    name:UISceneWillDeactivateNotification
                                                  object:nil];
     }
-#endif  // defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
     if (loadFromCache) {
       @try {
         [self loadFromCachePath:nil];
@@ -157,19 +155,11 @@ - (void)loadFromCachePath:(NSString *)cacheFilePath {
   id fetchedActivityRecords;
   NSData *data = [NSData dataWithContentsOfFile:filePath];
   if (data) {
-    if (@available(macOS 10.13, iOS 11.0, tvOS 11.0, *)) {
-      fetchedActivityRecords = [NSKeyedUnarchiver
-          unarchivedObjectOfClasses:[NSSet setWithObjects:[FIRIAMActivityRecord class],
-                                                          [NSMutableArray class], nil]
-                           fromData:data
-                              error:nil];
-    } else {
-      // Fallback on earlier versions
-#pragma clang diagnostic push
-#pragma clang diagnostic ignored "-Wdeprecated-declarations"
-      fetchedActivityRecords = [NSKeyedUnarchiver unarchiveObjectWithFile:filePath];
-#pragma clang diagnostic pop
-    }
+    fetchedActivityRecords = [NSKeyedUnarchiver
+        unarchivedObjectOfClasses:[NSSet setWithObjects:[FIRIAMActivityRecord class],
+                                                        [NSMutableArray class], nil]
+                         fromData:data
+                            error:nil];
   }
   if (fetchedActivityRecords) {
     @synchronized(self) {
@@ -245,4 +235,4 @@ - (void)addLogRecord:(FIRIAMActivityRecord *)newRecord {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Flows/FIRIAMBookKeeper.m b/FirebaseInAppMessaging/Sources/Flows/FIRIAMBookKeeper.m
index db85d8723c5..5e17566b4cc 100644
--- a/FirebaseInAppMessaging/Sources/Flows/FIRIAMBookKeeper.m
+++ b/FirebaseInAppMessaging/Sources/Flows/FIRIAMBookKeeper.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import <GoogleUtilities/GULUserDefaults.h>
 
@@ -264,4 +264,4 @@ - (void)cleanupFetchRecords {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Flows/FIRIAMClientInfoFetcher.m b/FirebaseInAppMessaging/Sources/Flows/FIRIAMClientInfoFetcher.m
index 7d32c188a02..f9363e438ca 100644
--- a/FirebaseInAppMessaging/Sources/Flows/FIRIAMClientInfoFetcher.m
+++ b/FirebaseInAppMessaging/Sources/Flows/FIRIAMClientInfoFetcher.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 #import "FirebaseInstallations/Source/Library/Private/FirebaseInstallationsInternal.h"
@@ -133,4 +133,4 @@ - (NSString *)getIAMSDKVersion {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayCheckOnAnalyticEventsFlow.m b/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayCheckOnAnalyticEventsFlow.m
index 63e7651dc96..4e3a1491919 100644
--- a/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayCheckOnAnalyticEventsFlow.m
+++ b/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayCheckOnAnalyticEventsFlow.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 #import "Interop/Analytics/Public/FIRAnalyticsInterop.h"
@@ -68,4 +68,4 @@ - (void)stop {
 
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayCheckOnAppForegroundFlow.m b/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayCheckOnAppForegroundFlow.m
index 078b7d39706..27fc9492ba1 100644
--- a/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayCheckOnAppForegroundFlow.m
+++ b/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayCheckOnAppForegroundFlow.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 
@@ -33,7 +33,6 @@ - (void)start {
          selector:@selector(checkAndDisplayNextAppForegroundMessageFromForeground:)
              name:UIApplicationWillEnterForegroundNotification
            object:nil];
-#if defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
   if (@available(iOS 13.0, tvOS 13.0, *)) {
     [[NSNotificationCenter defaultCenter]
         addObserver:self
@@ -41,7 +40,6 @@ - (void)start {
                name:UISceneWillEnterForegroundNotification
              object:nil];
   }
-#endif  // defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
 }
 
 - (void)checkAndDisplayNextAppForegroundMessageFromForeground:(NSNotification *)notification {
@@ -67,4 +65,4 @@ - (void)dealloc {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayCheckOnFetchDoneNotificationFlow.m b/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayCheckOnFetchDoneNotificationFlow.m
index 45d4b39f563..6983910c051 100644
--- a/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayCheckOnFetchDoneNotificationFlow.m
+++ b/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayCheckOnFetchDoneNotificationFlow.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 
@@ -64,4 +64,4 @@ - (void)dealloc {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayCheckTriggerFlow.m b/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayCheckTriggerFlow.m
index b7f3137ad18..6ab0c90c41f 100644
--- a/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayCheckTriggerFlow.m
+++ b/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayCheckTriggerFlow.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseInAppMessaging/Sources/Private/Flows/FIRIAMDisplayCheckTriggerFlow.h"
 
@@ -34,4 +34,4 @@ - (void)stop {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayExecutor.m b/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayExecutor.m
index c4ec0def848..c7b10afe459 100644
--- a/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayExecutor.m
+++ b/FirebaseInAppMessaging/Sources/Flows/FIRIAMDisplayExecutor.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import <UIKit/UIKit.h>
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
@@ -355,7 +355,6 @@ - (void)displayMessageLoadError:(NSError *)error {
   [alert addAction:defaultAction];
 
   dispatch_async(dispatch_get_main_queue(), ^{
-#if defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
     if (@available(iOS 13.0, tvOS 13.0, *)) {
       UIWindowScene *foregroundedScene =
           [[UIApplication sharedApplication] fir_foregroundWindowScene];
@@ -364,10 +363,11 @@ - (void)displayMessageLoadError:(NSError *)error {
         return;
       }
       self.alertWindow = [[UIWindow alloc] initWithWindowScene:foregroundedScene];
-    }
-#else  // defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
-    self.alertWindow = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
+#if TARGET_OS_IOS || TARGET_OS_TV
+    } else {
+      self.alertWindow = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
 #endif
+    }
     UIViewController *alertViewController = [[UIViewController alloc] init];
     self.alertWindow.rootViewController = alertViewController;
     self.alertWindow.hidden = NO;
@@ -778,4 +778,4 @@ - (void)checkAndDisplayNextAppForegroundMessage {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Flows/FIRIAMFetchFlow.m b/FirebaseInAppMessaging/Sources/Flows/FIRIAMFetchFlow.m
index 38f8c181ffe..890fece4d57 100644
--- a/FirebaseInAppMessaging/Sources/Flows/FIRIAMFetchFlow.m
+++ b/FirebaseInAppMessaging/Sources/Flows/FIRIAMFetchFlow.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 
@@ -267,4 +267,4 @@ - (void)checkForAppLaunchMessage {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Flows/FIRIAMFetchOnAppForegroundFlow.m b/FirebaseInAppMessaging/Sources/Flows/FIRIAMFetchOnAppForegroundFlow.m
index 0a217b1faa6..946a9ec56b2 100644
--- a/FirebaseInAppMessaging/Sources/Flows/FIRIAMFetchOnAppForegroundFlow.m
+++ b/FirebaseInAppMessaging/Sources/Flows/FIRIAMFetchOnAppForegroundFlow.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 
@@ -29,14 +29,12 @@ - (void)start {
                                            selector:@selector(appWillEnterForeground:)
                                                name:UIApplicationWillEnterForegroundNotification
                                              object:nil];
-#if defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
   if (@available(iOS 13.0, tvOS 13.0, *)) {
     [[NSNotificationCenter defaultCenter] addObserver:self
                                              selector:@selector(appWillEnterForeground:)
                                                  name:UISceneWillEnterForegroundNotification
                                                object:nil];
   }
-#endif  // defined(__IPHONE_13_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130000
 }
 
 - (void)appWillEnterForeground:(NSNotification *)notification {
@@ -61,4 +59,4 @@ - (void)dealloc {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Flows/FIRIAMMessageClientCache.m b/FirebaseInAppMessaging/Sources/Flows/FIRIAMMessageClientCache.m
index 37f24531edd..844272ceaa0 100644
--- a/FirebaseInAppMessaging/Sources/Flows/FIRIAMMessageClientCache.m
+++ b/FirebaseInAppMessaging/Sources/Flows/FIRIAMMessageClientCache.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 
@@ -238,4 +238,4 @@ - (void)loadMessageDataFromServerFetchStorage:(FIRIAMServerMsgFetchStorage *)fet
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Flows/FIRIAMMsgFetcherUsingRestful.m b/FirebaseInAppMessaging/Sources/Flows/FIRIAMMsgFetcherUsingRestful.m
index 772148b6018..77053791922 100644
--- a/FirebaseInAppMessaging/Sources/Flows/FIRIAMMsgFetcherUsingRestful.m
+++ b/FirebaseInAppMessaging/Sources/Flows/FIRIAMMsgFetcherUsingRestful.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 
@@ -277,4 +277,4 @@ - (void)fetchMessagesWithImpressionList:(NSArray<FIRIAMImpressionRecord *> *)imp
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Flows/FIRIAMServerMsgFetchStorage.m b/FirebaseInAppMessaging/Sources/Flows/FIRIAMServerMsgFetchStorage.m
index 713550f8685..dfe42933697 100644
--- a/FirebaseInAppMessaging/Sources/Flows/FIRIAMServerMsgFetchStorage.m
+++ b/FirebaseInAppMessaging/Sources/Flows/FIRIAMServerMsgFetchStorage.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseCore/Extension/FirebaseCoreInternal.h"
 
@@ -66,4 +66,4 @@ - (void)readResponseDictionary:(void (^)(NSDictionary *response, BOOL success))c
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/RenderingObjects/FIRInAppMessagingRenderingDataClasses.m b/FirebaseInAppMessaging/Sources/RenderingObjects/FIRInAppMessagingRenderingDataClasses.m
index 36d81939500..663c72b8438 100644
--- a/FirebaseInAppMessaging/Sources/RenderingObjects/FIRInAppMessagingRenderingDataClasses.m
+++ b/FirebaseInAppMessaging/Sources/RenderingObjects/FIRInAppMessagingRenderingDataClasses.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import <Foundation/Foundation.h>
 
@@ -371,4 +371,4 @@ - (instancetype)initWithActionText:(nullable NSString *)actionText
 
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Runtime/FIRIAMActionURLFollower.m b/FirebaseInAppMessaging/Sources/Runtime/FIRIAMActionURLFollower.m
index cbcae06e2cb..f16936f9423 100644
--- a/FirebaseInAppMessaging/Sources/Runtime/FIRIAMActionURLFollower.m
+++ b/FirebaseInAppMessaging/Sources/Runtime/FIRIAMActionURLFollower.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import <Foundation/Foundation.h>
 #import <UIKit/UIKit.h>
@@ -227,4 +227,4 @@ + (BOOL)isHttpOrHttpsScheme:(NSURL *)url {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Runtime/FIRIAMRuntimeManager.m b/FirebaseInAppMessaging/Sources/Runtime/FIRIAMRuntimeManager.m
index 499b6fddc9b..b3fb094c67b 100644
--- a/FirebaseInAppMessaging/Sources/Runtime/FIRIAMRuntimeManager.m
+++ b/FirebaseInAppMessaging/Sources/Runtime/FIRIAMRuntimeManager.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import <GoogleUtilities/GULUserDefaults.h>
 
@@ -446,4 +446,4 @@ - (void)internalStartRuntimeWithSDKSettings:(FIRIAMSDKSettings *)settings {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Runtime/FIRIAMSDKModeManager.m b/FirebaseInAppMessaging/Sources/Runtime/FIRIAMSDKModeManager.m
index 6aa77c29f8d..39d51af13ca 100644
--- a/FirebaseInAppMessaging/Sources/Runtime/FIRIAMSDKModeManager.m
+++ b/FirebaseInAppMessaging/Sources/Runtime/FIRIAMSDKModeManager.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import <GoogleUtilities/GULUserDefaults.h>
 
@@ -117,4 +117,4 @@ - (FIRIAMSDKMode)currentMode {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Runtime/FIRIAMSDKSettings.m b/FirebaseInAppMessaging/Sources/Runtime/FIRIAMSDKSettings.m
index eccb73beaea..d2d05f6267b 100644
--- a/FirebaseInAppMessaging/Sources/Runtime/FIRIAMSDKSettings.m
+++ b/FirebaseInAppMessaging/Sources/Runtime/FIRIAMSDKSettings.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseInAppMessaging/Sources/Private/Runtime/FIRIAMSDKSettings.h"
 
@@ -37,4 +37,4 @@ - (NSString *)description {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Runtime/FIRInAppMessaging+Bootstrap.m b/FirebaseInAppMessaging/Sources/Runtime/FIRInAppMessaging+Bootstrap.m
index 1d1c773bf54..800c70787f6 100644
--- a/FirebaseInAppMessaging/Sources/Runtime/FIRInAppMessaging+Bootstrap.m
+++ b/FirebaseInAppMessaging/Sources/Runtime/FIRInAppMessaging+Bootstrap.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseInAppMessaging/Sources/Private/Runtime/FIRInAppMessaging+Bootstrap.h"
 
@@ -136,4 +136,4 @@ + (void)exitAppWithFatalError:(NSError *)error {
 
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Util/FIRIAMElapsedTimeTracker.m b/FirebaseInAppMessaging/Sources/Util/FIRIAMElapsedTimeTracker.m
index b299f17b3c7..d60a8e48c58 100644
--- a/FirebaseInAppMessaging/Sources/Util/FIRIAMElapsedTimeTracker.m
+++ b/FirebaseInAppMessaging/Sources/Util/FIRIAMElapsedTimeTracker.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseInAppMessaging/Sources/Util/FIRIAMElapsedTimeTracker.h"
 @interface FIRIAMElapsedTimeTracker ()
@@ -58,4 +58,4 @@ - (instancetype)initWithTimeFetcher:(id<FIRIAMTimeFetcher>)timeFetcher {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Util/FIRIAMTimeFetcher.m b/FirebaseInAppMessaging/Sources/Util/FIRIAMTimeFetcher.m
index c94798d87dd..1c74a24e264 100644
--- a/FirebaseInAppMessaging/Sources/Util/FIRIAMTimeFetcher.m
+++ b/FirebaseInAppMessaging/Sources/Util/FIRIAMTimeFetcher.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseInAppMessaging/Sources/Private/Util/FIRIAMTimeFetcher.h"
 
@@ -25,4 +25,4 @@ - (NSTimeInterval)currentTimestampInSeconds {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Util/NSString+FIRInterlaceStrings.m b/FirebaseInAppMessaging/Sources/Util/NSString+FIRInterlaceStrings.m
index ed2671daa9a..787da932356 100644
--- a/FirebaseInAppMessaging/Sources/Util/NSString+FIRInterlaceStrings.m
+++ b/FirebaseInAppMessaging/Sources/Util/NSString+FIRInterlaceStrings.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseInAppMessaging/Sources/Private/Util/NSString+FIRInterlaceStrings.h"
 
@@ -44,4 +44,4 @@ + (NSString *)fir_interlaceString:(NSString *)stringOne withString:(NSString *)s
 
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Util/UIApplication+FIRForegroundWindowScene.m b/FirebaseInAppMessaging/Sources/Util/UIApplication+FIRForegroundWindowScene.m
index ad0a5ec22c4..1658ad41c74 100644
--- a/FirebaseInAppMessaging/Sources/Util/UIApplication+FIRForegroundWindowScene.m
+++ b/FirebaseInAppMessaging/Sources/Util/UIApplication+FIRForegroundWindowScene.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseInAppMessaging/Sources/Private/Util/UIApplication+FIRForegroundWindowScene.h"
 
@@ -37,4 +37,4 @@ - (nullable UIWindowScene *)fir_foregroundWindowScene {
 
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInAppMessaging/Sources/Util/UIColor+FIRIAMHexString.m b/FirebaseInAppMessaging/Sources/Util/UIColor+FIRIAMHexString.m
index 3374623070a..34c9e220e0e 100644
--- a/FirebaseInAppMessaging/Sources/Util/UIColor+FIRIAMHexString.m
+++ b/FirebaseInAppMessaging/Sources/Util/UIColor+FIRIAMHexString.m
@@ -15,7 +15,7 @@
  */
 
 #import <TargetConditionals.h>
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 
 #import "FirebaseInAppMessaging/Sources/Util/UIColor+FIRIAMHexString.h"
 
@@ -41,4 +41,4 @@ + (UIColor *)firiam_colorWithHexString:(nullable NSString *)hexString {
 }
 @end
 
-#endif  // TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#endif  // TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
diff --git a/FirebaseInstallations.podspec b/FirebaseInstallations.podspec
index 2d136bace4b..148cdb0a3e7 100644
--- a/FirebaseInstallations.podspec
+++ b/FirebaseInstallations.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseInstallations'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Firebase Installations'
 
   s.description      = <<-DESC
diff --git a/FirebaseMLModelDownloader.podspec b/FirebaseMLModelDownloader.podspec
index 94a17627f44..0fdc9c30ef7 100644
--- a/FirebaseMLModelDownloader.podspec
+++ b/FirebaseMLModelDownloader.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseMLModelDownloader'
-  s.version          = '10.27.0-beta'
+  s.version          = '10.28.0-beta'
   s.summary          = 'Firebase ML Model Downloader'
 
   s.description      = <<-DESC
diff --git a/FirebaseMLModelDownloader/Sources/FileDownloader.swift b/FirebaseMLModelDownloader/Sources/FileDownloader.swift
index 06bf03ba8ae..b0fb4b8ed77 100644
--- a/FirebaseMLModelDownloader/Sources/FileDownloader.swift
+++ b/FirebaseMLModelDownloader/Sources/FileDownloader.swift
@@ -63,11 +63,9 @@ class ModelFileDownloader: NSObject, FileDownloader {
     self.conditions = conditions
     configuration = URLSessionConfiguration.ephemeral
     /// Wait for network connectivity.
-    if #available(iOS 11.0, macOS 10.13, macCatalyst 13.0, tvOS 11.0, watchOS 4.0, *) {
-      self.configuration.waitsForConnectivity = true
-      /// Wait for 10 minutes.
-      self.configuration.timeoutIntervalForResource = 600
-    }
+    configuration.waitsForConnectivity = true
+    /// Wait for 10 minutes.
+    configuration.timeoutIntervalForResource = 600
     configuration.allowsCellularAccess = conditions.allowsCellularAccess
   }
 
diff --git a/FirebaseMLModelDownloader/Sources/ModelInfoRetriever.swift b/FirebaseMLModelDownloader/Sources/ModelInfoRetriever.swift
index afaa58e7a7e..12d26f1ed4f 100644
--- a/FirebaseMLModelDownloader/Sources/ModelInfoRetriever.swift
+++ b/FirebaseMLModelDownloader/Sources/ModelInfoRetriever.swift
@@ -523,18 +523,10 @@ extension ModelInfoRetriever {
 
   /// Parse date from string - used to get download URL expiry time.
   private static func getDateFromString(_ strDate: String) -> Date? {
-    if #available(iOS 11, macOS 10.13, macCatalyst 13.0, tvOS 11.0, watchOS 4.0, *) {
-      let dateFormatter = ISO8601DateFormatter()
-      dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
-      dateFormatter.formatOptions = [.withFractionalSeconds]
-      return dateFormatter.date(from: strDate)
-    } else {
-      let dateFormatter = DateFormatter()
-      dateFormatter.locale = Locale(identifier: "en-US_POSIX")
-      dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
-      dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
-      return dateFormatter.date(from: strDate)
-    }
+    let dateFormatter = ISO8601DateFormatter()
+    dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
+    dateFormatter.formatOptions = [.withFractionalSeconds]
+    return dateFormatter.date(from: strDate)
   }
 
   /// Return model info created from server response.
diff --git a/FirebaseMessaging.podspec b/FirebaseMessaging.podspec
index a65bafc51db..32b9e73a863 100644
--- a/FirebaseMessaging.podspec
+++ b/FirebaseMessaging.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseMessaging'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Firebase Messaging'
 
   s.description      = <<-DESC
diff --git a/FirebaseMessaging/CHANGELOG.md b/FirebaseMessaging/CHANGELOG.md
index 27b82cc217e..c718a756697 100644
--- a/FirebaseMessaging/CHANGELOG.md
+++ b/FirebaseMessaging/CHANGELOG.md
@@ -1,4 +1,4 @@
-# Unreleased
+# 10.27.0
 - [fixed] Fixed bug preventing Messaging from working with a custom sqlite3
   dependency (#12900).
 
diff --git a/FirebaseMessaging/Sources/FIRMessaging.m b/FirebaseMessaging/Sources/FIRMessaging.m
index d28db581f2d..98f5e7dcb68 100644
--- a/FirebaseMessaging/Sources/FIRMessaging.m
+++ b/FirebaseMessaging/Sources/FIRMessaging.m
@@ -49,13 +49,8 @@
 static NSString *const kFIRMessagingMessageViaAPNSRootKey = @"aps";
 static NSString *const kFIRMessagingReachabilityHostname = @"www.google.com";
 
-#if defined(__IPHONE_10_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_10_0
 const NSNotificationName FIRMessagingRegistrationTokenRefreshedNotification =
     @"com.firebase.messaging.notif.fcm-token-refreshed";
-#else
-NSString *const FIRMessagingRegistrationTokenRefreshedNotification =
-    @"com.firebase.messaging.notif.fcm-token-refreshed";
-#endif  // defined(__IPHONE_10_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_10_0
 
 NSString *const kFIRMessagingUserDefaultsKeyAutoInitEnabled =
     @"com.firebase.messaging.auto-init.enabled";  // Auto Init Enabled key stored in NSUserDefaults
diff --git a/FirebaseMessaging/Sources/FIRMessagingContextManagerService.m b/FirebaseMessaging/Sources/FIRMessagingContextManagerService.m
index 741a62e65a9..b86e84cb3e6 100644
--- a/FirebaseMessaging/Sources/FIRMessagingContextManagerService.m
+++ b/FirebaseMessaging/Sources/FIRMessagingContextManagerService.m
@@ -13,11 +13,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-#if __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_10_0 ||                                          \
-    __MAC_OS_X_VERSION_MAX_ALLOWED >= __MAC_10_14 || __TV_OS_VERSION_MAX_ALLOWED >= __TV_10_0 || \
-    __WATCH_OS_VERSION_MAX_ALLOWED >= __WATCHOS_3_0 || TARGET_OS_MACCATALYST
+
 #import <UserNotifications/UserNotifications.h>
-#endif
 
 #import "FirebaseMessaging/Sources/FIRMessagingContextManagerService.h"
 
diff --git a/FirebaseMessaging/Sources/FIRMessagingUtilities.m b/FirebaseMessaging/Sources/FIRMessagingUtilities.m
index 713febdb833..2d9bddb5187 100644
--- a/FirebaseMessaging/Sources/FIRMessagingUtilities.m
+++ b/FirebaseMessaging/Sources/FIRMessagingUtilities.m
@@ -311,8 +311,7 @@ BOOL FIRMessagingIsProductionApp(void) {
 #if TARGET_OS_OSX || TARGET_OS_MACCATALYST
   NSString *path = [[[[NSBundle mainBundle] resourcePath] stringByDeletingLastPathComponent]
       stringByAppendingPathComponent:@"embedded.provisionprofile"];
-#elif TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_WATCH || \
-    (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#elif TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_WATCH || TARGET_OS_VISION
   NSString *path = [[[NSBundle mainBundle] bundlePath]
       stringByAppendingPathComponent:@"embedded.mobileprovision"];
 #endif
diff --git a/FirebaseMessaging/Sources/Token/FIRMessagingAuthKeychain.m b/FirebaseMessaging/Sources/Token/FIRMessagingAuthKeychain.m
index 51e2f955334..544d1718d80 100644
--- a/FirebaseMessaging/Sources/Token/FIRMessagingAuthKeychain.m
+++ b/FirebaseMessaging/Sources/Token/FIRMessagingAuthKeychain.m
@@ -92,7 +92,7 @@ - (NSMutableDictionary *)keychainQueryForService:(NSString *)service account:(NS
   NSMutableDictionary *keychainQuery = [self keychainQueryForService:service account:account];
   NSMutableArray<NSData *> *results;
   keychainQuery[(__bridge id)kSecReturnData] = (__bridge id)kCFBooleanTrue;
-#if TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
   keychainQuery[(__bridge id)kSecReturnAttributes] = (__bridge id)kCFBooleanTrue;
   keychainQuery[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitAll;
   // FIRMessagingKeychain should only take a query and return a result, will handle the query here.
diff --git a/FirebaseMessaging/Tests/UnitTests/FIRMessagingContextManagerServiceTest.m b/FirebaseMessaging/Tests/UnitTests/FIRMessagingContextManagerServiceTest.m
index dd03e1bd4e4..1339a690465 100644
--- a/FirebaseMessaging/Tests/UnitTests/FIRMessagingContextManagerServiceTest.m
+++ b/FirebaseMessaging/Tests/UnitTests/FIRMessagingContextManagerServiceTest.m
@@ -13,12 +13,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-#if __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_10_0 ||                                          \
-    __MAC_OS_X_VERSION_MAX_ALLOWED >= __MAC_10_14 || __TV_OS_VERSION_MAX_ALLOWED >= __TV_10_0 || \
-    __WATCH_OS_VERSION_MAX_ALLOWED >= __WATCHOS_3_0 || TARGET_OS_MACCATALYST
-#import <UserNotifications/UserNotifications.h>
-#endif
+
 #import <OCMock/OCMock.h>
+#import <UserNotifications/UserNotifications.h>
 #import <XCTest/XCTest.h>
 
 #import "FirebaseMessaging/Sources/FIRMessagingContextManagerService.h"
diff --git a/FirebaseMessaging/Tests/UnitTests/FIRMessagingRemoteNotificationsProxyTest.m b/FirebaseMessaging/Tests/UnitTests/FIRMessagingRemoteNotificationsProxyTest.m
index 9fadff6f46b..d3bbdc3ead3 100644
--- a/FirebaseMessaging/Tests/UnitTests/FIRMessagingRemoteNotificationsProxyTest.m
+++ b/FirebaseMessaging/Tests/UnitTests/FIRMessagingRemoteNotificationsProxyTest.m
@@ -14,11 +14,8 @@
  * limitations under the License.
  */
 
-#if __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_10_0 || \
-    __TV_OS_VERSION_MAX_ALLOWED >= __TV_10_0 || __MAC_OS_X_VERSION_MAX_ALLOWED >= __MAC_10_14
-#import <UserNotifications/UserNotifications.h>
-#endif
 #import <OCMock/OCMock.h>
+#import <UserNotifications/UserNotifications.h>
 #import <XCTest/XCTest.h>
 
 #import <GoogleUtilities/GULAppDelegateSwizzler.h>
@@ -95,8 +92,6 @@ - (void)application:(GULApplication *)application
 
 @end
 
-#if __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_10_0 || \
-    __TV_OS_VERSION_MAX_ALLOWED >= __TV_10_0 || __MAC_OS_X_VERSION_MAX_ALLOWED >= __MAC_10_14
 #pragma mark - Incompete UNUserNotificationCenterDelegate
 @interface IncompleteUserNotificationCenterDelegate : NSObject <UNUserNotificationCenterDelegate>
 @end
@@ -127,8 +122,6 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center
 #endif
 @end
 
-#endif
-
 @interface GULAppDelegateSwizzler (FIRMessagingRemoteNotificationsProxyTest)
 + (void)resetProxyOriginalDelegateOnceToken;
 @end
diff --git a/FirebaseMessagingInterop.podspec b/FirebaseMessagingInterop.podspec
index ce7c6ae1d21..ce10bc365bf 100644
--- a/FirebaseMessagingInterop.podspec
+++ b/FirebaseMessagingInterop.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseMessagingInterop'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Interfaces that allow other Firebase SDKs to use Messaging functionality.'
 
   s.description      = <<-DESC
diff --git a/FirebasePerformance.podspec b/FirebasePerformance.podspec
index 8fa8c6314ba..debb8bfe16d 100644
--- a/FirebasePerformance.podspec
+++ b/FirebasePerformance.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebasePerformance'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Firebase Performance'
 
   s.description      = <<-DESC
diff --git a/FirebasePerformance/CHANGELOG.md b/FirebasePerformance/CHANGELOG.md
index 8ca612948e3..f814d7d6290 100644
--- a/FirebasePerformance/CHANGELOG.md
+++ b/FirebasePerformance/CHANGELOG.md
@@ -1,3 +1,6 @@
+# Unreleased
+- Fix Crash from InstrumentUploadTaskWithStreamedRequest (#12983).
+
 # 10.25.0
 - [changed] Removed usages of user defaults API to eliminate required reason impact.
 
diff --git a/FirebasePerformance/Sources/Instrumentation/Network/FPRNSURLSessionInstrument.m b/FirebasePerformance/Sources/Instrumentation/Network/FPRNSURLSessionInstrument.m
index 123d3cdb3e9..4951cbd5a03 100644
--- a/FirebasePerformance/Sources/Instrumentation/Network/FPRNSURLSessionInstrument.m
+++ b/FirebasePerformance/Sources/Instrumentation/Network/FPRNSURLSessionInstrument.m
@@ -482,8 +482,15 @@ void InstrumentUploadTaskWithStreamedRequest(FPRNSURLSessionInstrument *instrume
     if (!strongInstrument) {
       ThrowExceptionBecauseInstrumentHasBeenDeallocated(selector, instrumentor.instrumentedClass);
     }
-    typedef NSURLSessionUploadTask *(*OriginalImp)(id, SEL, NSURLRequest *);
-    NSURLSessionUploadTask *uploadTask = ((OriginalImp)currentIMP)(session, selector, request);
+    typedef NSURLSessionUploadTask *(*OriginalImp)(id, SEL, NSURLRequest *, NSData *);
+    // To avoid a runtime warning in Xcode 15, the given `URLRequest`
+    // should have a nil `HTTPBody`. To workaround this, the `HTTPBody` data is removed
+    // and requestData is replaced with it, if it bodyData was `nil`.
+    NSMutableURLRequest *requestWithoutHTTPBody = [request mutableCopy];
+    NSData *requestData = requestWithoutHTTPBody.HTTPBody;
+    requestWithoutHTTPBody.HTTPBody = nil;
+    NSURLSessionUploadTask *uploadTask =
+        ((OriginalImp)currentIMP)(session, selector, request, requestData);
     if (uploadTask.originalRequest) {
       FPRNetworkTrace *trace =
           [[FPRNetworkTrace alloc] initWithURLRequest:uploadTask.originalRequest];
diff --git a/FirebaseRemoteConfig.podspec b/FirebaseRemoteConfig.podspec
index c3a54d7560a..205a4379e25 100644
--- a/FirebaseRemoteConfig.podspec
+++ b/FirebaseRemoteConfig.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseRemoteConfig'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Firebase Remote Config'
 
   s.description      = <<-DESC
diff --git a/FirebaseRemoteConfigInterop.podspec b/FirebaseRemoteConfigInterop.podspec
index b1f6059e549..33385ecf524 100644
--- a/FirebaseRemoteConfigInterop.podspec
+++ b/FirebaseRemoteConfigInterop.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseRemoteConfigInterop'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Interfaces that allow other Firebase SDKs to use Remote Config functionality.'
 
   s.description      = <<-DESC
diff --git a/FirebaseRemoteConfigSwift/Tests/SwiftAPI/APITestBase.swift b/FirebaseRemoteConfigSwift/Tests/SwiftAPI/APITestBase.swift
index f0288744018..a1618b279bf 100644
--- a/FirebaseRemoteConfigSwift/Tests/SwiftAPI/APITestBase.swift
+++ b/FirebaseRemoteConfigSwift/Tests/SwiftAPI/APITestBase.swift
@@ -35,6 +35,8 @@ class APITestBase: XCTestCase {
       #if USE_REAL_CONSOLE
         useFakeConfig = false
         FirebaseApp.configure()
+        // Sleep for 1 minute between test files to avoid exceeding rate limit.
+        sleep(60)
       #else
         useFakeConfig = true
         let options = FirebaseOptions(googleAppID: "1:123:ios:123abc",
diff --git a/FirebaseSessions.podspec b/FirebaseSessions.podspec
index 623c449d14d..07021c7ee8a 100644
--- a/FirebaseSessions.podspec
+++ b/FirebaseSessions.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseSessions'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Firebase Sessions'
 
   s.description      = <<-DESC
diff --git a/FirebaseSharedSwift.podspec b/FirebaseSharedSwift.podspec
index 1e0c02b9e86..b9a96cb0434 100644
--- a/FirebaseSharedSwift.podspec
+++ b/FirebaseSharedSwift.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name                    = 'FirebaseSharedSwift'
-  s.version                 = '10.27.0'
+  s.version                 = '10.28.0'
   s.summary                 = 'Shared Swift Extensions for Firebase'
 
   s.description      = <<-DESC
diff --git a/FirebaseSharedSwift/Sources/third_party/FirebaseDataEncoder/FirebaseDataEncoder.swift b/FirebaseSharedSwift/Sources/third_party/FirebaseDataEncoder/FirebaseDataEncoder.swift
index d402094b095..76bea358176 100644
--- a/FirebaseSharedSwift/Sources/third_party/FirebaseDataEncoder/FirebaseDataEncoder.swift
+++ b/FirebaseSharedSwift/Sources/third_party/FirebaseDataEncoder/FirebaseDataEncoder.swift
@@ -122,7 +122,6 @@ public class FirebaseDataEncoder {
     case millisecondsSince1970
 
     /// Encode the `Date` as an ISO-8601-formatted string (in RFC 3339 format).
-    @available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *)
     case iso8601
 
     /// Encode the `Date` as a string formatted by the given formatter.
@@ -823,11 +822,7 @@ extension __JSONEncoder {
       return NSNumber(value: 1000.0 * date.timeIntervalSince1970)
 
     case .iso8601:
-      if #available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) {
         return NSString(string: _iso8601Formatter.string(from: date))
-      } else {
-        fatalError("ISO8601DateFormatter is unavailable on this platform.")
-      }
 
     case .formatted(let formatter):
       return NSString(string: formatter.string(from: date))
@@ -1080,7 +1075,6 @@ public class FirebaseDataDecoder {
     case millisecondsSince1970
 
     /// Decode the `Date` as an ISO-8601-formatted string (in RFC 3339 format).
-    @available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *)
     case iso8601
 
     /// Decode the `Date` as a string parsed by the given formatter.
@@ -2443,16 +2437,12 @@ extension __JSONDecoder {
       return Date(timeIntervalSince1970: double / 1000.0)
 
     case .iso8601:
-      if #available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) {
         let string = try self.unbox(value, as: String.self)!
         guard let date = _iso8601Formatter.date(from: string) else {
           throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Expected date string to be ISO8601-formatted."))
         }
 
         return date
-      } else {
-        fatalError("ISO8601DateFormatter is unavailable on this platform.")
-      }
 
     case .formatted(let formatter):
       let string = try self.unbox(value, as: String.self)!
@@ -2621,7 +2611,6 @@ fileprivate struct _JSONKey : CodingKey {
 //===----------------------------------------------------------------------===//
 
 // NOTE: This value is implicitly lazy and _must_ be lazy. We're compiled against the latest SDK (w/ ISO8601DateFormatter), but linked against whichever Foundation the user has. ISO8601DateFormatter might not exist, so we better not hit this code path on an older OS.
-@available(macOS 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *)
 fileprivate var _iso8601Formatter: ISO8601DateFormatter = {
   let formatter = ISO8601DateFormatter()
   formatter.formatOptions = .withInternetDateTime
diff --git a/FirebaseSharedSwift/Tests/third_party/DataEncoderTests.swift b/FirebaseSharedSwift/Tests/third_party/DataEncoderTests.swift
index 4b1b2cb6940..0b7f01a0e1b 100644
--- a/FirebaseSharedSwift/Tests/third_party/DataEncoderTests.swift
+++ b/FirebaseSharedSwift/Tests/third_party/DataEncoderTests.swift
@@ -302,7 +302,6 @@ class TestFirebaseDataEncoder: XCTestCase {
   }
 
   func testEncodingDateISO8601() {
-    if #available(OSX 10.12, iOS 10.0, watchOS 3.0, tvOS 10.0, *) {
       let formatter = ISO8601DateFormatter()
       formatter.formatOptions = .withInternetDateTime
 
@@ -319,7 +318,6 @@ class TestFirebaseDataEncoder: XCTestCase {
                      expected: expected,
                      dateEncodingStrategy: .iso8601,
                      dateDecodingStrategy: .iso8601)
-    }
   }
 
   func testEncodingDateFormatted() {
diff --git a/FirebaseStorage.podspec b/FirebaseStorage.podspec
index 7a2519f9e05..36f88e3d037 100644
--- a/FirebaseStorage.podspec
+++ b/FirebaseStorage.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseStorage'
-  s.version          = '10.27.0'
+  s.version          = '10.28.0'
   s.summary          = 'Firebase Storage'
 
   s.description      = <<-DESC
diff --git a/FirebaseStorage/Sources/StorageError.swift b/FirebaseStorage/Sources/StorageError.swift
index 68543f50aba..d9e80dd31bd 100644
--- a/FirebaseStorage/Sources/StorageError.swift
+++ b/FirebaseStorage/Sources/StorageError.swift
@@ -82,13 +82,10 @@ public let StorageErrorDomain: String = "FIRStorageErrorDomain"
       requestString = "<nil request returned from server>"
     }
     let invalidDataString = "Invalid data returned from the server:\(requestString)"
-    var localizedFailureKey: String
-    if #available(OSX 10.13, iOS 11.0, watchOS 4.0, tvOS 11.0, *) {
-      localizedFailureKey = NSLocalizedFailureErrorKey
-    } else {
-      localizedFailureKey = "NSLocalizedFailure"
-    }
-    return error(withCode: .unknown, infoDictionary: [localizedFailureKey: invalidDataString])
+    return error(
+      withCode: .unknown,
+      infoDictionary: [NSLocalizedFailureErrorKey: invalidDataString]
+    )
   }
 
   /**
diff --git a/FirebaseVertexAI-Docs.not_podspec b/FirebaseVertexAI-Docs.not_podspec
new file mode 100644
index 00000000000..7967cc616c4
--- /dev/null
+++ b/FirebaseVertexAI-Docs.not_podspec
@@ -0,0 +1,47 @@
+Pod::Spec.new do |s|
+  s.name             = 'FirebaseVertexAI-Docs'
+  s.version          = '10.27.0'
+  s.summary          = 'Firebase Vertex AI'
+
+  s.description      = <<-DESC
+  Placeholder podspec for docsgen only. Do not use this pod.
+
+  NOTE: Rename the file extension from `.not_podspec` to `.podspec` before
+        running `pod gen` for docs generation.
+                        DESC
+
+  s.homepage         = 'https://firebase.google.com'
+  s.license          = { :type => 'Apache-2.0', :file => 'LICENSE' }
+  s.authors          = 'Google, Inc.'
+
+  s.source           = {
+    :git => 'https://github.com/firebase/firebase-ios-sdk.git',
+    :tag => 'CocoaPods-' + s.version.to_s
+  }
+
+  s.social_media_url = 'https://twitter.com/Firebase'
+
+  ios_deployment_target = '15.0'
+  osx_deployment_target = '11.0'
+
+  s.ios.deployment_target = ios_deployment_target
+  s.osx.deployment_target = osx_deployment_target
+
+  s.cocoapods_version = '>= 1.12.0'
+  s.prefix_header_file = false
+
+  s.source_files = [
+    'FirebaseVertexAI/Sources/**/*.swift',
+  ]
+
+  s.swift_version = '5.3'
+
+  s.framework = 'Foundation'
+  s.ios.framework = 'UIKit'
+  s.osx.framework = 'AppKit'
+
+  s.dependency 'FirebaseAppCheckInterop', '~> 10.17'
+  s.dependency 'FirebaseAuthInterop', '~> 10.25'
+  s.dependency 'FirebaseCore', '~> 10.5'
+  s.dependency 'FirebaseCoreExtension', '~> 10.0'
+end
diff --git a/FirebaseVertexAI/CHANGELOG.md b/FirebaseVertexAI/CHANGELOG.md
new file mode 100644
index 00000000000..7017d26ce58
--- /dev/null
+++ b/FirebaseVertexAI/CHANGELOG.md
@@ -0,0 +1,14 @@
+# 10.27.0
+- [changed] Removed uses of the `gemini-1.5-pro-preview-0409` model in docs and
+  samples. Developers should now use `gemini-1.5-pro-preview-0514` or
+  `gemini-1.5-flash-preview-0514`; see
+  [available model names](https://firebase.google.com/docs/vertex-ai/gemini-models#available-model-names)
+  for more details. (#12979)
+- [changed] Logged additional details when required APIs for Vertex AI are
+  not enabled or response payloads when requests fail. (#13007, #13009)
+
+# 10.26.0
+- [feature] Initial release of the Vertex AI for Firebase SDK (public preview).
+  Learn how to
+  [get started](https://firebase.google.com/docs/vertex-ai/get-started?platform=ios)
+  with the SDK in your app.
diff --git a/FirebaseVertexAI/README.md b/FirebaseVertexAI/README.md
new file mode 100644
index 00000000000..398a183eecb
--- /dev/null
+++ b/FirebaseVertexAI/README.md
@@ -0,0 +1,8 @@
+# Vertex AI for Firebase SDK
+
+**Preview**: Vertex AI for Firebase is in Public Preview, which means that the product is
+not subject to any SLA or deprecation policy and could change in backwards-incompatible
+ways.
+
+- For developer documentation, please visit https://firebase.google.com/docs/vertex-ai.
+- Try out the [sample app](Sample/README.md) to get started.
diff --git a/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/AccentColor.colorset/Contents.json b/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 00000000000..eb878970081
--- /dev/null
+++ b/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+  "colors" : [
+    {
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/AppIcon.appiconset/Contents.json b/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000000..13613e3ee1a
--- /dev/null
+++ b/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,13 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "platform" : "ios",
+      "size" : "1024x1024"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/Contents.json b/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/Contents.json
new file mode 100644
index 00000000000..73c00596a7f
--- /dev/null
+++ b/FirebaseVertexAI/Sample/ChatSample/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/FirebaseVertexAI/Sample/ChatSample/Models/ChatMessage.swift b/FirebaseVertexAI/Sample/ChatSample/Models/ChatMessage.swift
new file mode 100644
index 00000000000..6f7ab321b12
--- /dev/null
+++ b/FirebaseVertexAI/Sample/ChatSample/Models/ChatMessage.swift
@@ -0,0 +1,64 @@
+// Copyright 2023 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 Foundation
+
+enum Participant {
+  case system
+  case user
+}
+
+struct ChatMessage: Identifiable, Equatable {
+  let id = UUID().uuidString
+  var message: String
+  let participant: Participant
+  var pending = false
+
+  static func pending(participant: Participant) -> ChatMessage {
+    Self(message: "", participant: participant, pending: true)
+  }
+}
+
+extension ChatMessage {
+  static var samples: [ChatMessage] = [
+    .init(message: "Hello. What can I do for you today?", participant: .system),
+    .init(message: "Show me a simple loop in Swift.", participant: .user),
+    .init(message: """
+    Sure, here is a simple loop in Swift:
+
+    # Example 1
+    ```
+    for i in 1...5 {
+      print("Hello, world!")
+    }
+    ```
+
+    This loop will print the string "Hello, world!" five times. The for loop iterates over a range of numbers,
+    in this case the numbers from 1 to 5. The variable i is assigned each number in the range, and the code inside the loop is executed.
+
+    **Here is another example of a simple loop in Swift:**
+    ```swift
+    var sum = 0
+    for i in 1...100 {
+      sum += i
+    }
+    print("The sum of the numbers from 1 to 100 is \\(sum).")
+    ```
+
+    This loop calculates the sum of the numbers from 1 to 100. The variable sum is initialized to 0, and then the for loop iterates over the range of numbers from 1 to 100. The variable i is assigned each number in the range, and the value of i is added to the sum variable. After the loop has finished executing, the value of sum is printed to the console.
+    """, participant: .system),
+  ]
+
+  static var sample = samples[0]
+}
diff --git a/FirebaseVertexAI/Sample/ChatSample/Preview Content/Preview Assets.xcassets/Contents.json b/FirebaseVertexAI/Sample/ChatSample/Preview Content/Preview Assets.xcassets/Contents.json
new file mode 100644
index 00000000000..73c00596a7f
--- /dev/null
+++ b/FirebaseVertexAI/Sample/ChatSample/Preview Content/Preview Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/FirebaseVertexAI/Sample/ChatSample/Screens/ConversationScreen.swift b/FirebaseVertexAI/Sample/ChatSample/Screens/ConversationScreen.swift
new file mode 100644
index 00000000000..78c903e3412
--- /dev/null
+++ b/FirebaseVertexAI/Sample/ChatSample/Screens/ConversationScreen.swift
@@ -0,0 +1,129 @@
+// Copyright 2023 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 FirebaseVertexAI
+import GenerativeAIUIComponents
+import SwiftUI
+
+struct ConversationScreen: View {
+  @EnvironmentObject
+  var viewModel: ConversationViewModel
+
+  @State
+  private var userPrompt = ""
+
+  enum FocusedField: Hashable {
+    case message
+  }
+
+  @FocusState
+  var focusedField: FocusedField?
+
+  var body: some View {
+    VStack {
+      ScrollViewReader { scrollViewProxy in
+        List {
+          ForEach(viewModel.messages) { message in
+            MessageView(message: message)
+          }
+          if let error = viewModel.error {
+            ErrorView(error: error)
+              .tag("errorView")
+          }
+        }
+        .listStyle(.plain)
+        .onChange(of: viewModel.messages, perform: { newValue in
+          if viewModel.hasError {
+            // wait for a short moment to make sure we can actually scroll to the bottom
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
+              withAnimation {
+                scrollViewProxy.scrollTo("errorView", anchor: .bottom)
+              }
+              focusedField = .message
+            }
+          } else {
+            guard let lastMessage = viewModel.messages.last else { return }
+
+            // wait for a short moment to make sure we can actually scroll to the bottom
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
+              withAnimation {
+                scrollViewProxy.scrollTo(lastMessage.id, anchor: .bottom)
+              }
+              focusedField = .message
+            }
+          }
+        })
+      }
+      InputField("Message...", text: $userPrompt) {
+        Image(systemName: viewModel.busy ? "stop.circle.fill" : "arrow.up.circle.fill")
+          .font(.title)
+      }
+      .focused($focusedField, equals: .message)
+      .onSubmit { sendOrStop() }
+    }
+    .toolbar {
+      ToolbarItem(placement: .primaryAction) {
+        Button(action: newChat) {
+          Image(systemName: "square.and.pencil")
+        }
+      }
+    }
+    .navigationTitle("Chat sample")
+    .onAppear {
+      focusedField = .message
+    }
+  }
+
+  private func sendMessage() {
+    Task {
+      let prompt = userPrompt
+      userPrompt = ""
+      await viewModel.sendMessage(prompt, streaming: true)
+    }
+  }
+
+  private func sendOrStop() {
+    focusedField = nil
+
+    if viewModel.busy {
+      viewModel.stop()
+    } else {
+      sendMessage()
+    }
+  }
+
+  private func newChat() {
+    viewModel.startNewChat()
+  }
+}
+
+struct ConversationScreen_Previews: PreviewProvider {
+  struct ContainerView: View {
+    @StateObject var viewModel = ConversationViewModel()
+
+    var body: some View {
+      ConversationScreen()
+        .environmentObject(viewModel)
+        .onAppear {
+          viewModel.messages = ChatMessage.samples
+        }
+    }
+  }
+
+  static var previews: some View {
+    NavigationStack {
+      ConversationScreen()
+    }
+  }
+}
diff --git a/FirebaseVertexAI/Sample/ChatSample/ViewModels/ConversationViewModel.swift b/FirebaseVertexAI/Sample/ChatSample/ViewModels/ConversationViewModel.swift
new file mode 100644
index 00000000000..d8020b3ac41
--- /dev/null
+++ b/FirebaseVertexAI/Sample/ChatSample/ViewModels/ConversationViewModel.swift
@@ -0,0 +1,130 @@
+// Copyright 2023 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 FirebaseVertexAI
+import Foundation
+import UIKit
+
+@MainActor
+class ConversationViewModel: ObservableObject {
+  /// This array holds both the user's and the system's chat messages
+  @Published var messages = [ChatMessage]()
+
+  /// Indicates we're waiting for the model to finish
+  @Published var busy = false
+
+  @Published var error: Error?
+  var hasError: Bool {
+    return error != nil
+  }
+
+  private var model: GenerativeModel
+  private var chat: Chat
+  private var stopGenerating = false
+
+  private var chatTask: Task<Void, Never>?
+
+  init() {
+    model = VertexAI.vertexAI().generativeModel(modelName: "gemini-1.5-flash-preview-0514")
+    chat = model.startChat()
+  }
+
+  func sendMessage(_ text: String, streaming: Bool = true) async {
+    error = nil
+    if streaming {
+      await internalSendMessageStreaming(text)
+    } else {
+      await internalSendMessage(text)
+    }
+  }
+
+  func startNewChat() {
+    stop()
+    error = nil
+    chat = model.startChat()
+    messages.removeAll()
+  }
+
+  func stop() {
+    chatTask?.cancel()
+    error = nil
+  }
+
+  private func internalSendMessageStreaming(_ text: String) async {
+    chatTask?.cancel()
+
+    chatTask = Task {
+      busy = true
+      defer {
+        busy = false
+      }
+
+      // first, add the user's message to the chat
+      let userMessage = ChatMessage(message: text, participant: .user)
+      messages.append(userMessage)
+
+      // add a pending message while we're waiting for a response from the backend
+      let systemMessage = ChatMessage.pending(participant: .system)
+      messages.append(systemMessage)
+
+      do {
+        let responseStream = chat.sendMessageStream(text)
+        for try await chunk in responseStream {
+          messages[messages.count - 1].pending = false
+          if let text = chunk.text {
+            messages[messages.count - 1].message += text
+          }
+        }
+      } catch {
+        self.error = error
+        print(error.localizedDescription)
+        messages.removeLast()
+      }
+    }
+  }
+
+  private func internalSendMessage(_ text: String) async {
+    chatTask?.cancel()
+
+    chatTask = Task {
+      busy = true
+      defer {
+        busy = false
+      }
+
+      // first, add the user's message to the chat
+      let userMessage = ChatMessage(message: text, participant: .user)
+      messages.append(userMessage)
+
+      // add a pending message while we're waiting for a response from the backend
+      let systemMessage = ChatMessage.pending(participant: .system)
+      messages.append(systemMessage)
+
+      do {
+        var response: GenerateContentResponse?
+        response = try await chat.sendMessage(text)
+
+        if let responseText = response?.text {
+          // replace pending message with backend response
+          messages[messages.count - 1].message = responseText
+          messages[messages.count - 1].pending = false
+        }
+      } catch {
+        self.error = error
+        print(error.localizedDescription)
+        messages.removeLast()
+      }
+    }
+  }
+}
diff --git a/FirebaseVertexAI/Sample/ChatSample/Views/BouncingDots.swift b/FirebaseVertexAI/Sample/ChatSample/Views/BouncingDots.swift
new file mode 100644
index 00000000000..6895e6723da
--- /dev/null
+++ b/FirebaseVertexAI/Sample/ChatSample/Views/BouncingDots.swift
@@ -0,0 +1,77 @@
+// Copyright 2023 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 SwiftUI
+
+struct BouncingDots: View {
+  @State
+  private var dot1YOffset: CGFloat = 0.0
+
+  @State
+  private var dot2YOffset: CGFloat = 0.0
+
+  @State
+  private var dot3YOffset: CGFloat = 0.0
+
+  let animation = Animation.easeInOut(duration: 0.8)
+    .repeatForever(autoreverses: true)
+
+  var body: some View {
+    HStack(spacing: 8) {
+      Circle()
+        .fill(Color.white)
+        .frame(width: 10, height: 10)
+        .offset(y: dot1YOffset)
+        .onAppear {
+          withAnimation(self.animation.delay(0.0)) {
+            self.dot1YOffset = -5
+          }
+        }
+      Circle()
+        .fill(Color.white)
+        .frame(width: 10, height: 10)
+        .offset(y: dot2YOffset)
+        .onAppear {
+          withAnimation(self.animation.delay(0.2)) {
+            self.dot2YOffset = -5
+          }
+        }
+      Circle()
+        .fill(Color.white)
+        .frame(width: 10, height: 10)
+        .offset(y: dot3YOffset)
+        .onAppear {
+          withAnimation(self.animation.delay(0.4)) {
+            self.dot3YOffset = -5
+          }
+        }
+    }
+    .onAppear {
+      let baseOffset: CGFloat = -2
+
+      self.dot1YOffset = baseOffset
+      self.dot2YOffset = baseOffset
+      self.dot3YOffset = baseOffset
+    }
+  }
+}
+
+struct BouncingDots_Previews: PreviewProvider {
+  static var previews: some View {
+    BouncingDots()
+      .frame(width: 200, height: 50)
+      .background(.blue)
+      .roundedCorner(10, corners: [.allCorners])
+  }
+}
diff --git a/FirebaseVertexAI/Sample/ChatSample/Views/ErrorDetailsView.swift b/FirebaseVertexAI/Sample/ChatSample/Views/ErrorDetailsView.swift
new file mode 100644
index 00000000000..dc5ce8f9561
--- /dev/null
+++ b/FirebaseVertexAI/Sample/ChatSample/Views/ErrorDetailsView.swift
@@ -0,0 +1,206 @@
+// Copyright 2023 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 FirebaseVertexAI
+import MarkdownUI
+import SwiftUI
+
+extension SafetySetting.HarmCategory: CustomStringConvertible {
+  public var description: String {
+    switch self {
+    case .dangerousContent: "Dangerous content"
+    case .harassment: "Harassment"
+    case .hateSpeech: "Hate speech"
+    case .sexuallyExplicit: "Sexually explicit"
+    case .unknown: "Unknown"
+    case .unspecified: "Unspecified"
+    }
+  }
+}
+
+extension SafetyRating.HarmProbability: CustomStringConvertible {
+  public var description: String {
+    switch self {
+    case .high: "High"
+    case .low: "Low"
+    case .medium: "Medium"
+    case .negligible: "Negligible"
+    case .unknown: "Unknown"
+    case .unspecified: "Unspecified"
+    }
+  }
+}
+
+private struct SubtitleFormRow: View {
+  var title: String
+  var value: String
+
+  var body: some View {
+    VStack(alignment: .leading) {
+      Text(title)
+        .font(.subheadline)
+      Text(value)
+    }
+  }
+}
+
+private struct SubtitleMarkdownFormRow: View {
+  var title: String
+  var value: String
+
+  var body: some View {
+    VStack(alignment: .leading) {
+      Text(title)
+        .font(.subheadline)
+      Markdown(value)
+    }
+  }
+}
+
+private struct SafetyRatingsSection: View {
+  var ratings: [SafetyRating]
+
+  var body: some View {
+    Section("Safety ratings") {
+      List(ratings, id: \.self) { rating in
+        HStack {
+          Text("\(String(describing: rating.category))")
+            .font(.subheadline)
+          Spacer()
+          Text("\(String(describing: rating.probability))")
+        }
+      }
+    }
+  }
+}
+
+struct ErrorDetailsView: View {
+  var error: Error
+
+  var body: some View {
+    NavigationView {
+      Form {
+        switch error {
+        case let GenerateContentError.internalError(underlying: underlyingError):
+          Section("Error Type") {
+            Text("Internal error")
+          }
+
+          Section("Details") {
+            SubtitleFormRow(title: "Error description",
+                            value: underlyingError.localizedDescription)
+          }
+
+        case let GenerateContentError.promptBlocked(response: generateContentResponse):
+          Section("Error Type") {
+            Text("Your prompt was blocked")
+          }
+
+          Section("Details") {
+            if let reason = generateContentResponse.promptFeedback?.blockReason {
+              SubtitleFormRow(title: "Reason for blocking", value: reason.rawValue)
+            }
+
+            if let text = generateContentResponse.text {
+              SubtitleMarkdownFormRow(title: "Last chunk for the response", value: text)
+            }
+          }
+
+          if let ratings = generateContentResponse.candidates.first?.safetyRatings {
+            SafetyRatingsSection(ratings: ratings)
+          }
+
+        case let GenerateContentError.responseStoppedEarly(
+          reason: finishReason,
+          response: generateContentResponse
+        ):
+
+          Section("Error Type") {
+            Text("Response stopped early")
+          }
+
+          Section("Details") {
+            SubtitleFormRow(title: "Reason for finishing early", value: finishReason.rawValue)
+
+            if let text = generateContentResponse.text {
+              SubtitleMarkdownFormRow(title: "Last chunk for the response", value: text)
+            }
+          }
+
+          if let ratings = generateContentResponse.candidates.first?.safetyRatings {
+            SafetyRatingsSection(ratings: ratings)
+          }
+
+        default:
+          Section("Error Type") {
+            Text("Some other error")
+          }
+
+          Section("Details") {
+            SubtitleFormRow(title: "Error description", value: error.localizedDescription)
+          }
+        }
+      }
+      .navigationTitle("Error details")
+      .navigationBarTitleDisplayMode(.inline)
+    }
+  }
+}
+
+#Preview("Response Stopped Early") {
+  let error = GenerateContentError.responseStoppedEarly(
+    reason: .maxTokens,
+    response: GenerateContentResponse(candidates: [
+      CandidateResponse(content: ModelContent(role: "model", [
+        """
+        A _hypothetical_ model response.
+        Cillum ex aliqua amet aliquip labore amet eiusmod consectetur reprehenderit sit commodo.
+        """,
+      ]),
+      safetyRatings: [
+        SafetyRating(category: .dangerousContent, probability: .high),
+        SafetyRating(category: .harassment, probability: .low),
+        SafetyRating(category: .hateSpeech, probability: .low),
+        SafetyRating(category: .sexuallyExplicit, probability: .low),
+      ],
+      finishReason: FinishReason.maxTokens,
+      citationMetadata: nil),
+    ])
+  )
+
+  return ErrorDetailsView(error: error)
+}
+
+#Preview("Prompt Blocked") {
+  let error = GenerateContentError.promptBlocked(
+    response: GenerateContentResponse(candidates: [
+      CandidateResponse(content: ModelContent(role: "model", [
+        """
+        A _hypothetical_ model response.
+        Cillum ex aliqua amet aliquip labore amet eiusmod consectetur reprehenderit sit commodo.
+        """,
+      ]),
+      safetyRatings: [
+        SafetyRating(category: .dangerousContent, probability: .high),
+        SafetyRating(category: .harassment, probability: .low),
+        SafetyRating(category: .hateSpeech, probability: .low),
+        SafetyRating(category: .sexuallyExplicit, probability: .low),
+      ],
+      finishReason: FinishReason.other,
+      citationMetadata: nil),
+    ])
+  )
+
+  return ErrorDetailsView(error: error)
+}
diff --git a/FirebaseVertexAI/Sample/ChatSample/Views/ErrorView.swift b/FirebaseVertexAI/Sample/ChatSample/Views/ErrorView.swift
new file mode 100644
index 00000000000..d4db2d67dc5
--- /dev/null
+++ b/FirebaseVertexAI/Sample/ChatSample/Views/ErrorView.swift
@@ -0,0 +1,64 @@
+// Copyright 2023 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 FirebaseVertexAI
+import SwiftUI
+
+struct ErrorView: View {
+  var error: Error
+  @State private var isDetailsSheetPresented = false
+  var body: some View {
+    HStack {
+      Text("An error occurred.")
+      Button(action: { isDetailsSheetPresented.toggle() }) {
+        Image(systemName: "info.circle")
+      }
+    }
+    .frame(maxWidth: .infinity, alignment: .center)
+    .listRowSeparator(.hidden)
+    .sheet(isPresented: $isDetailsSheetPresented) {
+      ErrorDetailsView(error: error)
+    }
+  }
+}
+
+#Preview {
+  NavigationView {
+    let errorPromptBlocked = GenerateContentError.promptBlocked(
+      response: GenerateContentResponse(candidates: [
+        CandidateResponse(content: ModelContent(role: "model", [
+          """
+            A _hypothetical_ model response.
+            Cillum ex aliqua amet aliquip labore amet eiusmod consectetur reprehenderit sit commodo.
+          """,
+        ]),
+        safetyRatings: [
+          SafetyRating(category: .dangerousContent, probability: .high),
+          SafetyRating(category: .harassment, probability: .low),
+          SafetyRating(category: .hateSpeech, probability: .low),
+          SafetyRating(category: .sexuallyExplicit, probability: .low),
+        ],
+        finishReason: FinishReason.other,
+        citationMetadata: nil),
+      ])
+    )
+    List {
+      MessageView(message: ChatMessage.samples[0])
+      MessageView(message: ChatMessage.samples[1])
+      ErrorView(error: errorPromptBlocked)
+    }
+    .listStyle(.plain)
+    .navigationTitle("Chat sample")
+  }
+}
diff --git a/FirebaseVertexAI/Sample/ChatSample/Views/MessageView.swift b/FirebaseVertexAI/Sample/ChatSample/Views/MessageView.swift
new file mode 100644
index 00000000000..79894503ffd
--- /dev/null
+++ b/FirebaseVertexAI/Sample/ChatSample/Views/MessageView.swift
@@ -0,0 +1,108 @@
+// Copyright 2023 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 MarkdownUI
+import SwiftUI
+
+struct RoundedCorner: Shape {
+  var radius: CGFloat = .infinity
+  var corners: UIRectCorner = .allCorners
+
+  func path(in rect: CGRect) -> Path {
+    let path = UIBezierPath(
+      roundedRect: rect,
+      byRoundingCorners: corners,
+      cornerRadii: CGSize(width: radius, height: radius)
+    )
+    return Path(path.cgPath)
+  }
+}
+
+extension View {
+  func roundedCorner(_ radius: CGFloat, corners: UIRectCorner) -> some View {
+    clipShape(RoundedCorner(radius: radius, corners: corners))
+  }
+}
+
+struct MessageContentView: View {
+  var message: ChatMessage
+
+  var body: some View {
+    if message.pending {
+      BouncingDots()
+    } else {
+      Markdown(message.message)
+        .markdownTextStyle {
+          FontFamilyVariant(.normal)
+          FontSize(.em(0.85))
+          ForegroundColor(message.participant == .system ? Color(UIColor.label) : .white)
+        }
+        .markdownBlockStyle(\.codeBlock) { configuration in
+          configuration.label
+            .relativeLineSpacing(.em(0.25))
+            .markdownTextStyle {
+              FontFamilyVariant(.monospaced)
+              FontSize(.em(0.85))
+              ForegroundColor(Color(.label))
+            }
+            .padding()
+            .background(Color(.secondarySystemBackground))
+            .clipShape(RoundedRectangle(cornerRadius: 8))
+            .markdownMargin(top: .zero, bottom: .em(0.8))
+        }
+    }
+  }
+}
+
+struct MessageView: View {
+  var message: ChatMessage
+
+  var body: some View {
+    HStack {
+      if message.participant == .user {
+        Spacer()
+      }
+      MessageContentView(message: message)
+        .padding(10)
+        .background(message.participant == .system
+          ? Color(UIColor.systemFill)
+          : Color(UIColor.systemBlue))
+        .roundedCorner(10,
+                       corners: [
+                         .topLeft,
+                         .topRight,
+                         message.participant == .system ? .bottomRight : .bottomLeft,
+                       ])
+      if message.participant == .system {
+        Spacer()
+      }
+    }
+    .listRowSeparator(.hidden)
+  }
+}
+
+struct MessageView_Previews: PreviewProvider {
+  static var previews: some View {
+    NavigationView {
+      List {
+        MessageView(message: ChatMessage.samples[0])
+        MessageView(message: ChatMessage.samples[1])
+        MessageView(message: ChatMessage.samples[2])
+        MessageView(message: ChatMessage(message: "Hello!", participant: .system, pending: true))
+      }
+      .listStyle(.plain)
+      .navigationTitle("Chat sample")
+    }
+  }
+}
diff --git a/FirebaseVertexAI/Sample/FunctionCallingSample/Screens/FunctionCallingScreen.swift b/FirebaseVertexAI/Sample/FunctionCallingSample/Screens/FunctionCallingScreen.swift
new file mode 100644
index 00000000000..f16da39e22f
--- /dev/null
+++ b/FirebaseVertexAI/Sample/FunctionCallingSample/Screens/FunctionCallingScreen.swift
@@ -0,0 +1,131 @@
+// Copyright 2023 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 FirebaseVertexAI
+import GenerativeAIUIComponents
+import SwiftUI
+
+struct FunctionCallingScreen: View {
+  @EnvironmentObject
+  var viewModel: FunctionCallingViewModel
+
+  @State
+  private var userPrompt = "What is 100 Euros in U.S. Dollars?"
+
+  enum FocusedField: Hashable {
+    case message
+  }
+
+  @FocusState
+  var focusedField: FocusedField?
+
+  var body: some View {
+    VStack {
+      ScrollViewReader { scrollViewProxy in
+        List {
+          Text("Interact with a currency conversion API using function calling in Gemini.")
+          ForEach(viewModel.messages) { message in
+            MessageView(message: message)
+          }
+          if let error = viewModel.error {
+            ErrorView(error: error)
+              .tag("errorView")
+          }
+        }
+        .listStyle(.plain)
+        .onChange(of: viewModel.messages, perform: { newValue in
+          if viewModel.hasError {
+            // Wait for a short moment to make sure we can actually scroll to the bottom.
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
+              withAnimation {
+                scrollViewProxy.scrollTo("errorView", anchor: .bottom)
+              }
+              focusedField = .message
+            }
+          } else {
+            guard let lastMessage = viewModel.messages.last else { return }
+
+            // Wait for a short moment to make sure we can actually scroll to the bottom.
+            DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
+              withAnimation {
+                scrollViewProxy.scrollTo(lastMessage.id, anchor: .bottom)
+              }
+              focusedField = .message
+            }
+          }
+        })
+        .onTapGesture {
+          focusedField = nil
+        }
+      }
+      InputField("Message...", text: $userPrompt) {
+        Image(systemName: viewModel.busy ? "stop.circle.fill" : "arrow.up.circle.fill")
+          .font(.title)
+      }
+      .focused($focusedField, equals: .message)
+      .onSubmit { sendOrStop() }
+    }
+    .toolbar {
+      ToolbarItem(placement: .primaryAction) {
+        Button(action: newChat) {
+          Image(systemName: "square.and.pencil")
+        }
+      }
+    }
+    .navigationTitle("Function Calling")
+    .onAppear {
+      focusedField = .message
+    }
+  }
+
+  private func sendMessage() {
+    Task {
+      let prompt = userPrompt
+      userPrompt = ""
+      await viewModel.sendMessage(prompt, streaming: true)
+    }
+  }
+
+  private func sendOrStop() {
+    if viewModel.busy {
+      viewModel.stop()
+    } else {
+      sendMessage()
+    }
+  }
+
+  private func newChat() {
+    viewModel.startNewChat()
+  }
+}
+
+struct FunctionCallingScreen_Previews: PreviewProvider {
+  struct ContainerView: View {
+    @EnvironmentObject
+    var viewModel: FunctionCallingViewModel
+
+    var body: some View {
+      FunctionCallingScreen()
+        .onAppear {
+          viewModel.messages = ChatMessage.samples
+        }
+    }
+  }
+
+  static var previews: some View {
+    NavigationStack {
+      FunctionCallingScreen().environmentObject(FunctionCallingViewModel())
+    }
+  }
+}
diff --git a/FirebaseVertexAI/Sample/FunctionCallingSample/ViewModels/FunctionCallingViewModel.swift b/FirebaseVertexAI/Sample/FunctionCallingSample/ViewModels/FunctionCallingViewModel.swift
new file mode 100644
index 00000000000..3b6074b8cd5
--- /dev/null
+++ b/FirebaseVertexAI/Sample/FunctionCallingSample/ViewModels/FunctionCallingViewModel.swift
@@ -0,0 +1,264 @@
+// Copyright 2023 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 FirebaseVertexAI
+import Foundation
+import UIKit
+
+@MainActor
+class FunctionCallingViewModel: ObservableObject {
+  /// This array holds both the user's and the system's chat messages
+  @Published var messages = [ChatMessage]()
+
+  /// Indicates we're waiting for the model to finish
+  @Published var busy = false
+
+  @Published var error: Error?
+  var hasError: Bool {
+    return error != nil
+  }
+
+  /// Function calls pending processing
+  private var functionCalls = [FunctionCall]()
+
+  private var model: GenerativeModel
+  private var chat: Chat
+
+  private var chatTask: Task<Void, Never>?
+
+  init() {
+    model = VertexAI.vertexAI().generativeModel(
+      modelName: "gemini-1.5-flash-preview-0514",
+      tools: [Tool(functionDeclarations: [
+        FunctionDeclaration(
+          name: "get_exchange_rate",
+          description: "Get the exchange rate for currencies between countries",
+          parameters: [
+            "currency_from": Schema(
+              type: .string,
+              format: "enum",
+              description: "The currency to convert from in ISO 4217 format",
+              enumValues: ["USD", "EUR", "JPY", "GBP", "AUD", "CAD"]
+            ),
+            "currency_to": Schema(
+              type: .string,
+              format: "enum",
+              description: "The currency to convert to in ISO 4217 format",
+              enumValues: ["USD", "EUR", "JPY", "GBP", "AUD", "CAD"]
+            ),
+          ],
+          requiredParameters: ["currency_from", "currency_to"]
+        ),
+      ])]
+    )
+    chat = model.startChat()
+  }
+
+  func sendMessage(_ text: String, streaming: Bool = true) async {
+    error = nil
+    chatTask?.cancel()
+
+    chatTask = Task {
+      busy = true
+      defer {
+        busy = false
+      }
+
+      // first, add the user's message to the chat
+      let userMessage = ChatMessage(message: text, participant: .user)
+      messages.append(userMessage)
+
+      // add a pending message while we're waiting for a response from the backend
+      let systemMessage = ChatMessage.pending(participant: .system)
+      messages.append(systemMessage)
+
+      print(messages)
+      do {
+        repeat {
+          if streaming {
+            try await internalSendMessageStreaming(text)
+          } else {
+            try await internalSendMessage(text)
+          }
+        } while !functionCalls.isEmpty
+      } catch {
+        self.error = error
+        print(error.localizedDescription)
+        messages.removeLast()
+      }
+    }
+  }
+
+  func startNewChat() {
+    stop()
+    error = nil
+    chat = model.startChat()
+    messages.removeAll()
+  }
+
+  func stop() {
+    chatTask?.cancel()
+    error = nil
+  }
+
+  private func internalSendMessageStreaming(_ text: String) async throws {
+    let functionResponses = try await processFunctionCalls()
+    let responseStream: AsyncThrowingStream<GenerateContentResponse, Error>
+    if functionResponses.isEmpty {
+      responseStream = chat.sendMessageStream(text)
+    } else {
+      for functionResponse in functionResponses {
+        messages.insert(functionResponse.chatMessage(), at: messages.count - 1)
+      }
+      responseStream = chat.sendMessageStream(functionResponses.modelContent())
+    }
+    for try await chunk in responseStream {
+      processResponseContent(content: chunk)
+    }
+  }
+
+  private func internalSendMessage(_ text: String) async throws {
+    let functionResponses = try await processFunctionCalls()
+    let response: GenerateContentResponse
+    if functionResponses.isEmpty {
+      response = try await chat.sendMessage(text)
+    } else {
+      for functionResponse in functionResponses {
+        messages.insert(functionResponse.chatMessage(), at: messages.count - 1)
+      }
+      response = try await chat.sendMessage(functionResponses.modelContent())
+    }
+    processResponseContent(content: response)
+  }
+
+  func processResponseContent(content: GenerateContentResponse) {
+    guard let candidate = content.candidates.first else {
+      fatalError("No candidate.")
+    }
+
+    for part in candidate.content.parts {
+      switch part {
+      case let .text(text):
+        // replace pending message with backend response
+        messages[messages.count - 1].message += text
+        messages[messages.count - 1].pending = false
+      case let .functionCall(functionCall):
+        messages.insert(functionCall.chatMessage(), at: messages.count - 1)
+        functionCalls.append(functionCall)
+      case .data, .fileData, .functionResponse:
+        fatalError("Unsupported response content.")
+      }
+    }
+  }
+
+  func processFunctionCalls() async throws -> [FunctionResponse] {
+    var functionResponses = [FunctionResponse]()
+    for functionCall in functionCalls {
+      switch functionCall.name {
+      case "get_exchange_rate":
+        let exchangeRates = getExchangeRate(args: functionCall.args)
+        functionResponses.append(FunctionResponse(
+          name: "get_exchange_rate",
+          response: exchangeRates
+        ))
+      default:
+        fatalError("Unknown function named \"\(functionCall.name)\".")
+      }
+    }
+    functionCalls = []
+
+    return functionResponses
+  }
+
+  // MARK: - Callable Functions
+
+  func getExchangeRate(args: JSONObject) -> JSONObject {
+    // 1. Validate and extract the parameters provided by the model (from a `FunctionCall`)
+    guard case let .string(from) = args["currency_from"] else {
+      fatalError("Missing `currency_from` parameter.")
+    }
+    guard case let .string(to) = args["currency_to"] else {
+      fatalError("Missing `currency_to` parameter.")
+    }
+
+    // 2. Get the exchange rate
+    let allRates: [String: [String: Double]] = [
+      "AUD": ["CAD": 0.89265, "EUR": 0.6072, "GBP": 0.51714, "JPY": 97.75, "USD": 0.66379],
+      "CAD": ["AUD": 1.1203, "EUR": 0.68023, "GBP": 0.57933, "JPY": 109.51, "USD": 0.74362],
+      "EUR": ["AUD": 1.6469, "CAD": 1.4701, "GBP": 0.85168, "JPY": 160.99, "USD": 1.0932],
+      "GBP": ["AUD": 1.9337, "CAD": 1.7261, "EUR": 1.1741, "JPY": 189.03, "USD": 1.2836],
+      "JPY": ["AUD": 0.01023, "CAD": 0.00913, "EUR": 0.00621, "GBP": 0.00529, "USD": 0.00679],
+      "USD": ["AUD": 1.5065, "CAD": 1.3448, "EUR": 0.91475, "GBP": 0.77907, "JPY": 147.26],
+    ]
+    guard let fromRates = allRates[from] else {
+      return ["error": .string("No data for currency \(from).")]
+    }
+    guard let toRate = fromRates[to] else {
+      return ["error": .string("No data for currency \(to).")]
+    }
+
+    // 3. Return the exchange rates as a JSON object (returned to the model in a `FunctionResponse`)
+    return ["rates": .number(toRate)]
+  }
+}
+
+private extension FunctionCall {
+  func chatMessage() -> ChatMessage {
+    let encoder = JSONEncoder()
+    encoder.outputFormatting = .prettyPrinted
+
+    let jsonData: Data
+    do {
+      jsonData = try encoder.encode(self)
+    } catch {
+      fatalError("JSON Encoding Failed: \(error.localizedDescription)")
+    }
+    guard let json = String(data: jsonData, encoding: .utf8) else {
+      fatalError("Failed to convert JSON data to a String.")
+    }
+    let messageText = "Function call requested by model:\n```\n\(json)\n```"
+
+    return ChatMessage(message: messageText, participant: .system)
+  }
+}
+
+private extension FunctionResponse {
+  func chatMessage() -> ChatMessage {
+    let encoder = JSONEncoder()
+    encoder.outputFormatting = .prettyPrinted
+
+    let jsonData: Data
+    do {
+      jsonData = try encoder.encode(self)
+    } catch {
+      fatalError("JSON Encoding Failed: \(error.localizedDescription)")
+    }
+    guard let json = String(data: jsonData, encoding: .utf8) else {
+      fatalError("Failed to convert JSON data to a String.")
+    }
+    let messageText = "Function response returned by app:\n```\n\(json)\n```"
+
+    return ChatMessage(message: messageText, participant: .user)
+  }
+}
+
+private extension [FunctionResponse] {
+  func modelContent() -> [ModelContent] {
+    return self.map { ModelContent(
+      role: "function",
+      parts: [ModelContent.Part.functionResponse($0)]
+    )
+    }
+  }
+}
diff --git a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/AccentColor.colorset/Contents.json b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 00000000000..eb878970081
--- /dev/null
+++ b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+  "colors" : [
+    {
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/AppIcon.appiconset/Contents.json b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000000..13613e3ee1a
--- /dev/null
+++ b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,13 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "platform" : "ios",
+      "size" : "1024x1024"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/Contents.json b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/Contents.json
new file mode 100644
index 00000000000..73c00596a7f
--- /dev/null
+++ b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Preview Content/Preview Assets.xcassets/Contents.json b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Preview Content/Preview Assets.xcassets/Contents.json
new file mode 100644
index 00000000000..73c00596a7f
--- /dev/null
+++ b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Preview Content/Preview Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Screens/PhotoReasoningScreen.swift b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Screens/PhotoReasoningScreen.swift
new file mode 100644
index 00000000000..930214770d8
--- /dev/null
+++ b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/Screens/PhotoReasoningScreen.swift
@@ -0,0 +1,78 @@
+// Copyright 2023 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 GenerativeAIUIComponents
+import MarkdownUI
+import PhotosUI
+import SwiftUI
+
+struct PhotoReasoningScreen: View {
+  @StateObject var viewModel = PhotoReasoningViewModel()
+
+  enum FocusedField: Hashable {
+    case message
+  }
+
+  @FocusState
+  var focusedField: FocusedField?
+
+  var body: some View {
+    VStack {
+      MultimodalInputField(text: $viewModel.userInput, selection: $viewModel.selectedItems)
+        .focused($focusedField, equals: .message)
+        .onSubmit {
+          onSendTapped()
+        }
+
+      ScrollViewReader { scrollViewProxy in
+        List {
+          if let outputText = viewModel.outputText {
+            HStack(alignment: .top) {
+              if viewModel.inProgress {
+                ProgressView()
+              } else {
+                Image(systemName: "cloud.circle.fill")
+                  .font(.title2)
+              }
+
+              Markdown("\(outputText)")
+            }
+            .listRowSeparator(.hidden)
+          }
+        }
+        .listStyle(.plain)
+      }
+    }
+    .navigationTitle("Multimodal sample")
+    .onAppear {
+      focusedField = .message
+    }
+  }
+
+  // MARK: - Actions
+
+  private func onSendTapped() {
+    focusedField = nil
+
+    Task {
+      await viewModel.reason()
+    }
+  }
+}
+
+#Preview {
+  NavigationStack {
+    PhotoReasoningScreen()
+  }
+}
diff --git a/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/ViewModels/PhotoReasoningViewModel.swift b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/ViewModels/PhotoReasoningViewModel.swift
new file mode 100644
index 00000000000..ec0f3a7c6b3
--- /dev/null
+++ b/FirebaseVertexAI/Sample/GenerativeAIMultimodalSample/ViewModels/PhotoReasoningViewModel.swift
@@ -0,0 +1,119 @@
+// Copyright 2023 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 FirebaseVertexAI
+import Foundation
+import OSLog
+import PhotosUI
+import SwiftUI
+
+@MainActor
+class PhotoReasoningViewModel: ObservableObject {
+  // Maximum value for the larger of the two image dimensions (height and width) in pixels. This is
+  // being used to reduce the image size in bytes.
+  private static let largestImageDimension = 768.0
+
+  private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "generative-ai")
+
+  @Published
+  var userInput: String = ""
+
+  @Published
+  var selectedItems = [PhotosPickerItem]()
+
+  @Published
+  var outputText: String? = nil
+
+  @Published
+  var errorMessage: String?
+
+  @Published
+  var inProgress = false
+
+  private var model: GenerativeModel?
+
+  init() {
+    model = VertexAI.vertexAI().generativeModel(modelName: "gemini-1.5-flash-preview-0514")
+  }
+
+  func reason() async {
+    defer {
+      inProgress = false
+    }
+    guard let model else {
+      return
+    }
+
+    do {
+      inProgress = true
+      errorMessage = nil
+      outputText = ""
+
+      let prompt = "Look at the image(s), and then answer the following question: \(userInput)"
+
+      var images = [any ThrowingPartsRepresentable]()
+      for item in selectedItems {
+        if let data = try? await item.loadTransferable(type: Data.self) {
+          guard let image = UIImage(data: data) else {
+            logger.error("Failed to parse data as an image, skipping.")
+            continue
+          }
+          if image.size.fits(largestDimension: PhotoReasoningViewModel.largestImageDimension) {
+            images.append(image)
+          } else {
+            guard let resizedImage = image
+              .preparingThumbnail(of: image.size
+                .aspectFit(largestDimension: PhotoReasoningViewModel.largestImageDimension)) else {
+              logger.error("Failed to resize image: \(image)")
+              continue
+            }
+
+            images.append(resizedImage)
+          }
+        }
+      }
+
+      let outputContentStream = model.generateContentStream(prompt, images)
+
+      // stream response
+      for try await outputContent in outputContentStream {
+        guard let line = outputContent.text else {
+          return
+        }
+
+        outputText = (outputText ?? "") + line
+      }
+    } catch {
+      logger.error("\(error.localizedDescription)")
+      errorMessage = error.localizedDescription
+    }
+  }
+}
+
+private extension CGSize {
+  func fits(largestDimension length: CGFloat) -> Bool {
+    return width <= length && height <= length
+  }
+
+  func aspectFit(largestDimension length: CGFloat) -> CGSize {
+    let aspectRatio = width / height
+    if width > height {
+      let width = min(self.width, length)
+      return CGSize(width: width, height: round(width / aspectRatio))
+    } else {
+      let height = min(self.height, length)
+      return CGSize(width: round(height * aspectRatio), height: height)
+    }
+  }
+}
diff --git a/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/AccentColor.colorset/Contents.json b/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 00000000000..eb878970081
--- /dev/null
+++ b/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+  "colors" : [
+    {
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/AppIcon.appiconset/Contents.json b/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000000..13613e3ee1a
--- /dev/null
+++ b/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,13 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "platform" : "ios",
+      "size" : "1024x1024"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/Contents.json b/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/Contents.json
new file mode 100644
index 00000000000..73c00596a7f
--- /dev/null
+++ b/FirebaseVertexAI/Sample/GenerativeAITextSample/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/FirebaseVertexAI/Sample/GenerativeAITextSample/Preview Content/Preview Assets.xcassets/Contents.json b/FirebaseVertexAI/Sample/GenerativeAITextSample/Preview Content/Preview Assets.xcassets/Contents.json
new file mode 100644
index 00000000000..73c00596a7f
--- /dev/null
+++ b/FirebaseVertexAI/Sample/GenerativeAITextSample/Preview Content/Preview Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/FirebaseVertexAI/Sample/GenerativeAITextSample/Screens/SummarizeScreen.swift b/FirebaseVertexAI/Sample/GenerativeAITextSample/Screens/SummarizeScreen.swift
new file mode 100644
index 00000000000..748c1addd5f
--- /dev/null
+++ b/FirebaseVertexAI/Sample/GenerativeAITextSample/Screens/SummarizeScreen.swift
@@ -0,0 +1,80 @@
+// Copyright 2023 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 MarkdownUI
+import SwiftUI
+
+struct SummarizeScreen: View {
+  @StateObject var viewModel = SummarizeViewModel()
+  @State var userInput = ""
+
+  enum FocusedField: Hashable {
+    case message
+  }
+
+  @FocusState
+  var focusedField: FocusedField?
+
+  var body: some View {
+    VStack {
+      VStack(alignment: .leading) {
+        Text("Enter some text, then tap on _Go_ to summarize it.")
+          .padding(.horizontal, 6)
+        HStack(alignment: .top) {
+          TextField("Enter text summarize", text: $userInput, axis: .vertical)
+            .focused($focusedField, equals: .message)
+            .textFieldStyle(.roundedBorder)
+            .onSubmit {
+              onSummarizeTapped()
+            }
+          Button("Go") {
+            onSummarizeTapped()
+          }
+          .padding(.top, 4)
+        }
+      }
+      .padding(.horizontal, 16)
+
+      List {
+        HStack(alignment: .top) {
+          if viewModel.inProgress {
+            ProgressView()
+          } else {
+            Image(systemName: "cloud.circle.fill")
+              .font(.title2)
+          }
+
+          Markdown("\(viewModel.outputText)")
+        }
+        .listRowSeparator(.hidden)
+      }
+      .listStyle(.plain)
+    }
+    .navigationTitle("Text sample")
+  }
+
+  private func onSummarizeTapped() {
+    focusedField = nil
+
+    Task {
+      await viewModel.summarize(inputText: userInput)
+    }
+  }
+}
+
+#Preview {
+  NavigationStack {
+    SummarizeScreen()
+  }
+}
diff --git a/FirebaseVertexAI/Sample/GenerativeAITextSample/ViewModels/SummarizeViewModel.swift b/FirebaseVertexAI/Sample/GenerativeAITextSample/ViewModels/SummarizeViewModel.swift
new file mode 100644
index 00000000000..77e815ea77d
--- /dev/null
+++ b/FirebaseVertexAI/Sample/GenerativeAITextSample/ViewModels/SummarizeViewModel.swift
@@ -0,0 +1,68 @@
+// Copyright 2023 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 FirebaseVertexAI
+import Foundation
+import OSLog
+
+@MainActor
+class SummarizeViewModel: ObservableObject {
+  private var logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "generative-ai")
+
+  @Published
+  var outputText = ""
+
+  @Published
+  var errorMessage: String?
+
+  @Published
+  var inProgress = false
+
+  private var model: GenerativeModel?
+
+  init() {
+    model = VertexAI.vertexAI().generativeModel(modelName: "gemini-1.5-flash-preview-0514")
+  }
+
+  func summarize(inputText: String) async {
+    defer {
+      inProgress = false
+    }
+    guard let model else {
+      return
+    }
+
+    do {
+      inProgress = true
+      errorMessage = nil
+      outputText = ""
+
+      let prompt = "Summarize the following text for me: \(inputText)"
+
+      let outputContentStream = model.generateContentStream(prompt)
+
+      // stream response
+      for try await outputContent in outputContentStream {
+        guard let line = outputContent.text else {
+          return
+        }
+
+        outputText = outputText + line
+      }
+    } catch {
+      logger.error("\(error.localizedDescription)")
+      errorMessage = error.localizedDescription
+    }
+  }
+}
diff --git a/FirebaseVertexAI/Sample/GenerativeAIUIComponents/Package.swift b/FirebaseVertexAI/Sample/GenerativeAIUIComponents/Package.swift
new file mode 100644
index 00000000000..808f5f42a97
--- /dev/null
+++ b/FirebaseVertexAI/Sample/GenerativeAIUIComponents/Package.swift
@@ -0,0 +1,35 @@
+// swift-tools-version: 5.9
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+// Copyright 2023 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 PackageDescription
+
+let package = Package(
+  name: "GenerativeAIUIComponents",
+  platforms: [
+    .iOS(.v16),
+  ],
+  products: [
+    .library(
+      name: "GenerativeAIUIComponents",
+      targets: ["GenerativeAIUIComponents"]
+    ),
+  ],
+  targets: [
+    .target(
+      name: "GenerativeAIUIComponents"
+    ),
+  ]
+)
diff --git a/FirebaseVertexAI/Sample/GenerativeAIUIComponents/Sources/GenerativeAIUIComponents/InputField.swift b/FirebaseVertexAI/Sample/GenerativeAIUIComponents/Sources/GenerativeAIUIComponents/InputField.swift
new file mode 100644
index 00000000000..67941c370cb
--- /dev/null
+++ b/FirebaseVertexAI/Sample/GenerativeAIUIComponents/Sources/GenerativeAIUIComponents/InputField.swift
@@ -0,0 +1,83 @@
+// Copyright 2023 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 SwiftUI
+
+public struct InputField<Label>: View where Label: View {
+  @Binding
+  private var text: String
+
+  private var title: String?
+  private var label: () -> Label
+
+  @Environment(\.submitHandler)
+  var submitHandler
+
+  private func submit() {
+    if let submitHandler {
+      submitHandler()
+    }
+  }
+
+  public init(_ title: String? = nil, text: Binding<String>,
+              @ViewBuilder label: @escaping () -> Label) {
+    self.title = title
+    _text = text
+    self.label = label
+  }
+
+  public var body: some View {
+    VStack(alignment: .leading) {
+      HStack(alignment: .bottom) {
+        VStack(alignment: .leading) {
+          TextField(
+            title ?? "",
+            text: $text,
+            axis: .vertical
+          )
+          .padding(.vertical, 4)
+          .onSubmit(submit)
+        }
+        .padding(.horizontal, 8)
+        .padding(.vertical, 4)
+        .overlay {
+          RoundedRectangle(
+            cornerRadius: 8,
+            style: .continuous
+          )
+          .stroke(Color(UIColor.systemFill), lineWidth: 1)
+        }
+
+        Button(action: submit, label: label)
+          .padding(.bottom, 4)
+      }
+    }
+    .padding(8)
+  }
+}
+
+#Preview {
+  struct Wrapper: View {
+    @State var userInput: String = ""
+
+    var body: some View {
+      InputField("Message", text: $userInput) {
+        Image(systemName: "arrow.up.circle.fill")
+          .font(.title)
+      }
+    }
+  }
+
+  return Wrapper()
+}
diff --git a/FirebaseVertexAI/Sample/GenerativeAIUIComponents/Sources/GenerativeAIUIComponents/MultimodalInputField.swift b/FirebaseVertexAI/Sample/GenerativeAIUIComponents/Sources/GenerativeAIUIComponents/MultimodalInputField.swift
new file mode 100644
index 00000000000..128da4a4e23
--- /dev/null
+++ b/FirebaseVertexAI/Sample/GenerativeAIUIComponents/Sources/GenerativeAIUIComponents/MultimodalInputField.swift
@@ -0,0 +1,183 @@
+// Copyright 2023 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 PhotosUI
+import SwiftUI
+
+struct MultimodalInputFieldSubmitHandler: EnvironmentKey {
+  static var defaultValue: (() -> Void)?
+}
+
+extension EnvironmentValues {
+  var submitHandler: (() -> Void)? {
+    get { self[MultimodalInputFieldSubmitHandler.self] }
+    set { self[MultimodalInputFieldSubmitHandler.self] = newValue }
+  }
+}
+
+public extension View {
+  func onSubmit(submitHandler: @escaping () -> Void) -> some View {
+    environment(\.submitHandler, submitHandler)
+  }
+}
+
+public struct MultimodalInputField: View {
+  @Binding public var text: String
+  @Binding public var selection: [PhotosPickerItem]
+
+  @Environment(\.submitHandler) var submitHandler
+
+  @State private var selectedImages = [Image]()
+
+  @State private var isChooseAttachmentTypePickerShowing = false
+  @State private var isAttachmentPickerShowing = false
+
+  private func showChooseAttachmentTypePicker() {
+    isChooseAttachmentTypePickerShowing.toggle()
+  }
+
+  private func showAttachmentPicker() {
+    isAttachmentPickerShowing.toggle()
+  }
+
+  private func submit() {
+    if let submitHandler {
+      submitHandler()
+    }
+  }
+
+  public init(text: Binding<String>,
+              selection: Binding<[PhotosPickerItem]>) {
+    _text = text
+    _selection = selection
+  }
+
+  public var body: some View {
+    VStack(alignment: .leading) {
+      HStack(alignment: .top) {
+        Button(action: showChooseAttachmentTypePicker) {
+          Image(systemName: "plus")
+        }
+        .padding(.top, 10)
+
+        VStack(alignment: .leading) {
+          TextField(
+            "Upload an image, and then ask a question about it",
+            text: $text,
+            axis: .vertical
+          )
+          .padding(.vertical, 4)
+          .onSubmit(submit)
+
+          if selectedImages.count > 0 {
+            ScrollView(.horizontal) {
+              LazyHStack {
+                ForEach(0 ..< selectedImages.count, id: \.self) { i in
+                  HStack {
+                    selectedImages[i]
+                      .resizable()
+                      .scaledToFill()
+                      .frame(width: 50, height: 50)
+                      .cornerRadius(8)
+                  }
+                }
+              }
+            }
+            .frame(height: 50)
+          }
+        }
+        .padding(.horizontal, 8)
+        .padding(.vertical, 4)
+        .overlay {
+          RoundedRectangle(
+            cornerRadius: 8,
+            style: .continuous
+          )
+          .stroke(Color(UIColor.systemFill), lineWidth: 1)
+        }
+
+        Button(action: submit) {
+          Text("Go")
+        }
+        .padding(.top, 8)
+      }
+    }
+    .padding(.horizontal)
+    .confirmationDialog(
+      "Select an image",
+      isPresented: $isChooseAttachmentTypePickerShowing,
+      titleVisibility: .hidden
+    ) {
+      Button(action: showAttachmentPicker) {
+        Text("Photo & Video Library")
+      }
+    }
+    .photosPicker(isPresented: $isAttachmentPickerShowing, selection: $selection)
+    .onChange(of: selection) { _ in
+      Task {
+        selectedImages.removeAll()
+
+        for item in selection {
+          if let data = try? await item.loadTransferable(type: Data.self) {
+            if let uiImage = UIImage(data: data) {
+              let image = Image(uiImage: uiImage)
+              selectedImages.append(image)
+            }
+          }
+        }
+      }
+    }
+  }
+}
+
+#Preview {
+  struct Wrapper: View {
+    @State var userInput: String = ""
+    @State var selectedItems = [PhotosPickerItem]()
+
+    @State private var selectedImages = [Image]()
+
+    var body: some View {
+      MultimodalInputField(text: $userInput, selection: $selectedItems)
+        .onChange(of: selectedItems) { _ in
+          Task {
+            selectedImages.removeAll()
+
+            for item in selectedItems {
+              if let data = try? await item.loadTransferable(type: Data.self) {
+                if let uiImage = UIImage(data: data) {
+                  let image = Image(uiImage: uiImage)
+                  selectedImages.append(image)
+                }
+              }
+            }
+          }
+        }
+
+      List {
+        ForEach(0 ..< $selectedImages.count, id: \.self) { i in
+          HStack {
+            selectedImages[i]
+              .resizable()
+              .scaledToFill()
+              .frame(width: .infinity)
+              .cornerRadius(8)
+          }
+        }
+      }
+    }
+  }
+
+  return Wrapper()
+}
diff --git a/FirebaseVertexAI/Sample/GoogleService-Info.plist b/FirebaseVertexAI/Sample/GoogleService-Info.plist
new file mode 100644
index 00000000000..b026413cc39
--- /dev/null
+++ b/FirebaseVertexAI/Sample/GoogleService-Info.plist
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>AD_UNIT_ID_FOR_BANNER_TEST</key>
+	<string>ca-app-pub-3940256099942544/2934735716</string>
+	<key>AD_UNIT_ID_FOR_INTERSTITIAL_TEST</key>
+	<string>ca-app-pub-3940256099942544/4411468910</string>
+	<key>API_KEY</key>
+	<string>AIzaSyAzlj4APqi5S58nFtE52Da0fYBOHA2MhaY</string>
+	<key>BUNDLE_ID</key>
+	<string>com.google.firebase.VertexAISample</string>
+	<key>CLIENT_ID</key>
+	<string>123456789000-hjugbg6ud799v4c49dim8ce2usclthar.apps.googleusercontent.com</string>
+	<key>DATABASE_URL</key>
+	<string>https://mockproject-1234.firebaseio.com</string>
+	<key>GCM_SENDER_ID</key>
+	<string>123456789000</string>
+	<key>GOOGLE_APP_ID</key>
+	<string>1:123456789000:ios:f1bf012572b04063</string>
+	<key>IS_ADS_ENABLED</key>
+	<true/>
+	<key>IS_ANALYTICS_ENABLED</key>
+	<true/>
+	<key>IS_APPINVITE_ENABLED</key>
+	<true/>
+	<key>IS_GCM_ENABLED</key>
+	<true/>
+	<key>IS_SIGNIN_ENABLED</key>
+	<true/>
+	<key>PLIST_VERSION</key>
+	<string>1</string>
+	<key>PROJECT_ID</key>
+	<string>mockproject-1234</string>
+	<key>REVERSED_CLIENT_ID</key>
+	<string>com.googleusercontent.apps.123456789000-hjugbg6ud799v4c49dim8ce2usclthar</string>
+	<key>STORAGE_BUCKET</key>
+	<string>mockproject-1234.appspot.com</string>
+</dict>
+</plist>
diff --git a/FirebaseVertexAI/Sample/README.md b/FirebaseVertexAI/Sample/README.md
new file mode 100644
index 00000000000..acf5a1d4713
--- /dev/null
+++ b/FirebaseVertexAI/Sample/README.md
@@ -0,0 +1,47 @@
+# Vertex AI for Firebase Sample App
+
+This sample demonstrates how to make calls to the Vertex AI Gemini API directly
+from your app, rather than server-side, using the
+[Vertex AI for Firebase SDK](https://firebase.google.com/docs/vertex-ai/get-started?platform=ios).
+
+## Getting Started
+
+### Clone and open the sample project
+
+1. Clone this repo.
+1. Change into the `FirebaseVertexAI/Sample` directory.
+1. Open `VertexAISample.xcodeproj` using Xcode.
+
+```bash
+git clone https://github.com/firebase/firebase-ios-sdk.git
+cd firebase-ios-sdk
+cd FirebaseVertexAI/Sample
+open VertexAISample.xcodeproj
+```
+
+### Connect the sample to your Firebase project
+
+To have a functional application, you will need to connect the Vertex AI for
+Firebase sample app to your Firebase project (or create a new project):
+
+1. Follow the instructions in
+   [Set up a Firebase project and connect your app to Firebase](https://firebase.google.com/docs/vertex-ai/get-started?platform=ios#set-up-firebase).
+2. Add an iOS+ app to your project. Make sure the `Bundle Identifier` you set
+   matches the one in the sample.
+     - The default bundle ID is `com.google.firebase.VertexAISample`
+3. Download the `GoogleService-Info.plist` for the app when prompted and save
+   it to the `FirebaseVertexAI/Sample` directory, overwriting the placeholder
+   file with the same name.
+
+You should now be able to build and run the sample!
+
+## Documentation
+
+To learn more about the Vertex AI for Firebase SDK, check out the
+[documentation](https://firebase.google.com/docs/vertex-ai).
+
+## Support
+
+- [GitHub Issue](https://github.com/firebase/firebase-ios-sdk/issues/new/choose)
+  - File an issue in the `firebase-ios-sdk` repo, choosing the Vertex AI product.
+- [Firebase Support](https://firebase.google.com/support/)
diff --git a/FirebaseVertexAI/Sample/VertexAISample.xcodeproj/project.pbxproj b/FirebaseVertexAI/Sample/VertexAISample.xcodeproj/project.pbxproj
new file mode 100644
index 00000000000..aa7eb9d4120
--- /dev/null
+++ b/FirebaseVertexAI/Sample/VertexAISample.xcodeproj/project.pbxproj
@@ -0,0 +1,644 @@
+// !$*UTF8*$!
+{
+	archiveVersion = 1;
+	classes = {
+	};
+	objectVersion = 60;
+	objects = {
+
+/* Begin PBXBuildFile section */
+		869200B32B879C4F00482873 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 869200B22B879C4F00482873 /* GoogleService-Info.plist */; };
+		86C1F4832BC726150026816F /* FunctionCallingScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86C1F47E2BC726150026816F /* FunctionCallingScreen.swift */; };
+		86C1F4842BC726150026816F /* FunctionCallingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86C1F4802BC726150026816F /* FunctionCallingViewModel.swift */; };
+		86D9CA8B2BED3EE1007D939E /* FirebaseAppCheck in Frameworks */ = {isa = PBXBuildFile; productRef = 86D9CA8A2BED3EE1007D939E /* FirebaseAppCheck */; };
+		86D9CA8F2BED3EE1007D939E /* FirebaseAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 86D9CA8E2BED3EE1007D939E /* FirebaseAuth */; };
+		86D9CAB52BED3EE1007D939E /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 86D9CAB42BED3EE1007D939E /* FirebaseStorage */; };
+		86D9CAB92BED3EE1007D939E /* FirebaseVertexAI-Preview in Frameworks */ = {isa = PBXBuildFile; productRef = 86D9CAB82BED3EE1007D939E /* FirebaseVertexAI-Preview */; };
+		88263BF02B239C09008AB09B /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88263BEE2B239BFE008AB09B /* ErrorView.swift */; };
+		88263BF12B239C11008AB09B /* ErrorDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 889873842B208563005B4896 /* ErrorDetailsView.swift */; };
+		8848C8332B0D04BC007B434F /* VertexAISampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8848C8322B0D04BC007B434F /* VertexAISampleApp.swift */; };
+		8848C8352B0D04BC007B434F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8848C8342B0D04BC007B434F /* ContentView.swift */; };
+		8848C8372B0D04BD007B434F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8848C8362B0D04BD007B434F /* Assets.xcassets */; };
+		8848C83A2B0D04BD007B434F /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8848C8392B0D04BD007B434F /* Preview Assets.xcassets */; };
+		886F95D52B17BA010036F07A /* SummarizeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88209C1B2B0FBDC300F64795 /* SummarizeScreen.swift */; };
+		886F95D62B17BA010036F07A /* SummarizeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88209C1D2B0FBDC300F64795 /* SummarizeViewModel.swift */; };
+		886F95D82B17BA420036F07A /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 886F95D72B17BA420036F07A /* MarkdownUI */; };
+		886F95DB2B17BAEF0036F07A /* PhotoReasoningViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8802666F2B0FC39000CF7CB6 /* PhotoReasoningViewModel.swift */; };
+		886F95DC2B17BAEF0036F07A /* PhotoReasoningScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 880266752B0FC39000CF7CB6 /* PhotoReasoningScreen.swift */; };
+		886F95DD2B17D5010036F07A /* MessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E10F5A2B11133E00C08E95 /* MessageView.swift */; };
+		886F95DE2B17D5010036F07A /* ChatMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E10F582B11131900C08E95 /* ChatMessage.swift */; };
+		886F95DF2B17D5010036F07A /* BouncingDots.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E10F5C2B11135000C08E95 /* BouncingDots.swift */; };
+		886F95E02B17D5010036F07A /* ConversationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E10F562B1112F600C08E95 /* ConversationViewModel.swift */; };
+		886F95E12B17D5010036F07A /* ConversationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88E10F542B1112CA00C08E95 /* ConversationScreen.swift */; };
+		886F95E32B17D6630036F07A /* GenerativeAIUIComponents in Frameworks */ = {isa = PBXBuildFile; productRef = 886F95E22B17D6630036F07A /* GenerativeAIUIComponents */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+		869200B22B879C4F00482873 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
+		86C1F47E2BC726150026816F /* FunctionCallingScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FunctionCallingScreen.swift; sourceTree = "<group>"; };
+		86C1F4802BC726150026816F /* FunctionCallingViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FunctionCallingViewModel.swift; sourceTree = "<group>"; };
+		8802666F2B0FC39000CF7CB6 /* PhotoReasoningViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoReasoningViewModel.swift; sourceTree = "<group>"; };
+		880266752B0FC39000CF7CB6 /* PhotoReasoningScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoReasoningScreen.swift; sourceTree = "<group>"; };
+		88209C1B2B0FBDC300F64795 /* SummarizeScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SummarizeScreen.swift; sourceTree = "<group>"; };
+		88209C1D2B0FBDC300F64795 /* SummarizeViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SummarizeViewModel.swift; sourceTree = "<group>"; };
+		88263BEE2B239BFE008AB09B /* ErrorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
+		8848C82F2B0D04BC007B434F /* VertexAISample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VertexAISample.app; sourceTree = BUILT_PRODUCTS_DIR; };
+		8848C8322B0D04BC007B434F /* VertexAISampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VertexAISampleApp.swift; sourceTree = "<group>"; };
+		8848C8342B0D04BC007B434F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
+		8848C8362B0D04BD007B434F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
+		8848C8392B0D04BD007B434F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
+		8848C84A2B0D051F007B434F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
+		8848C84D2B0D051F007B434F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
+		8848C85C2B0D056D007B434F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
+		8848C85F2B0D056D007B434F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
+		889873842B208563005B4896 /* ErrorDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetailsView.swift; sourceTree = "<group>"; };
+		88B8A9352B0FCBA700424728 /* GenerativeAIUIComponents */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = GenerativeAIUIComponents; sourceTree = "<group>"; };
+		88E10F482B110D5400C08E95 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
+		88E10F4B2B110D5400C08E95 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
+		88E10F542B1112CA00C08E95 /* ConversationScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationScreen.swift; sourceTree = "<group>"; };
+		88E10F562B1112F600C08E95 /* ConversationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationViewModel.swift; sourceTree = "<group>"; };
+		88E10F582B11131900C08E95 /* ChatMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatMessage.swift; sourceTree = "<group>"; };
+		88E10F5A2B11133E00C08E95 /* MessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageView.swift; sourceTree = "<group>"; };
+		88E10F5C2B11135000C08E95 /* BouncingDots.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BouncingDots.swift; sourceTree = "<group>"; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+		8848C82C2B0D04BC007B434F /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				886F95D82B17BA420036F07A /* MarkdownUI in Frameworks */,
+				86D9CAB52BED3EE1007D939E /* FirebaseStorage in Frameworks */,
+				86D9CA8F2BED3EE1007D939E /* FirebaseAuth in Frameworks */,
+				86D9CA8B2BED3EE1007D939E /* FirebaseAppCheck in Frameworks */,
+				886F95E32B17D6630036F07A /* GenerativeAIUIComponents in Frameworks */,
+				86D9CAB92BED3EE1007D939E /* FirebaseVertexAI-Preview in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+		86C1F47F2BC726150026816F /* Screens */ = {
+			isa = PBXGroup;
+			children = (
+				86C1F47E2BC726150026816F /* FunctionCallingScreen.swift */,
+			);
+			path = Screens;
+			sourceTree = "<group>";
+		};
+		86C1F4812BC726150026816F /* ViewModels */ = {
+			isa = PBXGroup;
+			children = (
+				86C1F4802BC726150026816F /* FunctionCallingViewModel.swift */,
+			);
+			path = ViewModels;
+			sourceTree = "<group>";
+		};
+		86C1F4822BC726150026816F /* FunctionCallingSample */ = {
+			isa = PBXGroup;
+			children = (
+				86C1F4812BC726150026816F /* ViewModels */,
+				86C1F47F2BC726150026816F /* Screens */,
+			);
+			path = FunctionCallingSample;
+			sourceTree = "<group>";
+		};
+		8802666E2B0FC39000CF7CB6 /* ViewModels */ = {
+			isa = PBXGroup;
+			children = (
+				8802666F2B0FC39000CF7CB6 /* PhotoReasoningViewModel.swift */,
+			);
+			path = ViewModels;
+			sourceTree = "<group>";
+		};
+		880266742B0FC39000CF7CB6 /* Screens */ = {
+			isa = PBXGroup;
+			children = (
+				880266752B0FC39000CF7CB6 /* PhotoReasoningScreen.swift */,
+			);
+			path = Screens;
+			sourceTree = "<group>";
+		};
+		88209C1A2B0FBDC300F64795 /* Screens */ = {
+			isa = PBXGroup;
+			children = (
+				88209C1B2B0FBDC300F64795 /* SummarizeScreen.swift */,
+			);
+			path = Screens;
+			sourceTree = "<group>";
+		};
+		88209C1C2B0FBDC300F64795 /* ViewModels */ = {
+			isa = PBXGroup;
+			children = (
+				88209C1D2B0FBDC300F64795 /* SummarizeViewModel.swift */,
+			);
+			path = ViewModels;
+			sourceTree = "<group>";
+		};
+		88209C222B0FBE1700F64795 /* Frameworks */ = {
+			isa = PBXGroup;
+			children = (
+			);
+			name = Frameworks;
+			sourceTree = "<group>";
+		};
+		8848C8262B0D04BC007B434F = {
+			isa = PBXGroup;
+			children = (
+				88B8A9352B0FCBA700424728 /* GenerativeAIUIComponents */,
+				869200B22B879C4F00482873 /* GoogleService-Info.plist */,
+				8848C8312B0D04BC007B434F /* VertexAISample */,
+				8848C8452B0D051E007B434F /* GenerativeAITextSample */,
+				8848C8572B0D056C007B434F /* GenerativeAIMultimodalSample */,
+				88E10F432B110D5300C08E95 /* ChatSample */,
+				86C1F4822BC726150026816F /* FunctionCallingSample */,
+				8848C8302B0D04BC007B434F /* Products */,
+				88209C222B0FBE1700F64795 /* Frameworks */,
+			);
+			sourceTree = "<group>";
+		};
+		8848C8302B0D04BC007B434F /* Products */ = {
+			isa = PBXGroup;
+			children = (
+				8848C82F2B0D04BC007B434F /* VertexAISample.app */,
+			);
+			name = Products;
+			sourceTree = "<group>";
+		};
+		8848C8312B0D04BC007B434F /* VertexAISample */ = {
+			isa = PBXGroup;
+			children = (
+				8848C8322B0D04BC007B434F /* VertexAISampleApp.swift */,
+				8848C8342B0D04BC007B434F /* ContentView.swift */,
+				8848C8362B0D04BD007B434F /* Assets.xcassets */,
+				8848C8382B0D04BD007B434F /* Preview Content */,
+			);
+			path = VertexAISample;
+			sourceTree = "<group>";
+		};
+		8848C8382B0D04BD007B434F /* Preview Content */ = {
+			isa = PBXGroup;
+			children = (
+				8848C8392B0D04BD007B434F /* Preview Assets.xcassets */,
+			);
+			path = "Preview Content";
+			sourceTree = "<group>";
+		};
+		8848C8452B0D051E007B434F /* GenerativeAITextSample */ = {
+			isa = PBXGroup;
+			children = (
+				88209C1C2B0FBDC300F64795 /* ViewModels */,
+				88209C1A2B0FBDC300F64795 /* Screens */,
+				8848C84A2B0D051F007B434F /* Assets.xcassets */,
+				8848C84C2B0D051F007B434F /* Preview Content */,
+			);
+			path = GenerativeAITextSample;
+			sourceTree = "<group>";
+		};
+		8848C84C2B0D051F007B434F /* Preview Content */ = {
+			isa = PBXGroup;
+			children = (
+				8848C84D2B0D051F007B434F /* Preview Assets.xcassets */,
+			);
+			path = "Preview Content";
+			sourceTree = "<group>";
+		};
+		8848C8572B0D056C007B434F /* GenerativeAIMultimodalSample */ = {
+			isa = PBXGroup;
+			children = (
+				8802666E2B0FC39000CF7CB6 /* ViewModels */,
+				880266742B0FC39000CF7CB6 /* Screens */,
+				8848C85C2B0D056D007B434F /* Assets.xcassets */,
+				8848C85E2B0D056D007B434F /* Preview Content */,
+			);
+			path = GenerativeAIMultimodalSample;
+			sourceTree = "<group>";
+		};
+		8848C85E2B0D056D007B434F /* Preview Content */ = {
+			isa = PBXGroup;
+			children = (
+				8848C85F2B0D056D007B434F /* Preview Assets.xcassets */,
+			);
+			path = "Preview Content";
+			sourceTree = "<group>";
+		};
+		88E10F432B110D5300C08E95 /* ChatSample */ = {
+			isa = PBXGroup;
+			children = (
+				88E10F522B11124A00C08E95 /* Models */,
+				88E10F502B11123600C08E95 /* ViewModels */,
+				88E10F512B11124100C08E95 /* Views */,
+				88E10F532B1112B900C08E95 /* Screens */,
+				88E10F482B110D5400C08E95 /* Assets.xcassets */,
+				88E10F4A2B110D5400C08E95 /* Preview Content */,
+			);
+			path = ChatSample;
+			sourceTree = "<group>";
+		};
+		88E10F4A2B110D5400C08E95 /* Preview Content */ = {
+			isa = PBXGroup;
+			children = (
+				88E10F4B2B110D5400C08E95 /* Preview Assets.xcassets */,
+			);
+			path = "Preview Content";
+			sourceTree = "<group>";
+		};
+		88E10F502B11123600C08E95 /* ViewModels */ = {
+			isa = PBXGroup;
+			children = (
+				88E10F562B1112F600C08E95 /* ConversationViewModel.swift */,
+			);
+			path = ViewModels;
+			sourceTree = "<group>";
+		};
+		88E10F512B11124100C08E95 /* Views */ = {
+			isa = PBXGroup;
+			children = (
+				88263BEE2B239BFE008AB09B /* ErrorView.swift */,
+				88E10F5A2B11133E00C08E95 /* MessageView.swift */,
+				88E10F5C2B11135000C08E95 /* BouncingDots.swift */,
+				889873842B208563005B4896 /* ErrorDetailsView.swift */,
+			);
+			path = Views;
+			sourceTree = "<group>";
+		};
+		88E10F522B11124A00C08E95 /* Models */ = {
+			isa = PBXGroup;
+			children = (
+				88E10F582B11131900C08E95 /* ChatMessage.swift */,
+			);
+			path = Models;
+			sourceTree = "<group>";
+		};
+		88E10F532B1112B900C08E95 /* Screens */ = {
+			isa = PBXGroup;
+			children = (
+				88E10F542B1112CA00C08E95 /* ConversationScreen.swift */,
+			);
+			path = Screens;
+			sourceTree = "<group>";
+		};
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+		8848C82E2B0D04BC007B434F /* VertexAISample */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 8848C83D2B0D04BD007B434F /* Build configuration list for PBXNativeTarget "VertexAISample" */;
+			buildPhases = (
+				8848C82B2B0D04BC007B434F /* Sources */,
+				8848C82C2B0D04BC007B434F /* Frameworks */,
+				8848C82D2B0D04BC007B434F /* Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			name = VertexAISample;
+			packageProductDependencies = (
+				886F95D72B17BA420036F07A /* MarkdownUI */,
+				886F95E22B17D6630036F07A /* GenerativeAIUIComponents */,
+				86D9CA8A2BED3EE1007D939E /* FirebaseAppCheck */,
+				86D9CA8E2BED3EE1007D939E /* FirebaseAuth */,
+				86D9CAB42BED3EE1007D939E /* FirebaseStorage */,
+				86D9CAB82BED3EE1007D939E /* FirebaseVertexAI-Preview */,
+			);
+			productName = GenerativeAISample;
+			productReference = 8848C82F2B0D04BC007B434F /* VertexAISample.app */;
+			productType = "com.apple.product-type.application";
+		};
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+		8848C8272B0D04BC007B434F /* Project object */ = {
+			isa = PBXProject;
+			attributes = {
+				BuildIndependentTargetsInParallel = 1;
+				LastSwiftUpdateCheck = 1510;
+				LastUpgradeCheck = 1510;
+				TargetAttributes = {
+					8848C82E2B0D04BC007B434F = {
+						CreatedOnToolsVersion = 15.1;
+					};
+				};
+			};
+			buildConfigurationList = 8848C82A2B0D04BC007B434F /* Build configuration list for PBXProject "VertexAISample" */;
+			compatibilityVersion = "Xcode 14.0";
+			developmentRegion = en;
+			hasScannedForEncodings = 0;
+			knownRegions = (
+				en,
+				Base,
+			);
+			mainGroup = 8848C8262B0D04BC007B434F;
+			packageReferences = (
+				88209C212B0FBDF700F64795 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
+				DEA09AC32B1FCE22001962D9 /* XCRemoteSwiftPackageReference "NetworkImage" */,
+				86D9CA892BED3EE1007D939E /* XCLocalSwiftPackageReference "../.." */,
+			);
+			productRefGroup = 8848C8302B0D04BC007B434F /* Products */;
+			projectDirPath = "";
+			projectRoot = "";
+			targets = (
+				8848C82E2B0D04BC007B434F /* VertexAISample */,
+			);
+		};
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+		8848C82D2B0D04BC007B434F /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				8848C83A2B0D04BD007B434F /* Preview Assets.xcassets in Resources */,
+				8848C8372B0D04BD007B434F /* Assets.xcassets in Resources */,
+				869200B32B879C4F00482873 /* GoogleService-Info.plist in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+		8848C82B2B0D04BC007B434F /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				86C1F4832BC726150026816F /* FunctionCallingScreen.swift in Sources */,
+				886F95DF2B17D5010036F07A /* BouncingDots.swift in Sources */,
+				86C1F4842BC726150026816F /* FunctionCallingViewModel.swift in Sources */,
+				886F95DE2B17D5010036F07A /* ChatMessage.swift in Sources */,
+				88263BF12B239C11008AB09B /* ErrorDetailsView.swift in Sources */,
+				8848C8352B0D04BC007B434F /* ContentView.swift in Sources */,
+				886F95D52B17BA010036F07A /* SummarizeScreen.swift in Sources */,
+				8848C8332B0D04BC007B434F /* VertexAISampleApp.swift in Sources */,
+				886F95E02B17D5010036F07A /* ConversationViewModel.swift in Sources */,
+				886F95DD2B17D5010036F07A /* MessageView.swift in Sources */,
+				886F95DC2B17BAEF0036F07A /* PhotoReasoningScreen.swift in Sources */,
+				886F95DB2B17BAEF0036F07A /* PhotoReasoningViewModel.swift in Sources */,
+				886F95E12B17D5010036F07A /* ConversationScreen.swift in Sources */,
+				88263BF02B239C09008AB09B /* ErrorView.swift in Sources */,
+				886F95D62B17BA010036F07A /* SummarizeViewModel.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+		8848C83B2B0D04BD007B434F /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_ENABLE_OBJC_WEAK = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_COMMA = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_STRICT_PROTOTYPES = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = dwarf;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				ENABLE_TESTABILITY = YES;
+				ENABLE_USER_SCRIPT_SANDBOXING = YES;
+				GCC_C_LANGUAGE_STANDARD = gnu17;
+				GCC_DYNAMIC_NO_PIC = NO;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_OPTIMIZATION_LEVEL = 0;
+				GCC_PREPROCESSOR_DEFINITIONS = (
+					"DEBUG=1",
+					"$(inherited)",
+				);
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+				MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+				MTL_FAST_MATH = YES;
+				ONLY_ACTIVE_ARCH = YES;
+				SDKROOT = iphoneos;
+				SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+			};
+			name = Debug;
+		};
+		8848C83C2B0D04BD007B434F /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_ENABLE_OBJC_WEAK = YES;
+				CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_COMMA = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+				CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+				CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+				CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+				CLANG_WARN_STRICT_PROTOTYPES = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+				ENABLE_NS_ASSERTIONS = NO;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				ENABLE_USER_SCRIPT_SANDBOXING = YES;
+				GCC_C_LANGUAGE_STANDARD = gnu17;
+				GCC_NO_COMMON_BLOCKS = YES;
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				IPHONEOS_DEPLOYMENT_TARGET = 16.0;
+				LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+				MTL_ENABLE_DEBUG_INFO = NO;
+				MTL_FAST_MATH = YES;
+				SDKROOT = iphoneos;
+				SWIFT_COMPILATION_MODE = wholemodule;
+				VALIDATE_PRODUCT = YES;
+			};
+			name = Release;
+		};
+		8848C83E2B0D04BD007B434F /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_ASSET_PATHS = "\"VertexAISample/Preview Content\"";
+				DEVELOPMENT_TEAM = "";
+				ENABLE_PREVIEWS = YES;
+				ENABLE_USER_SCRIPT_SANDBOXING = NO;
+				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+				);
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.google.firebase.VertexAISample;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Debug;
+		};
+		8848C83F2B0D04BD007B434F /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+				CODE_SIGN_STYLE = Automatic;
+				CURRENT_PROJECT_VERSION = 1;
+				DEVELOPMENT_ASSET_PATHS = "\"VertexAISample/Preview Content\"";
+				DEVELOPMENT_TEAM = "";
+				ENABLE_PREVIEWS = YES;
+				ENABLE_USER_SCRIPT_SANDBOXING = NO;
+				GENERATE_INFOPLIST_FILE = YES;
+				INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
+				INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
+				INFOPLIST_KEY_UILaunchScreen_Generation = YES;
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
+				LD_RUNPATH_SEARCH_PATHS = (
+					"$(inherited)",
+					"@executable_path/Frameworks",
+				);
+				MARKETING_VERSION = 1.0;
+				PRODUCT_BUNDLE_IDENTIFIER = com.google.firebase.VertexAISample;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_EMIT_LOC_STRINGS = YES;
+				SWIFT_VERSION = 5.0;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Release;
+		};
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+		8848C82A2B0D04BC007B434F /* Build configuration list for PBXProject "VertexAISample" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				8848C83B2B0D04BD007B434F /* Debug */,
+				8848C83C2B0D04BD007B434F /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+		8848C83D2B0D04BD007B434F /* Build configuration list for PBXNativeTarget "VertexAISample" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				8848C83E2B0D04BD007B434F /* Debug */,
+				8848C83F2B0D04BD007B434F /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+		86D9CA892BED3EE1007D939E /* XCLocalSwiftPackageReference "../.." */ = {
+			isa = XCLocalSwiftPackageReference;
+			relativePath = ../..;
+		};
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCRemoteSwiftPackageReference section */
+		88209C212B0FBDF700F64795 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
+			isa = XCRemoteSwiftPackageReference;
+			repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
+			requirement = {
+				kind = revision;
+				revision = 5df8a4adedd6ae4eb2455ef60ff75183984daeb8;
+			};
+		};
+		DEA09AC32B1FCE22001962D9 /* XCRemoteSwiftPackageReference "NetworkImage" */ = {
+			isa = XCRemoteSwiftPackageReference;
+			repositoryURL = "https://github.com/gonzalezreal/NetworkImage";
+			requirement = {
+				kind = revision;
+				revision = 7aff8d1b31148d32c5933d75557d42f6323ee3d1;
+			};
+		};
+/* End XCRemoteSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+		86D9CA8A2BED3EE1007D939E /* FirebaseAppCheck */ = {
+			isa = XCSwiftPackageProductDependency;
+			productName = FirebaseAppCheck;
+		};
+		86D9CA8E2BED3EE1007D939E /* FirebaseAuth */ = {
+			isa = XCSwiftPackageProductDependency;
+			productName = FirebaseAuth;
+		};
+		86D9CAB42BED3EE1007D939E /* FirebaseStorage */ = {
+			isa = XCSwiftPackageProductDependency;
+			productName = FirebaseStorage;
+		};
+		86D9CAB82BED3EE1007D939E /* FirebaseVertexAI-Preview */ = {
+			isa = XCSwiftPackageProductDependency;
+			productName = "FirebaseVertexAI-Preview";
+		};
+		886F95D72B17BA420036F07A /* MarkdownUI */ = {
+			isa = XCSwiftPackageProductDependency;
+			package = 88209C212B0FBDF700F64795 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
+			productName = MarkdownUI;
+		};
+		886F95E22B17D6630036F07A /* GenerativeAIUIComponents */ = {
+			isa = XCSwiftPackageProductDependency;
+			productName = GenerativeAIUIComponents;
+		};
+/* End XCSwiftPackageProductDependency section */
+	};
+	rootObject = 8848C8272B0D04BC007B434F /* Project object */;
+}
diff --git a/FirebaseVertexAI/Sample/VertexAISample/Assets.xcassets/AccentColor.colorset/Contents.json b/FirebaseVertexAI/Sample/VertexAISample/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 00000000000..eb878970081
--- /dev/null
+++ b/FirebaseVertexAI/Sample/VertexAISample/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,11 @@
+{
+  "colors" : [
+    {
+      "idiom" : "universal"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/FirebaseVertexAI/Sample/VertexAISample/Assets.xcassets/AppIcon.appiconset/Contents.json b/FirebaseVertexAI/Sample/VertexAISample/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000000..13613e3ee1a
--- /dev/null
+++ b/FirebaseVertexAI/Sample/VertexAISample/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,13 @@
+{
+  "images" : [
+    {
+      "idiom" : "universal",
+      "platform" : "ios",
+      "size" : "1024x1024"
+    }
+  ],
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/FirebaseVertexAI/Sample/VertexAISample/Assets.xcassets/Contents.json b/FirebaseVertexAI/Sample/VertexAISample/Assets.xcassets/Contents.json
new file mode 100644
index 00000000000..73c00596a7f
--- /dev/null
+++ b/FirebaseVertexAI/Sample/VertexAISample/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/FirebaseVertexAI/Sample/VertexAISample/ContentView.swift b/FirebaseVertexAI/Sample/VertexAISample/ContentView.swift
new file mode 100644
index 00000000000..2841d634a6c
--- /dev/null
+++ b/FirebaseVertexAI/Sample/VertexAISample/ContentView.swift
@@ -0,0 +1,56 @@
+// Copyright 2023 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 SwiftUI
+
+struct ContentView: View {
+  @StateObject
+  var viewModel = ConversationViewModel()
+
+  @StateObject
+  var functionCallingViewModel = FunctionCallingViewModel()
+
+  var body: some View {
+    NavigationStack {
+      List {
+        NavigationLink {
+          SummarizeScreen()
+        } label: {
+          Label("Text", systemImage: "doc.text")
+        }
+        NavigationLink {
+          PhotoReasoningScreen()
+        } label: {
+          Label("Multi-modal", systemImage: "doc.richtext")
+        }
+        NavigationLink {
+          ConversationScreen()
+            .environmentObject(viewModel)
+        } label: {
+          Label("Chat", systemImage: "ellipsis.message.fill")
+        }
+        NavigationLink {
+          FunctionCallingScreen().environmentObject(functionCallingViewModel)
+        } label: {
+          Label("Function Calling", systemImage: "function")
+        }
+      }
+      .navigationTitle("Generative AI Samples")
+    }
+  }
+}
+
+#Preview {
+  ContentView()
+}
diff --git a/FirebaseVertexAI/Sample/VertexAISample/Preview Content/Preview Assets.xcassets/Contents.json b/FirebaseVertexAI/Sample/VertexAISample/Preview Content/Preview Assets.xcassets/Contents.json
new file mode 100644
index 00000000000..73c00596a7f
--- /dev/null
+++ b/FirebaseVertexAI/Sample/VertexAISample/Preview Content/Preview Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+  "info" : {
+    "author" : "xcode",
+    "version" : 1
+  }
+}
diff --git a/FirebaseVertexAI/Sample/VertexAISample/VertexAISampleApp.swift b/FirebaseVertexAI/Sample/VertexAISample/VertexAISampleApp.swift
new file mode 100644
index 00000000000..f675f77a423
--- /dev/null
+++ b/FirebaseVertexAI/Sample/VertexAISample/VertexAISampleApp.swift
@@ -0,0 +1,67 @@
+// Copyright 2023 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 FirebaseAppCheck
+import FirebaseCore
+import SwiftUI
+
+class AppDelegate: NSObject, UIApplicationDelegate {
+  func application(_ application: UIApplication,
+                   didFinishLaunchingWithOptions launchOptions: [UIApplication
+                     .LaunchOptionsKey: Any]? = nil) -> Bool {
+    // Recommendation: Protect your Vertex AI API resources from abuse by preventing unauthorized
+    // clients using App Check; see https://firebase.google.com/docs/app-check#get_started.
+    AppCheck.setAppCheckProviderFactory(AppCheckNotConfiguredFactory())
+
+    FirebaseApp.configure()
+
+    if let firebaseApp = FirebaseApp.app(), firebaseApp.options.projectID == "mockproject-1234" {
+      guard let bundleID = Bundle.main.bundleIdentifier else { fatalError() }
+      fatalError("""
+      You must create and/or download a valid `GoogleService-Info.plist` file for \(bundleID) from \
+      https://console.firebase.google.com to run this sample. Replace the existing \
+      `GoogleService-Info.plist` file in the `FirebaseVertexAI/Sample` directory with this new file.
+      """)
+    }
+
+    return true
+  }
+}
+
+@main
+struct VertexAISampleApp: App {
+  @UIApplicationDelegateAdaptor var appDelegate: AppDelegate
+
+  var body: some Scene {
+    WindowGroup {
+      ContentView()
+    }
+  }
+}
+
+/// Placeholder App Check provider factory that returns a simple ``AppCheckNotConfigured`` error.
+private class AppCheckNotConfiguredFactory: NSObject, AppCheckProviderFactory {
+  private class AppCheckNotConfiguredProvider: NSObject, AppCheckProvider {
+    func getToken() async throws -> AppCheckToken {
+      throw AppCheckNotConfigured()
+    }
+  }
+
+  func createProvider(with app: FirebaseApp) -> (any AppCheckProvider)? {
+    return AppCheckNotConfiguredProvider()
+  }
+}
+
+/// Error indicating that App Check is not configured in the sample app.
+struct AppCheckNotConfigured: Error {}
diff --git a/FirebaseVertexAI/Sources/Chat.swift b/FirebaseVertexAI/Sources/Chat.swift
new file mode 100644
index 00000000000..6549df469d0
--- /dev/null
+++ b/FirebaseVertexAI/Sources/Chat.swift
@@ -0,0 +1,191 @@
+// Copyright 2023 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 Foundation
+
+/// An object that represents a back-and-forth chat with a model, capturing the history and saving
+/// the context in memory between each message sent.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public class Chat {
+  private let model: GenerativeModel
+
+  /// Initializes a new chat representing a 1:1 conversation between model and user.
+  init(model: GenerativeModel, history: [ModelContent]) {
+    self.model = model
+    self.history = history
+  }
+
+  /// The previous content from the chat that has been successfully sent and received from the
+  /// model. This will be provided to the model for each message sent as context for the discussion.
+  public var history: [ModelContent]
+
+  /// Sends a message using the existing history of this chat as context. If successful, the message
+  /// and response will be added to the history. If unsuccessful, history will remain unchanged.
+  /// - Parameter parts: The new content to send as a single chat message.
+  /// - Returns: The model's response if no error occurred.
+  /// - Throws: A ``GenerateContentError`` if an error occurred.
+  public func sendMessage(_ parts: any ThrowingPartsRepresentable...) async throws
+    -> GenerateContentResponse {
+    return try await sendMessage([ModelContent(parts: parts)])
+  }
+
+  /// Sends a message using the existing history of this chat as context. If successful, the message
+  /// and response will be added to the history. If unsuccessful, history will remain unchanged.
+  /// - Parameter content: The new content to send as a single chat message.
+  /// - Returns: The model's response if no error occurred.
+  /// - Throws: A ``GenerateContentError`` if an error occurred.
+  public func sendMessage(_ content: @autoclosure () throws -> [ModelContent]) async throws
+    -> GenerateContentResponse {
+    // Ensure that the new content has the role set.
+    let newContent: [ModelContent]
+    do {
+      newContent = try content().map(populateContentRole(_:))
+    } catch let underlying {
+      if let contentError = underlying as? ImageConversionError {
+        throw GenerateContentError.promptImageContentError(underlying: contentError)
+      } else {
+        throw GenerateContentError.internalError(underlying: underlying)
+      }
+    }
+
+    // Send the history alongside the new message as context.
+    let request = history + newContent
+    let result = try await model.generateContent(request)
+    guard let reply = result.candidates.first?.content else {
+      let error = NSError(domain: "com.google.generative-ai",
+                          code: -1,
+                          userInfo: [
+                            NSLocalizedDescriptionKey: "No candidates with content available.",
+                          ])
+      throw GenerateContentError.internalError(underlying: error)
+    }
+
+    // Make sure we inject the role into the content received.
+    let toAdd = ModelContent(role: "model", parts: reply.parts)
+
+    // Append the request and successful result to history, then return the value.
+    history.append(contentsOf: newContent)
+    history.append(toAdd)
+    return result
+  }
+
+  /// Sends a message using the existing history of this chat as context. If successful, the message
+  /// and response will be added to the history. If unsuccessful, history will remain unchanged.
+  /// - Parameter parts: The new content to send as a single chat message.
+  /// - Returns: A stream containing the model's response or an error if an error occurred.
+  @available(macOS 12.0, *)
+  public func sendMessageStream(_ parts: any ThrowingPartsRepresentable...)
+    -> AsyncThrowingStream<GenerateContentResponse, Error> {
+    return try sendMessageStream([ModelContent(parts: parts)])
+  }
+
+  /// Sends a message using the existing history of this chat as context. If successful, the message
+  /// and response will be added to the history. If unsuccessful, history will remain unchanged.
+  /// - Parameter content: The new content to send as a single chat message.
+  /// - Returns: A stream containing the model's response or an error if an error occurred.
+  @available(macOS 12.0, *)
+  public func sendMessageStream(_ content: @autoclosure () throws -> [ModelContent])
+    -> AsyncThrowingStream<GenerateContentResponse, Error> {
+    let resolvedContent: [ModelContent]
+    do {
+      resolvedContent = try content()
+    } catch let underlying {
+      return AsyncThrowingStream { continuation in
+        let error: Error
+        if let contentError = underlying as? ImageConversionError {
+          error = GenerateContentError.promptImageContentError(underlying: contentError)
+        } else {
+          error = GenerateContentError.internalError(underlying: underlying)
+        }
+        continuation.finish(throwing: error)
+      }
+    }
+
+    return AsyncThrowingStream { continuation in
+      Task {
+        var aggregatedContent: [ModelContent] = []
+
+        // Ensure that the new content has the role set.
+        let newContent: [ModelContent] = resolvedContent.map(populateContentRole(_:))
+
+        // Send the history alongside the new message as context.
+        let request = history + newContent
+        let stream = model.generateContentStream(request)
+        do {
+          for try await chunk in stream {
+            // Capture any content that's streaming. This should be populated if there's no error.
+            if let chunkContent = chunk.candidates.first?.content {
+              aggregatedContent.append(chunkContent)
+            }
+
+            // Pass along the chunk.
+            continuation.yield(chunk)
+          }
+        } catch {
+          // Rethrow the error that the underlying stream threw. Don't add anything to history.
+          continuation.finish(throwing: error)
+          return
+        }
+
+        // Save the request.
+        history.append(contentsOf: newContent)
+
+        // Aggregate the content to add it to the history before we finish.
+        let aggregated = aggregatedChunks(aggregatedContent)
+        history.append(aggregated)
+
+        continuation.finish()
+      }
+    }
+  }
+
+  private func aggregatedChunks(_ chunks: [ModelContent]) -> ModelContent {
+    var parts: [ModelContent.Part] = []
+    var combinedText = ""
+    for aggregate in chunks {
+      // Loop through all the parts, aggregating the text and adding the images.
+      for part in aggregate.parts {
+        switch part {
+        case let .text(str):
+          combinedText += str
+
+        case .data, .fileData, .functionCall, .functionResponse:
+          // Don't combine it, just add to the content. If there's any text pending, add that as
+          // a part.
+          if !combinedText.isEmpty {
+            parts.append(.text(combinedText))
+            combinedText = ""
+          }
+
+          parts.append(part)
+        }
+      }
+    }
+
+    if !combinedText.isEmpty {
+      parts.append(.text(combinedText))
+    }
+
+    return ModelContent(role: "model", parts: parts)
+  }
+
+  /// Populates the `role` field with `user` if it doesn't exist. Required in chat sessions.
+  private func populateContentRole(_ content: ModelContent) -> ModelContent {
+    if content.role != nil {
+      return content
+    } else {
+      return ModelContent(role: "user", parts: content.parts)
+    }
+  }
+}
diff --git a/FirebaseVertexAI/Sources/Constants.swift b/FirebaseVertexAI/Sources/Constants.swift
new file mode 100644
index 00000000000..d23744fee76
--- /dev/null
+++ b/FirebaseVertexAI/Sources/Constants.swift
@@ -0,0 +1,26 @@
+// Copyright 2023 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 Foundation
+
+#if !os(macOS) && !os(iOS)
+  #warning("Only iOS, macOS, and Catalyst targets are currently fully supported.")
+#endif
+
+/// Constants associated with the Vertex AI for Firebase SDK.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+enum Constants {
+  /// The Vertex AI backend endpoint URL.
+  static let baseURL = "https://firebaseml.googleapis.com"
+}
diff --git a/FirebaseVertexAI/Sources/CountTokensRequest.swift b/FirebaseVertexAI/Sources/CountTokensRequest.swift
new file mode 100644
index 00000000000..e08566bede9
--- /dev/null
+++ b/FirebaseVertexAI/Sources/CountTokensRequest.swift
@@ -0,0 +1,70 @@
+// Copyright 2023 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 Foundation
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+struct CountTokensRequest {
+  let model: String
+  let contents: [ModelContent]
+  let options: RequestOptions
+}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension CountTokensRequest: GenerativeAIRequest {
+  typealias Response = CountTokensResponse
+
+  var url: URL {
+    URL(string: "\(Constants.baseURL)/\(options.apiVersion)/\(model):countTokens")!
+  }
+}
+
+/// The model's response to a count tokens request.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public struct CountTokensResponse {
+  /// The total number of tokens in the input given to the model as a prompt.
+  public let totalTokens: Int
+
+  /// The total number of billable characters in the text input given to the model as a prompt.
+  ///
+  /// > Important: This does not include billable image, video or other non-text input. See
+  /// [Vertex AI pricing](https://cloud.google.com/vertex-ai/generative-ai/pricing) for details.
+  public let totalBillableCharacters: Int
+}
+
+// MARK: - Codable Conformances
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension CountTokensRequest: Encodable {
+  enum CodingKeys: CodingKey {
+    case contents
+  }
+}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension CountTokensResponse: Decodable {
+  enum CodingKeys: CodingKey {
+    case totalTokens
+    case totalBillableCharacters
+  }
+
+  public init(from decoder: any Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+    totalTokens = try container.decode(Int.self, forKey: .totalTokens)
+    totalBillableCharacters = try container.decodeIfPresent(
+      Int.self,
+      forKey: .totalBillableCharacters
+    ) ?? 0
+  }
+}
diff --git a/FirebaseVertexAI/Sources/Errors.swift b/FirebaseVertexAI/Sources/Errors.swift
new file mode 100644
index 00000000000..17a4dd9e201
--- /dev/null
+++ b/FirebaseVertexAI/Sources/Errors.swift
@@ -0,0 +1,199 @@
+// Copyright 2023 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 Foundation
+
+struct RPCError: Error {
+  let httpResponseCode: Int
+  let message: String
+  let status: RPCStatus
+  let details: [ErrorDetails]
+
+  private var errorInfo: ErrorDetails? {
+    return details.first { $0.isErrorInfo() }
+  }
+
+  init(httpResponseCode: Int, message: String, status: RPCStatus, details: [ErrorDetails]) {
+    self.httpResponseCode = httpResponseCode
+    self.message = message
+    self.status = status
+    self.details = details
+  }
+
+  func isFirebaseMLServiceDisabledError() -> Bool {
+    return details.contains { $0.isFirebaseMLServiceDisabledErrorDetails() }
+  }
+}
+
+extension RPCError: Decodable {
+  enum CodingKeys: CodingKey {
+    case error
+  }
+
+  init(from decoder: Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+    let status = try container.decode(ErrorStatus.self, forKey: .error)
+
+    if let code = status.code {
+      httpResponseCode = code
+    } else {
+      httpResponseCode = -1
+    }
+
+    if let message = status.message {
+      self.message = message
+    } else {
+      message = "Unknown error."
+    }
+
+    if let rpcStatus = status.status {
+      self.status = rpcStatus
+    } else {
+      self.status = .unknown
+    }
+
+    details = status.details
+  }
+}
+
+struct ErrorStatus {
+  let code: Int?
+  let message: String?
+  let status: RPCStatus?
+  let details: [ErrorDetails]
+}
+
+struct ErrorDetails {
+  static let errorInfoType = "type.googleapis.com/google.rpc.ErrorInfo"
+
+  let type: String
+  let reason: String?
+  let domain: String?
+  let metadata: [String: String]?
+
+  func isErrorInfo() -> Bool {
+    return type == ErrorDetails.errorInfoType
+  }
+
+  func isFirebaseMLServiceDisabledErrorDetails() -> Bool {
+    guard isErrorInfo() else {
+      return false
+    }
+    guard reason == "SERVICE_DISABLED" else {
+      return false
+    }
+    guard domain == "googleapis.com" else {
+      return false
+    }
+    guard let metadata, metadata["service"] == "firebaseml.googleapis.com" else {
+      return false
+    }
+    return true
+  }
+}
+
+extension ErrorDetails: Decodable, Equatable {
+  enum CodingKeys: String, CodingKey {
+    case type = "@type"
+    case reason
+    case domain
+    case metadata
+  }
+}
+
+extension ErrorStatus: Decodable {
+  enum CodingKeys: CodingKey {
+    case code
+    case message
+    case status
+    case details
+  }
+
+  init(from decoder: Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+    code = try container.decodeIfPresent(Int.self, forKey: .code)
+    message = try container.decodeIfPresent(String.self, forKey: .message)
+    do {
+      status = try container.decodeIfPresent(RPCStatus.self, forKey: .status)
+    } catch {
+      status = .unknown
+    }
+    if container.contains(.details) {
+      details = try container.decode([ErrorDetails].self, forKey: .details)
+    } else {
+      details = []
+    }
+  }
+}
+
+enum RPCStatus: String, Decodable {
+  // Not an error; returned on success.
+  case ok = "OK"
+
+  // The operation was cancelled, typically by the caller.
+  case cancelled = "CANCELLED"
+
+  // Unknown error.
+  case unknown = "UNKNOWN"
+
+  // The client specified an invalid argument.
+  case invalidArgument = "INVALID_ARGUMENT"
+
+  // The deadline expired before the operation could complete.
+  case deadlineExceeded = "DEADLINE_EXCEEDED"
+
+  // Some requested entity (e.g., file or directory) was not found.
+  case notFound = "NOT_FOUND"
+
+  // The entity that a client attempted to create (e.g., file or directory) already exists.
+  case alreadyExists = "ALREADY_EXISTS"
+
+  // The caller does not have permission to execute the specified operation.
+  case permissionDenied = "PERMISSION_DENIED"
+
+  // The request does not have valid authentication credentials for the operation.
+  case unauthenticated = "UNAUTHENTICATED"
+
+  // Some resource has been exhausted, perhaps a per-user quota, or perhaps the entire file system
+  // is out of space.
+  case resourceExhausted = "RESOURCE_EXHAUSTED"
+
+  // The operation was rejected because the system is not in a state required for the operation's
+  // execution.
+  case failedPrecondition = "FAILED_PRECONDITION"
+
+  // The operation was aborted, typically due to a concurrency issue such as a sequencer check
+  // failure or transaction abort.
+  case aborted = "ABORTED"
+
+  // The operation was attempted past the valid range.
+  case outOfRange = "OUT_OF_RANGE"
+
+  // The operation is not implemented or is not supported/enabled in this service.
+  case unimplemented = "UNIMPLEMENTED"
+
+  // Internal errors.
+  case internalError = "INTERNAL"
+
+  // The service is currently unavailable.
+  case unavailable = "UNAVAILABLE"
+
+  // Unrecoverable data loss or corruption.
+  case dataLoss = "DATA_LOSS"
+}
+
+enum InvalidCandidateError: Error {
+  case emptyContent(underlyingError: Error)
+  case malformedContent(underlyingError: Error)
+}
diff --git a/FirebaseVertexAI/Sources/FunctionCalling.swift b/FirebaseVertexAI/Sources/FunctionCalling.swift
new file mode 100644
index 00000000000..d3b5e2da61e
--- /dev/null
+++ b/FirebaseVertexAI/Sources/FunctionCalling.swift
@@ -0,0 +1,295 @@
+// Copyright 2024 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 Foundation
+
+/// A predicted function call returned from the model.
+public struct FunctionCall: Equatable {
+  /// The name of the function to call.
+  public let name: String
+
+  /// The function parameters and values.
+  public let args: JSONObject
+}
+
+/// A `Schema` object allows the definition of input and output data types.
+///
+/// These types can be objects, but also primitives and arrays. Represents a select subset of an
+/// [OpenAPI 3.0 schema object](https://spec.openapis.org/oas/v3.0.3#schema).
+public class Schema {
+  /// The data type.
+  let type: DataType
+
+  /// The format of the data.
+  let format: String?
+
+  /// A brief description of the parameter.
+  let description: String?
+
+  /// Indicates if the value may be null.
+  let nullable: Bool?
+
+  /// Possible values of the element of type ``DataType/string`` with "enum" format.
+  let enumValues: [String]?
+
+  /// Schema of the elements of type ``DataType/array``.
+  let items: Schema?
+
+  /// Properties of type ``DataType/object``.
+  let properties: [String: Schema]?
+
+  /// Required properties of type ``DataType/object``.
+  let requiredProperties: [String]?
+
+  /// Constructs a new `Schema`.
+  ///
+  /// - Parameters:
+  ///   - type: The data type.
+  ///   - format: The format of the data; used only for primitive datatypes.
+  ///     Supported formats:
+  ///     - ``DataType/integer``: int32, int64
+  ///     - ``DataType/number``: float, double
+  ///     - ``DataType/string``: enum
+  ///   - description: A brief description of the parameter; may be formatted as Markdown.
+  ///   - nullable: Indicates if the value may be null.
+  ///   - enumValues: Possible values of the element of type ``DataType/string`` with "enum" format.
+  ///     For example, an enum `Direction` may be defined as `["EAST", NORTH", "SOUTH", "WEST"]`.
+  ///   - items: Schema of the elements of type ``DataType/array``.
+  ///   - properties: Properties of type ``DataType/object``.
+  ///   - requiredProperties: Required properties of type ``DataType/object``.
+  public init(type: DataType, format: String? = nil, description: String? = nil,
+              nullable: Bool? = nil,
+              enumValues: [String]? = nil, items: Schema? = nil,
+              properties: [String: Schema]? = nil,
+              requiredProperties: [String]? = nil) {
+    self.type = type
+    self.format = format
+    self.description = description
+    self.nullable = nullable
+    self.enumValues = enumValues
+    self.items = items
+    self.properties = properties
+    self.requiredProperties = requiredProperties
+  }
+}
+
+/// A data type.
+///
+/// Contains the set of OpenAPI [data types](https://spec.openapis.org/oas/v3.0.3#data-types).
+public enum DataType: String {
+  /// A `String` type.
+  case string = "STRING"
+
+  /// A floating-point number type.
+  case number = "NUMBER"
+
+  /// An integer type.
+  case integer = "INTEGER"
+
+  /// A boolean type.
+  case boolean = "BOOLEAN"
+
+  /// An array type.
+  case array = "ARRAY"
+
+  /// An object type.
+  case object = "OBJECT"
+}
+
+/// Structured representation of a function declaration.
+///
+/// This `FunctionDeclaration` is a representation of a block of code that can be used as a ``Tool``
+/// by the model and executed by the client.
+public struct FunctionDeclaration {
+  /// The name of the function.
+  let name: String
+
+  /// A brief description of the function.
+  let description: String
+
+  /// Describes the parameters to this function; must be of type ``DataType/object``.
+  let parameters: Schema?
+
+  /// Constructs a new `FunctionDeclaration`.
+  ///
+  /// - Parameters:
+  ///   - name: The name of the function; must be a-z, A-Z, 0-9, or contain underscores and dashes,
+  ///   with a maximum length of 63.
+  ///   - description: A brief description of the function.
+  ///   - parameters: Describes the parameters to this function; the keys are parameter names and
+  ///   the values are ``Schema`` objects describing them.
+  ///   - requiredParameters: A list of required parameters by name.
+  public init(name: String, description: String, parameters: [String: Schema]?,
+              requiredParameters: [String]? = nil) {
+    self.name = name
+    self.description = description
+    self.parameters = Schema(
+      type: .object,
+      properties: parameters,
+      requiredProperties: requiredParameters
+    )
+  }
+}
+
+/// Helper tools that the model may use to generate response.
+///
+/// A `Tool` is a piece of code that enables the system to interact with external systems to
+/// perform an action, or set of actions, outside of knowledge and scope of the model.
+public struct Tool {
+  /// A list of `FunctionDeclarations` available to the model.
+  let functionDeclarations: [FunctionDeclaration]?
+
+  /// Constructs a new `Tool`.
+  ///
+  /// - Parameters:
+  ///   - functionDeclarations: A list of `FunctionDeclarations` available to the model that can be
+  ///   used for function calling.
+  ///   The model or system does not execute the function. Instead the defined function may be
+  ///   returned as a ``FunctionCall`` in ``ModelContent/Part/functionCall(_:)`` with arguments to
+  ///   the client side for execution. The model may decide to call a subset of these functions by
+  ///   populating ``FunctionCall`` in the response. The next conversation turn may contain a
+  ///   ``FunctionResponse`` in ``ModelContent/Part/functionResponse(_:)`` with the
+  ///   ``ModelContent/role`` "function", providing generation context for the next model turn.
+  public init(functionDeclarations: [FunctionDeclaration]?) {
+    self.functionDeclarations = functionDeclarations
+  }
+}
+
+/// Configuration for specifying function calling behavior.
+public struct FunctionCallingConfig {
+  /// Defines the execution behavior for function calling by defining the
+  /// execution mode.
+  public enum Mode: String {
+    /// The default behavior for function calling. The model calls functions to answer queries at
+    /// its discretion.
+    case auto = "AUTO"
+
+    /// The model always predicts a provided function call to answer every query.
+    case any = "ANY"
+
+    /// The model will never predict a function call to answer a query. This can also be achieved by
+    /// not passing any tools to the model.
+    case none = "NONE"
+  }
+
+  /// Specifies the mode in which function calling should execute. If
+  /// unspecified, the default value will be set to AUTO.
+  let mode: Mode?
+
+  /// A set of function names that, when provided, limits the functions the model
+  /// will call.
+  ///
+  /// This should only be set when the Mode is ANY. Function names
+  /// should match [FunctionDeclaration.name]. With mode set to ANY, model will
+  /// predict a function call from the set of function names provided.
+  let allowedFunctionNames: [String]?
+
+  public init(mode: FunctionCallingConfig.Mode? = nil, allowedFunctionNames: [String]? = nil) {
+    self.mode = mode
+    self.allowedFunctionNames = allowedFunctionNames
+  }
+}
+
+/// Tool configuration for any `Tool` specified in the request.
+public struct ToolConfig {
+  let functionCallingConfig: FunctionCallingConfig?
+
+  public init(functionCallingConfig: FunctionCallingConfig? = nil) {
+    self.functionCallingConfig = functionCallingConfig
+  }
+}
+
+/// Result output from a ``FunctionCall``.
+///
+/// Contains a string representing the `FunctionDeclaration.name` and a structured JSON object
+/// containing any output from the function is used as context to the model. This should contain the
+/// result of a ``FunctionCall`` made based on model prediction.
+public struct FunctionResponse: Equatable {
+  /// The name of the function that was called.
+  let name: String
+
+  /// The function's response.
+  let response: JSONObject
+
+  /// Constructs a new `FunctionResponse`.
+  ///
+  /// - Parameters:
+  ///   - name: The name of the function that was called.
+  ///   - response: The function's response.
+  public init(name: String, response: JSONObject) {
+    self.name = name
+    self.response = response
+  }
+}
+
+// MARK: - Codable Conformance
+
+extension FunctionCall: Decodable {
+  enum CodingKeys: CodingKey {
+    case name
+    case args
+  }
+
+  public init(from decoder: Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+    name = try container.decode(String.self, forKey: .name)
+    if let args = try container.decodeIfPresent(JSONObject.self, forKey: .args) {
+      self.args = args
+    } else {
+      args = JSONObject()
+    }
+  }
+}
+
+extension FunctionCall: Encodable {}
+
+extension FunctionDeclaration: Encodable {
+  enum CodingKeys: String, CodingKey {
+    case name
+    case description
+    case parameters
+  }
+
+  public func encode(to encoder: Encoder) throws {
+    var container = encoder.container(keyedBy: CodingKeys.self)
+    try container.encode(name, forKey: .name)
+    try container.encode(description, forKey: .description)
+    try container.encode(parameters, forKey: .parameters)
+  }
+}
+
+extension Schema: Encodable {
+  enum CodingKeys: String, CodingKey {
+    case type
+    case format
+    case description
+    case nullable
+    case enumValues = "enum"
+    case items
+    case properties
+    case requiredProperties = "required"
+  }
+}
+
+extension DataType: Encodable {}
+
+extension Tool: Encodable {}
+
+extension FunctionCallingConfig: Encodable {}
+
+extension FunctionCallingConfig.Mode: Encodable {}
+
+extension ToolConfig: Encodable {}
+
+extension FunctionResponse: Encodable {}
diff --git a/FirebaseVertexAI/Sources/GenerateContentError.swift b/FirebaseVertexAI/Sources/GenerateContentError.swift
new file mode 100644
index 00000000000..31501a7744c
--- /dev/null
+++ b/FirebaseVertexAI/Sources/GenerateContentError.swift
@@ -0,0 +1,31 @@
+// Copyright 2023 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 Foundation
+
+/// Errors that occur when generating content from a model.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public enum GenerateContentError: Error {
+  /// An error occurred when constructing the prompt. Examine the related error for details.
+  case promptImageContentError(underlying: ImageConversionError)
+
+  /// An internal error occurred. See the underlying error for more context.
+  case internalError(underlying: Error)
+
+  /// A prompt was blocked. See the response's `promptFeedback.blockReason` for more information.
+  case promptBlocked(response: GenerateContentResponse)
+
+  /// A response didn't fully complete. See the `FinishReason` for more information.
+  case responseStoppedEarly(reason: FinishReason, response: GenerateContentResponse)
+}
diff --git a/FirebaseVertexAI/Sources/GenerateContentRequest.swift b/FirebaseVertexAI/Sources/GenerateContentRequest.swift
new file mode 100644
index 00000000000..d26e957be1c
--- /dev/null
+++ b/FirebaseVertexAI/Sources/GenerateContentRequest.swift
@@ -0,0 +1,55 @@
+// Copyright 2023 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 Foundation
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+struct GenerateContentRequest {
+  /// Model name.
+  let model: String
+  let contents: [ModelContent]
+  let generationConfig: GenerationConfig?
+  let safetySettings: [SafetySetting]?
+  let tools: [Tool]?
+  let toolConfig: ToolConfig?
+  let systemInstruction: ModelContent?
+  let isStreaming: Bool
+  let options: RequestOptions
+}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension GenerateContentRequest: Encodable {
+  enum CodingKeys: String, CodingKey {
+    case contents
+    case generationConfig
+    case safetySettings
+    case tools
+    case toolConfig
+    case systemInstruction
+  }
+}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension GenerateContentRequest: GenerativeAIRequest {
+  typealias Response = GenerateContentResponse
+
+  var url: URL {
+    let modelURL = "\(Constants.baseURL)/\(options.apiVersion)/\(model)"
+    if isStreaming {
+      return URL(string: "\(modelURL):streamGenerateContent?alt=sse")!
+    } else {
+      return URL(string: "\(modelURL):generateContent")!
+    }
+  }
+}
diff --git a/FirebaseVertexAI/Sources/GenerateContentResponse.swift b/FirebaseVertexAI/Sources/GenerateContentResponse.swift
new file mode 100644
index 00000000000..a152286581b
--- /dev/null
+++ b/FirebaseVertexAI/Sources/GenerateContentResponse.swift
@@ -0,0 +1,370 @@
+// Copyright 2023 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 Foundation
+
+/// The model's response to a generate content request.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public struct GenerateContentResponse {
+  /// Token usage metadata for processing the generate content request.
+  public struct UsageMetadata {
+    /// The number of tokens in the request prompt.
+    public let promptTokenCount: Int
+
+    /// The total number of tokens across the generated response candidates.
+    public let candidatesTokenCount: Int
+
+    /// The total number of tokens in both the request and response.
+    public let totalTokenCount: Int
+  }
+
+  /// A list of candidate response content, ordered from best to worst.
+  public let candidates: [CandidateResponse]
+
+  /// A value containing the safety ratings for the response, or, if the request was blocked, a
+  /// reason for blocking the request.
+  public let promptFeedback: PromptFeedback?
+
+  /// Token usage metadata for processing the generate content request.
+  public let usageMetadata: UsageMetadata?
+
+  /// The response's content as text, if it exists.
+  public var text: String? {
+    guard let candidate = candidates.first else {
+      Logging.default
+        .error("[FirebaseVertexAI] Could not get text from a response that had no candidates.")
+      return nil
+    }
+    let textValues: [String] = candidate.content.parts.compactMap { part in
+      guard case let .text(text) = part else {
+        return nil
+      }
+      return text
+    }
+    guard textValues.count > 0 else {
+      Logging.default
+        .error("[FirebaseVertexAI] Could not get a text part from the first candidate.")
+      return nil
+    }
+    return textValues.joined(separator: " ")
+  }
+
+  /// Returns function calls found in any `Part`s of the first candidate of the response, if any.
+  public var functionCalls: [FunctionCall] {
+    guard let candidate = candidates.first else {
+      return []
+    }
+    return candidate.content.parts.compactMap { part in
+      guard case let .functionCall(functionCall) = part else {
+        return nil
+      }
+      return functionCall
+    }
+  }
+
+  /// Initializer for SwiftUI previews or tests.
+  public init(candidates: [CandidateResponse], promptFeedback: PromptFeedback? = nil,
+              usageMetadata: UsageMetadata? = nil) {
+    self.candidates = candidates
+    self.promptFeedback = promptFeedback
+    self.usageMetadata = usageMetadata
+  }
+}
+
+/// A struct representing a possible reply to a content generation prompt. Each content generation
+/// prompt may produce multiple candidate responses.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public struct CandidateResponse {
+  /// The response's content.
+  public let content: ModelContent
+
+  /// The safety rating of the response content.
+  public let safetyRatings: [SafetyRating]
+
+  /// The reason the model stopped generating content, if it exists; for example, if the model
+  /// generated a predefined stop sequence.
+  public let finishReason: FinishReason?
+
+  /// Cited works in the model's response content, if it exists.
+  public let citationMetadata: CitationMetadata?
+
+  /// Initializer for SwiftUI previews or tests.
+  public init(content: ModelContent, safetyRatings: [SafetyRating], finishReason: FinishReason?,
+              citationMetadata: CitationMetadata?) {
+    self.content = content
+    self.safetyRatings = safetyRatings
+    self.finishReason = finishReason
+    self.citationMetadata = citationMetadata
+  }
+}
+
+/// A collection of source attributions for a piece of content.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public struct CitationMetadata {
+  /// A list of individual cited sources and the parts of the content to which they apply.
+  public let citationSources: [Citation]
+}
+
+/// A struct describing a source attribution.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public struct Citation {
+  /// The inclusive beginning of a sequence in a model response that derives from a cited source.
+  public let startIndex: Int
+
+  /// The exclusive end of a sequence in a model response that derives from a cited source.
+  public let endIndex: Int
+
+  /// A link to the cited source.
+  public let uri: String
+
+  /// The license the cited source work is distributed under, if specified.
+  public let license: String?
+}
+
+/// A value enumerating possible reasons for a model to terminate a content generation request.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public enum FinishReason: String {
+  case unknown = "FINISH_REASON_UNKNOWN"
+
+  case unspecified = "FINISH_REASON_UNSPECIFIED"
+
+  /// Natural stop point of the model or provided stop sequence.
+  case stop = "STOP"
+
+  /// The maximum number of tokens as specified in the request was reached.
+  case maxTokens = "MAX_TOKENS"
+
+  /// The token generation was stopped because the response was flagged for safety reasons.
+  /// NOTE: When streaming, the Candidate.content will be empty if content filters blocked the
+  /// output.
+  case safety = "SAFETY"
+
+  /// The token generation was stopped because the response was flagged for unauthorized citations.
+  case recitation = "RECITATION"
+
+  /// All other reasons that stopped token generation.
+  case other = "OTHER"
+}
+
+/// A metadata struct containing any feedback the model had on the prompt it was provided.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public struct PromptFeedback {
+  /// A type describing possible reasons to block a prompt.
+  public enum BlockReason: String {
+    /// The block reason is unknown.
+    case unknown = "UNKNOWN"
+
+    /// The block reason was not specified in the server response.
+    case unspecified = "BLOCK_REASON_UNSPECIFIED"
+
+    /// The prompt was blocked because it was deemed unsafe.
+    case safety = "SAFETY"
+
+    /// All other block reasons.
+    case other = "OTHER"
+  }
+
+  /// The reason a prompt was blocked, if it was blocked.
+  public let blockReason: BlockReason?
+
+  /// The safety ratings of the prompt.
+  public let safetyRatings: [SafetyRating]
+
+  /// Initializer for SwiftUI previews or tests.
+  public init(blockReason: BlockReason?, safetyRatings: [SafetyRating]) {
+    self.blockReason = blockReason
+    self.safetyRatings = safetyRatings
+  }
+}
+
+// MARK: - Codable Conformances
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension GenerateContentResponse: Decodable {
+  enum CodingKeys: CodingKey {
+    case candidates
+    case promptFeedback
+    case usageMetadata
+  }
+
+  public init(from decoder: Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+
+    guard container.contains(CodingKeys.candidates) || container
+      .contains(CodingKeys.promptFeedback) else {
+      let context = DecodingError.Context(
+        codingPath: [],
+        debugDescription: "Failed to decode GenerateContentResponse;" +
+          " missing keys 'candidates' and 'promptFeedback'."
+      )
+      throw DecodingError.dataCorrupted(context)
+    }
+
+    if let candidates = try container.decodeIfPresent(
+      [CandidateResponse].self,
+      forKey: .candidates
+    ) {
+      self.candidates = candidates
+    } else {
+      candidates = []
+    }
+    promptFeedback = try container.decodeIfPresent(PromptFeedback.self, forKey: .promptFeedback)
+    usageMetadata = try container.decodeIfPresent(UsageMetadata.self, forKey: .usageMetadata)
+  }
+}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension GenerateContentResponse.UsageMetadata: Decodable {
+  enum CodingKeys: CodingKey {
+    case promptTokenCount
+    case candidatesTokenCount
+    case totalTokenCount
+  }
+
+  public init(from decoder: any Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+    promptTokenCount = try container.decodeIfPresent(Int.self, forKey: .promptTokenCount) ?? 0
+    candidatesTokenCount = try container
+      .decodeIfPresent(Int.self, forKey: .candidatesTokenCount) ?? 0
+    totalTokenCount = try container.decodeIfPresent(Int.self, forKey: .totalTokenCount) ?? 0
+  }
+}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension CandidateResponse: Decodable {
+  enum CodingKeys: CodingKey {
+    case content
+    case safetyRatings
+    case finishReason
+    case finishMessage
+    case citationMetadata
+  }
+
+  /// Initializes a response from a decoder. Used for decoding server responses; not for public
+  /// use.
+  public init(from decoder: Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+
+    do {
+      if let content = try container.decodeIfPresent(ModelContent.self, forKey: .content) {
+        self.content = content
+      } else {
+        content = ModelContent(parts: [])
+      }
+    } catch {
+      // Check if `content` can be decoded as an empty dictionary to detect the `"content": {}` bug.
+      if let content = try? container.decode([String: String].self, forKey: .content),
+         content.isEmpty {
+        throw InvalidCandidateError.emptyContent(underlyingError: error)
+      } else {
+        throw InvalidCandidateError.malformedContent(underlyingError: error)
+      }
+    }
+
+    if let safetyRatings = try container.decodeIfPresent(
+      [SafetyRating].self,
+      forKey: .safetyRatings
+    ) {
+      self.safetyRatings = safetyRatings
+    } else {
+      safetyRatings = []
+    }
+
+    finishReason = try container.decodeIfPresent(FinishReason.self, forKey: .finishReason)
+
+    citationMetadata = try container.decodeIfPresent(
+      CitationMetadata.self,
+      forKey: .citationMetadata
+    )
+  }
+}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension CitationMetadata: Decodable {
+  enum CodingKeys: String, CodingKey {
+    case citationSources = "citations"
+  }
+}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension Citation: Decodable {
+  enum CodingKeys: CodingKey {
+    case startIndex
+    case endIndex
+    case uri
+    case license
+  }
+
+  public init(from decoder: any Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+    startIndex = try container.decodeIfPresent(Int.self, forKey: .startIndex) ?? 0
+    endIndex = try container.decode(Int.self, forKey: .endIndex)
+    uri = try container.decode(String.self, forKey: .uri)
+    license = try container.decodeIfPresent(String.self, forKey: .license)
+  }
+}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension FinishReason: Decodable {
+  public init(from decoder: Decoder) throws {
+    let value = try decoder.singleValueContainer().decode(String.self)
+    guard let decodedFinishReason = FinishReason(rawValue: value) else {
+      Logging.default
+        .error("[FirebaseVertexAI] Unrecognized FinishReason with value \"\(value)\".")
+      self = .unknown
+      return
+    }
+
+    self = decodedFinishReason
+  }
+}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension PromptFeedback.BlockReason: Decodable {
+  public init(from decoder: Decoder) throws {
+    let value = try decoder.singleValueContainer().decode(String.self)
+    guard let decodedBlockReason = PromptFeedback.BlockReason(rawValue: value) else {
+      Logging.default
+        .error("[FirebaseVertexAI] Unrecognized BlockReason with value \"\(value)\".")
+      self = .unknown
+      return
+    }
+
+    self = decodedBlockReason
+  }
+}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension PromptFeedback: Decodable {
+  enum CodingKeys: CodingKey {
+    case blockReason
+    case safetyRatings
+  }
+
+  public init(from decoder: Decoder) throws {
+    let container = try decoder.container(keyedBy: CodingKeys.self)
+    blockReason = try container.decodeIfPresent(
+      PromptFeedback.BlockReason.self,
+      forKey: .blockReason
+    )
+    if let safetyRatings = try container.decodeIfPresent(
+      [SafetyRating].self,
+      forKey: .safetyRatings
+    ) {
+      self.safetyRatings = safetyRatings
+    } else {
+      safetyRatings = []
+    }
+  }
+}
diff --git a/FirebaseVertexAI/Sources/GenerationConfig.swift b/FirebaseVertexAI/Sources/GenerationConfig.swift
new file mode 100644
index 00000000000..ee527700d9a
--- /dev/null
+++ b/FirebaseVertexAI/Sources/GenerationConfig.swift
@@ -0,0 +1,99 @@
+// Copyright 2023 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 Foundation
+
+/// A struct defining model parameters to be used when sending generative AI
+/// requests to the backend model.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public struct GenerationConfig {
+  /// A parameter controlling the degree of randomness in token selection. A
+  /// temperature of zero is deterministic, always choosing the
+  /// highest-probability response. Typical values are between 0 and 1
+  /// inclusive. Defaults to 0 if unspecified.
+  public let temperature: Float?
+
+  /// The `topP` parameter changes how the model selects tokens for output.
+  /// Tokens are selected from the most to least probable until the sum of
+  /// their probabilities equals the `topP` value. For example, if tokens A, B,
+  /// and C have probabilities of 0.3, 0.2, and 0.1 respectively and the topP
+  /// value is 0.5, then the model will select either A or B as the next token
+  /// by using the `temperature` and exclude C as a candidate.
+  /// Defaults to 0.95 if unset.
+  public let topP: Float?
+
+  /// The `topK` parameter changes how the model selects tokens for output. A
+  /// `topK` of 1 means the selected token is the most probable among all the
+  /// tokens in the model's vocabulary, while a `topK` of 3 means that the next
+  /// token is selected from among the 3 most probable using the `temperature`.
+  /// For each token selection step, the `topK` tokens with the highest
+  /// probabilities are sampled. Tokens are then further filtered based on
+  /// `topP` with the final token selected using `temperature` sampling.
+  /// Defaults to 40 if unspecified.
+  public let topK: Int?
+
+  /// The maximum number of generated response messages to return. This value
+  /// must be between [1, 8], inclusive. If unset, this will default to 1.
+  ///
+  /// - Note: Only unique candidates are returned. Higher temperatures are more
+  ///     likely to produce unique candidates. Setting `temperature` to 0 will
+  ///     always produce exactly one candidate regardless of the
+  ///     `candidateCount`.
+  public let candidateCount: Int?
+
+  /// Specifies the maximum number of tokens that can be generated in the
+  /// response. The number of tokens per word varies depending on the
+  /// language outputted. The maximum value is capped at 1024. Defaults to 0
+  /// (unbounded).
+  public let maxOutputTokens: Int?
+
+  /// A set of up to 5 `String`s that will stop output generation. If
+  /// specified, the API will stop at the first appearance of a stop sequence.
+  /// The stop sequence will not be included as part of the response.
+  public let stopSequences: [String]?
+
+  /// Output response MIME type of the generated candidate text.
+  ///
+  /// Supported MIME types:
+  /// - `text/plain`: Text output; the default behavior if unspecified.
+  /// - `application/json`: JSON response in the candidates.
+  public let responseMIMEType: String?
+
+  /// Creates a new `GenerationConfig` value.
+  ///
+  /// - Parameter temperature: See ``temperature``
+  /// - Parameter topP: See ``topP``
+  /// - Parameter topK: See ``topK``
+  /// - Parameter candidateCount: See ``candidateCount``
+  /// - Parameter maxOutputTokens: See ``maxOutputTokens``
+  /// - Parameter stopSequences: See ``stopSequences``
+  public init(temperature: Float? = nil, topP: Float? = nil, topK: Int? = nil,
+              candidateCount: Int? = nil, maxOutputTokens: Int? = nil,
+              stopSequences: [String]? = nil, responseMIMEType: String? = nil) {
+    // Explicit init because otherwise if we re-arrange the above variables it changes the API
+    // surface.
+    self.temperature = temperature
+    self.topP = topP
+    self.topK = topK
+    self.candidateCount = candidateCount
+    self.maxOutputTokens = maxOutputTokens
+    self.stopSequences = stopSequences
+    self.responseMIMEType = responseMIMEType
+  }
+}
+
+// MARK: - Codable Conformances
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension GenerationConfig: Encodable {}
diff --git a/FirebaseVertexAI/Sources/GenerativeAIRequest.swift b/FirebaseVertexAI/Sources/GenerativeAIRequest.swift
new file mode 100644
index 00000000000..a3a198726d4
--- /dev/null
+++ b/FirebaseVertexAI/Sources/GenerativeAIRequest.swift
@@ -0,0 +1,44 @@
+// Copyright 2023 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 Foundation
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+protocol GenerativeAIRequest: Encodable {
+  associatedtype Response: Decodable
+
+  var url: URL { get }
+
+  var options: RequestOptions { get }
+}
+
+/// Configuration parameters for sending requests to the backend.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public struct RequestOptions {
+  /// The request’s timeout interval in seconds; if not specified uses the default value for a
+  /// `URLRequest`.
+  let timeout: TimeInterval?
+
+  /// The API version to use in requests to the backend.
+  let apiVersion = "v2beta"
+
+  /// Initializes a request options object.
+  ///
+  /// - Parameters:
+  ///   - timeout The request’s timeout interval in seconds; if not specified uses the default value
+  ///   for a `URLRequest`.
+  public init(timeout: TimeInterval? = nil) {
+    self.timeout = timeout
+  }
+}
diff --git a/FirebaseVertexAI/Sources/GenerativeAIService.swift b/FirebaseVertexAI/Sources/GenerativeAIService.swift
new file mode 100644
index 00000000000..c75331e1844
--- /dev/null
+++ b/FirebaseVertexAI/Sources/GenerativeAIService.swift
@@ -0,0 +1,309 @@
+// Copyright 2023 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 FirebaseAppCheckInterop
+import FirebaseAuthInterop
+import FirebaseCore
+import Foundation
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+struct GenerativeAIService {
+  /// The language of the SDK in the format `gl-<language>/<version>`.
+  static let languageTag = "gl-swift/5"
+
+  /// The Firebase SDK version in the format `fire/<version>`.
+  static let firebaseVersionTag = "fire/\(FirebaseVersion())"
+
+  private let projectID: String
+
+  /// Gives permission to talk to the backend.
+  private let apiKey: String
+
+  private let appCheck: AppCheckInterop?
+
+  private let auth: AuthInterop?
+
+  private let urlSession: URLSession
+
+  init(projectID: String, apiKey: String, appCheck: AppCheckInterop?, auth: AuthInterop?,
+       urlSession: URLSession) {
+    self.projectID = projectID
+    self.apiKey = apiKey
+    self.appCheck = appCheck
+    self.auth = auth
+    self.urlSession = urlSession
+  }
+
+  func loadRequest<T: GenerativeAIRequest>(request: T) async throws -> T.Response {
+    let urlRequest = try await urlRequest(request: request)
+
+    #if DEBUG
+      printCURLCommand(from: urlRequest)
+    #endif
+
+    let data: Data
+    let rawResponse: URLResponse
+    (data, rawResponse) = try await urlSession.data(for: urlRequest)
+
+    let response = try httpResponse(urlResponse: rawResponse)
+
+    // Verify the status code is 200
+    guard response.statusCode == 200 else {
+      Logging.network.error("[FirebaseVertexAI] The server responded with an error: \(response)")
+      if let responseString = String(data: data, encoding: .utf8) {
+        Logging.default.error("[FirebaseVertexAI] Response payload: \(responseString)")
+      }
+
+      throw parseError(responseData: data)
+    }
+
+    return try parseResponse(T.Response.self, from: data)
+  }
+
+  @available(macOS 12.0, *)
+  func loadRequestStream<T: GenerativeAIRequest>(request: T)
+    -> AsyncThrowingStream<T.Response, Error> {
+    return AsyncThrowingStream { continuation in
+      Task {
+        let urlRequest: URLRequest
+        do {
+          urlRequest = try await self.urlRequest(request: request)
+        } catch {
+          continuation.finish(throwing: error)
+          return
+        }
+
+        #if DEBUG
+          printCURLCommand(from: urlRequest)
+        #endif
+
+        let stream: URLSession.AsyncBytes
+        let rawResponse: URLResponse
+        do {
+          (stream, rawResponse) = try await urlSession.bytes(for: urlRequest)
+        } catch {
+          continuation.finish(throwing: error)
+          return
+        }
+
+        // Verify the status code is 200
+        let response: HTTPURLResponse
+        do {
+          response = try httpResponse(urlResponse: rawResponse)
+        } catch {
+          continuation.finish(throwing: error)
+          return
+        }
+
+        // Verify the status code is 200
+        guard response.statusCode == 200 else {
+          Logging.network
+            .error("[FirebaseVertexAI] The server responded with an error: \(response)")
+          var responseBody = ""
+          for try await line in stream.lines {
+            responseBody += line + "\n"
+          }
+
+          Logging.default.error("[FirebaseVertexAI] Response payload: \(responseBody)")
+          continuation.finish(throwing: parseError(responseBody: responseBody))
+
+          return
+        }
+
+        // Received lines that are not server-sent events (SSE); these are not prefixed with "data:"
+        var extraLines: String = ""
+
+        let decoder = JSONDecoder()
+        decoder.keyDecodingStrategy = .convertFromSnakeCase
+        for try await line in stream.lines {
+          Logging.network.debug("[FirebaseVertexAI] Stream response: \(line)")
+
+          if line.hasPrefix("data:") {
+            // We can assume 5 characters since it's utf-8 encoded, removing `data:`.
+            let jsonText = String(line.dropFirst(5))
+            let data: Data
+            do {
+              data = try jsonData(jsonText: jsonText)
+            } catch {
+              continuation.finish(throwing: error)
+              return
+            }
+
+            // Handle the content.
+            do {
+              let content = try parseResponse(T.Response.self, from: data)
+              continuation.yield(content)
+            } catch {
+              continuation.finish(throwing: error)
+              return
+            }
+          } else {
+            extraLines += line
+          }
+        }
+
+        if extraLines.count > 0 {
+          continuation.finish(throwing: parseError(responseBody: extraLines))
+          return
+        }
+
+        continuation.finish(throwing: nil)
+      }
+    }
+  }
+
+  // MARK: - Private Helpers
+
+  private func urlRequest<T: GenerativeAIRequest>(request: T) async throws -> URLRequest {
+    var urlRequest = URLRequest(url: request.url)
+    urlRequest.httpMethod = "POST"
+    urlRequest.setValue(apiKey, forHTTPHeaderField: "x-goog-api-key")
+    urlRequest.setValue(
+      "\(GenerativeAIService.languageTag) \(GenerativeAIService.firebaseVersionTag)",
+      forHTTPHeaderField: "x-goog-api-client"
+    )
+    urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+    if let appCheck {
+      let tokenResult = await appCheck.getToken(forcingRefresh: false)
+      urlRequest.setValue(tokenResult.token, forHTTPHeaderField: "X-Firebase-AppCheck")
+      if let error = tokenResult.error {
+        Logging.default
+          .debug("[FirebaseVertexAI] Failed to fetch AppCheck token. Error: \(error)")
+      }
+    }
+
+    if let auth, let authToken = try await auth.getToken(forcingRefresh: false) {
+      urlRequest.setValue("Firebase \(authToken)", forHTTPHeaderField: "Authorization")
+    }
+
+    let encoder = JSONEncoder()
+    encoder.keyEncodingStrategy = .convertToSnakeCase
+    urlRequest.httpBody = try encoder.encode(request)
+
+    if let timeoutInterval = request.options.timeout {
+      urlRequest.timeoutInterval = timeoutInterval
+    }
+
+    return urlRequest
+  }
+
+  private func httpResponse(urlResponse: URLResponse) throws -> HTTPURLResponse {
+    // Verify the status code is 200
+    guard let response = urlResponse as? HTTPURLResponse else {
+      Logging.default
+        .error(
+          "[FirebaseVertexAI] Response wasn't an HTTP response, internal error \(urlResponse)"
+        )
+      throw NSError(
+        domain: "com.google.generative-ai",
+        code: -1,
+        userInfo: [NSLocalizedDescriptionKey: "Response was not an HTTP response."]
+      )
+    }
+
+    return response
+  }
+
+  private func jsonData(jsonText: String) throws -> Data {
+    guard let data = jsonText.data(using: .utf8) else {
+      let error = NSError(
+        domain: "com.google.generative-ai",
+        code: -1,
+        userInfo: [NSLocalizedDescriptionKey: "Could not parse response as UTF8."]
+      )
+      throw error
+    }
+
+    return data
+  }
+
+  private func parseError(responseBody: String) -> Error {
+    do {
+      let data = try jsonData(jsonText: responseBody)
+      return parseError(responseData: data)
+    } catch {
+      return error
+    }
+  }
+
+  private func parseError(responseData: Data) -> Error {
+    do {
+      let rpcError = try JSONDecoder().decode(RPCError.self, from: responseData)
+      logRPCError(rpcError)
+      return rpcError
+    } catch {
+      // TODO: Return an error about an unrecognized error payload with the response body
+      return error
+    }
+  }
+
+  // Log specific RPC errors that cannot be mitigated or handled by user code.
+  // These errors do not produce specific GenerateContentError or CountTokensError cases.
+  private func logRPCError(_ error: RPCError) {
+    if error.isFirebaseMLServiceDisabledError() {
+      Logging.default.error("""
+      The Vertex AI for Firebase SDK requires the Firebase ML API `firebaseml.googleapis.com` to \
+      be enabled for your project. Get started in the Firebase Console \
+      (https://console.firebase.google.com/project/\(projectID)/genai/vertex) or verify that the \
+      API is enabled in the Google Cloud Console \
+      (https://console.developers.google.com/apis/api/firebaseml.googleapis.com/overview?project=\
+      \(projectID)).
+      """)
+    }
+  }
+
+  private func parseResponse<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
+    do {
+      return try JSONDecoder().decode(type, from: data)
+    } catch {
+      if let json = String(data: data, encoding: .utf8) {
+        Logging.network.error("[FirebaseVertexAI] JSON response: \(json)")
+      }
+      Logging.default.error("[FirebaseVertexAI] Error decoding server JSON: \(error)")
+      throw error
+    }
+  }
+
+  #if DEBUG
+    private func cURLCommand(from request: URLRequest) -> String {
+      var returnValue = "curl "
+      if let allHeaders = request.allHTTPHeaderFields {
+        for (key, value) in allHeaders {
+          returnValue += "-H '\(key): \(value)' "
+        }
+      }
+
+      guard let url = request.url else { return "" }
+      returnValue += "'\(url.absoluteString)' "
+
+      guard let body = request.httpBody,
+            let jsonStr = String(bytes: body, encoding: .utf8) else { return "" }
+      let escapedJSON = jsonStr.replacingOccurrences(of: "'", with: "'\\''")
+      returnValue += "-d '\(escapedJSON)'"
+
+      return returnValue
+    }
+
+    private func printCURLCommand(from request: URLRequest) {
+      let command = cURLCommand(from: request)
+      Logging.verbose.debug("""
+      [FirebaseVertexAI] Creating request with the equivalent cURL command:
+      ----- cURL command -----
+      \(command, privacy: .private)
+      ------------------------
+      """)
+    }
+  #endif // DEBUG
+}
diff --git a/FirebaseVertexAI/Sources/GenerativeModel.swift b/FirebaseVertexAI/Sources/GenerativeModel.swift
new file mode 100644
index 00000000000..6cce3f02a93
--- /dev/null
+++ b/FirebaseVertexAI/Sources/GenerativeModel.swift
@@ -0,0 +1,324 @@
+// Copyright 2023 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 FirebaseAppCheckInterop
+import FirebaseAuthInterop
+import Foundation
+
+/// A type that represents a remote multimodal model (like Gemini), with the ability to generate
+/// content based on various input types.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public final class GenerativeModel {
+  // The prefix for a model resource in the Gemini API.
+  private static let modelResourcePrefix = "models/"
+
+  /// The resource name of the model in the backend; has the format "models/model-name".
+  let modelResourceName: String
+
+  /// The backing service responsible for sending and receiving model requests to the backend.
+  let generativeAIService: GenerativeAIService
+
+  /// Configuration parameters used for the MultiModalModel.
+  let generationConfig: GenerationConfig?
+
+  /// The safety settings to be used for prompts.
+  let safetySettings: [SafetySetting]?
+
+  /// A list of tools the model may use to generate the next response.
+  let tools: [Tool]?
+
+  /// Tool configuration for any `Tool` specified in the request.
+  let toolConfig: ToolConfig?
+
+  /// Instructions that direct the model to behave a certain way.
+  let systemInstruction: ModelContent?
+
+  /// Configuration parameters for sending requests to the backend.
+  let requestOptions: RequestOptions
+
+  /// Initializes a new remote model with the given parameters.
+  ///
+  /// - Parameters:
+  ///   - name: The name of the model to use, for example `"gemini-1.0-pro"`.
+  ///   - projectID: The project ID from the Firebase console.
+  ///   - apiKey: The API key for your project.
+  ///   - generationConfig: The content generation parameters your model should use.
+  ///   - safetySettings: A value describing what types of harmful content your model should allow.
+  ///   - tools: A list of ``Tool`` objects that the model may use to generate the next response.
+  ///   - toolConfig: Tool configuration for any `Tool` specified in the request.
+  ///   - systemInstruction: Instructions that direct the model to behave a certain way; currently
+  ///     only text content is supported.
+  ///   - requestOptions: Configuration parameters for sending requests to the backend.
+  ///   - urlSession: The `URLSession` to use for requests; defaults to `URLSession.shared`.
+  init(name: String,
+       projectID: String,
+       apiKey: String,
+       generationConfig: GenerationConfig? = nil,
+       safetySettings: [SafetySetting]? = nil,
+       tools: [Tool]?,
+       toolConfig: ToolConfig? = nil,
+       systemInstruction: ModelContent? = nil,
+       requestOptions: RequestOptions,
+       appCheck: AppCheckInterop?,
+       auth: AuthInterop?,
+       urlSession: URLSession = .shared) {
+    modelResourceName = GenerativeModel.modelResourceName(name: name)
+    generativeAIService = GenerativeAIService(
+      projectID: projectID,
+      apiKey: apiKey,
+      appCheck: appCheck,
+      auth: auth,
+      urlSession: urlSession
+    )
+    self.generationConfig = generationConfig
+    self.safetySettings = safetySettings
+    self.tools = tools
+    self.toolConfig = toolConfig
+    self.systemInstruction = systemInstruction
+    self.requestOptions = requestOptions
+
+    if Logging.additionalLoggingEnabled() {
+      if ProcessInfo.processInfo.arguments.contains(Logging.migrationEnableArgumentKey) {
+        Logging.verbose.debug("""
+        [FirebaseVertexAI] Verbose logging enabled with the \
+        \(Logging.migrationEnableArgumentKey, privacy: .public) launch argument; please migrate to \
+        the \(Logging.enableArgumentKey, privacy: .public) argument to ensure future compatibility.
+        """)
+      } else {
+        Logging.verbose.debug("[FirebaseVertexAI] Verbose logging enabled.")
+      }
+    } else {
+      Logging.default.info("""
+      [FirebaseVertexAI] To enable additional logging, add \
+      `\(Logging.enableArgumentKey, privacy: .public)` as a launch argument in Xcode.
+      """)
+    }
+    Logging.default.debug("[FirebaseVertexAI] Model \(name, privacy: .public) initialized.")
+  }
+
+  /// Generates content from String and/or image inputs, given to the model as a prompt, that are
+  /// representable as one or more ``ModelContent/Part``s.
+  ///
+  /// Since ``ModelContent/Part``s do not specify a role, this method is intended for generating
+  /// content from
+  /// [zero-shot](https://developers.google.com/machine-learning/glossary/generative#zero-shot-prompting)
+  /// or "direct" prompts. For
+  /// [few-shot](https://developers.google.com/machine-learning/glossary/generative#few-shot-prompting)
+  /// prompts, see `generateContent(_ content: @autoclosure () throws -> [ModelContent])`.
+  ///
+  /// - Parameter content: The input(s) given to the model as a prompt (see
+  /// ``ThrowingPartsRepresentable``
+  /// for conforming types).
+  /// - Returns: The content generated by the model.
+  /// - Throws: A ``GenerateContentError`` if the request failed.
+  public func generateContent(_ parts: any ThrowingPartsRepresentable...)
+    async throws -> GenerateContentResponse {
+    return try await generateContent([ModelContent(parts: parts)])
+  }
+
+  /// Generates new content from input content given to the model as a prompt.
+  ///
+  /// - Parameter content: The input(s) given to the model as a prompt.
+  /// - Returns: The generated content response from the model.
+  /// - Throws: A ``GenerateContentError`` if the request failed.
+  public func generateContent(_ content: @autoclosure () throws -> [ModelContent]) async throws
+    -> GenerateContentResponse {
+    let response: GenerateContentResponse
+    do {
+      let generateContentRequest = try GenerateContentRequest(model: modelResourceName,
+                                                              contents: content(),
+                                                              generationConfig: generationConfig,
+                                                              safetySettings: safetySettings,
+                                                              tools: tools,
+                                                              toolConfig: toolConfig,
+                                                              systemInstruction: systemInstruction,
+                                                              isStreaming: false,
+                                                              options: requestOptions)
+      response = try await generativeAIService.loadRequest(request: generateContentRequest)
+    } catch {
+      if let imageError = error as? ImageConversionError {
+        throw GenerateContentError.promptImageContentError(underlying: imageError)
+      }
+      throw GenerativeModel.generateContentError(from: error)
+    }
+
+    // Check the prompt feedback to see if the prompt was blocked.
+    if response.promptFeedback?.blockReason != nil {
+      throw GenerateContentError.promptBlocked(response: response)
+    }
+
+    // Check to see if an error should be thrown for stop reason.
+    if let reason = response.candidates.first?.finishReason, reason != .stop {
+      throw GenerateContentError.responseStoppedEarly(reason: reason, response: response)
+    }
+
+    return response
+  }
+
+  /// Generates content from String and/or image inputs, given to the model as a prompt, that are
+  /// representable as one or more ``ModelContent/Part``s.
+  ///
+  /// Since ``ModelContent/Part``s do not specify a role, this method is intended for generating
+  /// content from
+  /// [zero-shot](https://developers.google.com/machine-learning/glossary/generative#zero-shot-prompting)
+  /// or "direct" prompts. For
+  /// [few-shot](https://developers.google.com/machine-learning/glossary/generative#few-shot-prompting)
+  /// prompts, see `generateContentStream(_ content: @autoclosure () throws -> [ModelContent])`.
+  ///
+  /// - Parameter content: The input(s) given to the model as a prompt (see
+  /// ``ThrowingPartsRepresentable``
+  /// for conforming types).
+  /// - Returns: A stream wrapping content generated by the model or a ``GenerateContentError``
+  ///     error if an error occurred.
+  @available(macOS 12.0, *)
+  public func generateContentStream(_ parts: any ThrowingPartsRepresentable...)
+    -> AsyncThrowingStream<GenerateContentResponse, Error> {
+    return try generateContentStream([ModelContent(parts: parts)])
+  }
+
+  /// Generates new content from input content given to the model as a prompt.
+  ///
+  /// - Parameter content: The input(s) given to the model as a prompt.
+  /// - Returns: A stream wrapping content generated by the model or a ``GenerateContentError``
+  ///     error if an error occurred.
+  @available(macOS 12.0, *)
+  public func generateContentStream(_ content: @autoclosure () throws -> [ModelContent])
+    -> AsyncThrowingStream<GenerateContentResponse, Error> {
+    let evaluatedContent: [ModelContent]
+    do {
+      evaluatedContent = try content()
+    } catch let underlying {
+      return AsyncThrowingStream { continuation in
+        let error: Error
+        if let contentError = underlying as? ImageConversionError {
+          error = GenerateContentError.promptImageContentError(underlying: contentError)
+        } else {
+          error = GenerateContentError.internalError(underlying: underlying)
+        }
+        continuation.finish(throwing: error)
+      }
+    }
+
+    let generateContentRequest = GenerateContentRequest(model: modelResourceName,
+                                                        contents: evaluatedContent,
+                                                        generationConfig: generationConfig,
+                                                        safetySettings: safetySettings,
+                                                        tools: tools,
+                                                        toolConfig: toolConfig,
+                                                        systemInstruction: systemInstruction,
+                                                        isStreaming: true,
+                                                        options: requestOptions)
+
+    var responseIterator = generativeAIService.loadRequestStream(request: generateContentRequest)
+      .makeAsyncIterator()
+    return AsyncThrowingStream {
+      let response: GenerateContentResponse?
+      do {
+        response = try await responseIterator.next()
+      } catch {
+        throw GenerativeModel.generateContentError(from: error)
+      }
+
+      // The responseIterator will return `nil` when it's done.
+      guard let response = response else {
+        // This is the end of the stream! Signal it by sending `nil`.
+        return nil
+      }
+
+      // Check the prompt feedback to see if the prompt was blocked.
+      if response.promptFeedback?.blockReason != nil {
+        throw GenerateContentError.promptBlocked(response: response)
+      }
+
+      // If the stream ended early unexpectedly, throw an error.
+      if let finishReason = response.candidates.first?.finishReason, finishReason != .stop {
+        throw GenerateContentError.responseStoppedEarly(reason: finishReason, response: response)
+      } else {
+        // Response was valid content, pass it along and continue.
+        return response
+      }
+    }
+  }
+
+  /// Creates a new chat conversation using this model with the provided history.
+  public func startChat(history: [ModelContent] = []) -> Chat {
+    return Chat(model: self, history: history)
+  }
+
+  /// Runs the model's tokenizer on String and/or image inputs that are representable as one or more
+  /// ``ModelContent/Part``s.
+  ///
+  /// Since ``ModelContent/Part``s do not specify a role, this method is intended for tokenizing
+  /// [zero-shot](https://developers.google.com/machine-learning/glossary/generative#zero-shot-prompting)
+  /// or "direct" prompts. For
+  /// [few-shot](https://developers.google.com/machine-learning/glossary/generative#few-shot-prompting)
+  /// input, see `countTokens(_ content: @autoclosure () throws -> [ModelContent])`.
+  ///
+  /// - Parameter content: The input(s) given to the model as a prompt (see
+  /// ``ThrowingPartsRepresentable``
+  /// for conforming types).
+  /// - Returns: The results of running the model's tokenizer on the input; contains
+  /// ``CountTokensResponse/totalTokens``.
+  /// - Throws: A ``CountTokensError`` if the tokenization request failed.
+  public func countTokens(_ parts: any ThrowingPartsRepresentable...) async throws
+    -> CountTokensResponse {
+    return try await countTokens([ModelContent(parts: parts)])
+  }
+
+  /// Runs the model's tokenizer on the input content and returns the token count.
+  ///
+  /// - Parameter content: The input given to the model as a prompt.
+  /// - Returns: The results of running the model's tokenizer on the input; contains
+  /// ``CountTokensResponse/totalTokens``.
+  /// - Throws: A ``CountTokensError`` if the tokenization request failed or the input content was
+  /// invalid.
+  public func countTokens(_ content: @autoclosure () throws -> [ModelContent]) async throws
+    -> CountTokensResponse {
+    do {
+      let countTokensRequest = try CountTokensRequest(
+        model: modelResourceName,
+        contents: content(),
+        options: requestOptions
+      )
+      return try await generativeAIService.loadRequest(request: countTokensRequest)
+    } catch {
+      throw CountTokensError.internalError(underlying: error)
+    }
+  }
+
+  /// Returns a model resource name of the form "models/model-name" based on `name`.
+  private static func modelResourceName(name: String) -> String {
+    if name.contains("/") {
+      return name
+    } else {
+      return modelResourcePrefix + name
+    }
+  }
+
+  /// Returns a `GenerateContentError` (for public consumption) from an internal error.
+  ///
+  /// If `error` is already a `GenerateContentError` the error is returned unchanged.
+  private static func generateContentError(from error: Error) -> GenerateContentError {
+    if let error = error as? GenerateContentError {
+      return error
+    }
+    return GenerateContentError.internalError(underlying: error)
+  }
+}
+
+/// An error thrown in `GenerativeModel.countTokens(_:)`.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public enum CountTokensError: Error {
+  case internalError(underlying: Error)
+}
diff --git a/FirebaseVertexAI/Sources/JSONValue.swift b/FirebaseVertexAI/Sources/JSONValue.swift
new file mode 100644
index 00000000000..5ce52cd2af9
--- /dev/null
+++ b/FirebaseVertexAI/Sources/JSONValue.swift
@@ -0,0 +1,96 @@
+// Copyright 2024 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 Foundation
+
+/// A collection of name-value pairs representing a JSON object.
+///
+/// This may be decoded from, or encoded to, a
+/// [`google.protobuf.Struct`](https://protobuf.dev/reference/protobuf/google.protobuf/#struct).
+public typealias JSONObject = [String: JSONValue]
+
+/// Represents a value in one of JSON's data types.
+///
+/// This may be decoded from, or encoded to, a
+/// [`google.protobuf.Value`](https://protobuf.dev/reference/protobuf/google.protobuf/#value).
+public enum JSONValue {
+  /// A `null` value.
+  case null
+
+  /// A numeric value.
+  case number(Double)
+
+  /// A string value.
+  case string(String)
+
+  /// A boolean value.
+  case bool(Bool)
+
+  /// A JSON object.
+  case object(JSONObject)
+
+  /// An array of `JSONValue`s.
+  case array([JSONValue])
+}
+
+extension JSONValue: Decodable {
+  public init(from decoder: Decoder) throws {
+    let container = try decoder.singleValueContainer()
+    if container.decodeNil() {
+      self = .null
+    } else if let numberValue = try? container.decode(Double.self) {
+      self = .number(numberValue)
+    } else if let stringValue = try? container.decode(String.self) {
+      self = .string(stringValue)
+    } else if let boolValue = try? container.decode(Bool.self) {
+      self = .bool(boolValue)
+    } else if let objectValue = try? container.decode(JSONObject.self) {
+      self = .object(objectValue)
+    } else if let arrayValue = try? container.decode([JSONValue].self) {
+      self = .array(arrayValue)
+    } else {
+      throw DecodingError.dataCorruptedError(
+        in: container,
+        debugDescription: "Failed to decode JSON value."
+      )
+    }
+  }
+}
+
+extension JSONValue: Encodable {
+  public func encode(to encoder: Encoder) throws {
+    var container = encoder.singleValueContainer()
+    switch self {
+    case .null:
+      try container.encodeNil()
+    case let .number(numberValue):
+      // Convert to `Decimal` before encoding for consistent floating-point serialization across
+      // platforms. E.g., `Double` serializes 3.14159 as 3.1415899999999999 in some cases and
+      // 3.14159 in others. See
+      // https://forums.swift.org/t/jsonencoder-encodable-floating-point-rounding-error/41390/4 for
+      // more details.
+      try container.encode(Decimal(numberValue))
+    case let .string(stringValue):
+      try container.encode(stringValue)
+    case let .bool(boolValue):
+      try container.encode(boolValue)
+    case let .object(objectValue):
+      try container.encode(objectValue)
+    case let .array(arrayValue):
+      try container.encode(arrayValue)
+    }
+  }
+}
+
+extension JSONValue: Equatable {}
diff --git a/FirebaseVertexAI/Sources/Logging.swift b/FirebaseVertexAI/Sources/Logging.swift
new file mode 100644
index 00000000000..81587ba20c8
--- /dev/null
+++ b/FirebaseVertexAI/Sources/Logging.swift
@@ -0,0 +1,71 @@
+// Copyright 2023 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 Foundation
+import OSLog
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+struct Logging {
+  /// Subsystem that should be used for all Loggers.
+  static let subsystem = "com.google.firebase.vertex-ai"
+
+  /// Default category used for most loggers, unless specialized.
+  static let defaultCategory = ""
+
+  /// The argument required to enable additional logging.
+  static let enableArgumentKey = "-FIRDebugEnabled"
+
+  /// The argument required to enable additional logging in the Google AI SDK; used for migration.
+  ///
+  /// To facillitate migration between the SDKs, this launch argument is also accepted to enable
+  /// additional logging at this time, though it is expected to be removed in the future.
+  static let migrationEnableArgumentKey = "-GoogleGenerativeAIDebugLogEnabled"
+
+  // No initializer available.
+  @available(*, unavailable)
+  private init() {}
+
+  /// The default logger that is visible for all users. Note: we shouldn't be using anything lower
+  /// than `.notice`.
+  static var `default` = Logger(subsystem: subsystem, category: defaultCategory)
+
+  /// A non default
+  static var network: Logger = {
+    if additionalLoggingEnabled() {
+      return Logger(subsystem: subsystem, category: "NetworkResponse")
+    } else {
+      // Return a valid logger that's using `OSLog.disabled` as the logger, hiding everything.
+      return Logger(.disabled)
+    }
+  }()
+
+  ///
+  static var verbose: Logger = {
+    if additionalLoggingEnabled() {
+      return Logger(subsystem: subsystem, category: defaultCategory)
+    } else {
+      // Return a valid logger that's using `OSLog.disabled` as the logger, hiding everything.
+      return Logger(.disabled)
+    }
+  }()
+
+  /// Returns `true` if additional logging has been enabled via a launch argument.
+  static func additionalLoggingEnabled() -> Bool {
+    let arguments = ProcessInfo.processInfo.arguments
+    if arguments.contains(enableArgumentKey) || arguments.contains(migrationEnableArgumentKey) {
+      return true
+    }
+    return false
+  }
+}
diff --git a/FirebaseVertexAI/Sources/ModelContent.swift b/FirebaseVertexAI/Sources/ModelContent.swift
new file mode 100644
index 00000000000..78449c23028
--- /dev/null
+++ b/FirebaseVertexAI/Sources/ModelContent.swift
@@ -0,0 +1,189 @@
+// Copyright 2023 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 Foundation
+
+/// A type describing data in media formats interpretable by an AI model. Each generative AI
+/// request or response contains an `Array` of ``ModelContent``s, and each ``ModelContent`` value
+/// may comprise multiple heterogeneous ``ModelContent/Part``s.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public struct ModelContent: Equatable {
+  /// A discrete piece of data in a media format interpretable by an AI model. Within a single value
+  /// of ``Part``, different data types may not mix.
+  public enum Part: Equatable {
+    /// Text value.
+    case text(String)
+
+    /// Data with a specified media type. Not all media types may be supported by the AI model.
+    case data(mimetype: String, Data)
+
+    /// File data stored in Cloud Storage for Firebase, referenced by URI.
+    ///
+    /// > Note: Supported media types depends on the model; see [media requirements
+    /// > ](https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/send-multimodal-prompts#media_requirements)
+    /// > for details.
+    ///
+    /// - Parameters:
+    ///   - mimetype: The IANA standard MIME type of the uploaded file, for example, `"image/jpeg"`
+    ///     or `"video/mp4"`; see [media requirements
+    ///     ](https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/send-multimodal-prompts#media_requirements)
+    ///     for supported values.
+    ///   - uri: The `"gs://"`-prefixed URI of the file in Cloud Storage for Firebase, for example,
+    ///     `"gs://bucket-name/path/image.jpg"`.
+    case fileData(mimetype: String, uri: String)
+
+    /// A predicted function call returned from the model.
+    case functionCall(FunctionCall)
+
+    /// A response to a function call.
+    case functionResponse(FunctionResponse)
+
+    // MARK: Convenience Initializers
+
+    /// Convenience function for populating a Part with JPEG data.
+    public static func jpeg(_ data: Data) -> Self {
+      return .data(mimetype: "image/jpeg", data)
+    }
+
+    /// Convenience function for populating a Part with PNG data.
+    public static func png(_ data: Data) -> Self {
+      return .data(mimetype: "image/png", data)
+    }
+
+    /// Returns the text contents of this ``Part``, if it contains text.
+    public var text: String? {
+      switch self {
+      case let .text(contents): return contents
+      default: return nil
+      }
+    }
+  }
+
+  /// The role of the entity creating the ``ModelContent``. For user-generated client requests,
+  /// for example, the role is `user`.
+  public let role: String?
+
+  /// The data parts comprising this ``ModelContent`` value.
+  public let parts: [Part]
+
+  /// Creates a new value from any data or `Array` of data interpretable as a
+  /// ``Part``. See ``ThrowingPartsRepresentable`` for types that can be interpreted as `Part`s.
+  public init(role: String? = "user", parts: some ThrowingPartsRepresentable) throws {
+    self.role = role
+    try self.parts = parts.tryPartsValue()
+  }
+
+  /// Creates a new value from any data or `Array` of data interpretable as a
+  /// ``Part``. See ``ThrowingPartsRepresentable`` for types that can be interpreted as `Part`s.
+  public init(role: String? = "user", parts: some PartsRepresentable) {
+    self.role = role
+    self.parts = parts.partsValue
+  }
+
+  /// Creates a new value from a list of ``Part``s.
+  public init(role: String? = "user", parts: [Part]) {
+    self.role = role
+    self.parts = parts
+  }
+
+  /// Creates a new value from any data interpretable as a ``Part``. See
+  /// ``ThrowingPartsRepresentable``
+  /// for types that can be interpreted as `Part`s.
+  public init(role: String? = "user", _ parts: any ThrowingPartsRepresentable...) throws {
+    let content = try parts.flatMap { try $0.tryPartsValue() }
+    self.init(role: role, parts: content)
+  }
+
+  /// Creates a new value from any data interpretable as a ``Part``. See
+  /// ``ThrowingPartsRepresentable``
+  /// for types that can be interpreted as `Part`s.
+  public init(role: String? = "user", _ parts: [PartsRepresentable]) {
+    let content = parts.flatMap { $0.partsValue }
+    self.init(role: role, parts: content)
+  }
+}
+
+// MARK: Codable Conformances
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension ModelContent: Codable {}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension ModelContent.Part: Codable {
+  enum CodingKeys: String, CodingKey {
+    case text
+    case inlineData
+    case fileData
+    case functionCall
+    case functionResponse
+  }
+
+  enum InlineDataKeys: String, CodingKey {
+    case mimeType = "mime_type"
+    case bytes = "data"
+  }
+
+  enum FileDataKeys: String, CodingKey {
+    case mimeType = "mime_type"
+    case uri = "file_uri"
+  }
+
+  public func encode(to encoder: Encoder) throws {
+    var container = encoder.container(keyedBy: CodingKeys.self)
+    switch self {
+    case let .text(a0):
+      try container.encode(a0, forKey: .text)
+    case let .data(mimetype, bytes):
+      var inlineDataContainer = container.nestedContainer(
+        keyedBy: InlineDataKeys.self,
+        forKey: .inlineData
+      )
+      try inlineDataContainer.encode(mimetype, forKey: .mimeType)
+      try inlineDataContainer.encode(bytes, forKey: .bytes)
+    case let .fileData(mimetype: mimetype, url):
+      var fileDataContainer = container.nestedContainer(
+        keyedBy: FileDataKeys.self,
+        forKey: .fileData
+      )
+      try fileDataContainer.encode(mimetype, forKey: .mimeType)
+      try fileDataContainer.encode(url, forKey: .uri)
+    case let .functionCall(functionCall):
+      try container.encode(functionCall, forKey: .functionCall)
+    case let .functionResponse(functionResponse):
+      try container.encode(functionResponse, forKey: .functionResponse)
+    }
+  }
+
+  public init(from decoder: Decoder) throws {
+    let values = try decoder.container(keyedBy: CodingKeys.self)
+    if values.contains(.text) {
+      self = try .text(values.decode(String.self, forKey: .text))
+    } else if values.contains(.inlineData) {
+      let dataContainer = try values.nestedContainer(
+        keyedBy: InlineDataKeys.self,
+        forKey: .inlineData
+      )
+      let mimetype = try dataContainer.decode(String.self, forKey: .mimeType)
+      let bytes = try dataContainer.decode(Data.self, forKey: .bytes)
+      self = .data(mimetype: mimetype, bytes)
+    } else if values.contains(.functionCall) {
+      self = try .functionCall(values.decode(FunctionCall.self, forKey: .functionCall))
+    } else {
+      throw DecodingError.dataCorrupted(.init(
+        codingPath: [CodingKeys.text, CodingKeys.inlineData],
+        debugDescription: "No text, inline data or function call was found."
+      ))
+    }
+  }
+}
diff --git a/FirebaseVertexAI/Sources/PartsRepresentable+Image.swift b/FirebaseVertexAI/Sources/PartsRepresentable+Image.swift
new file mode 100644
index 00000000000..052a004e52f
--- /dev/null
+++ b/FirebaseVertexAI/Sources/PartsRepresentable+Image.swift
@@ -0,0 +1,107 @@
+// Copyright 2024 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 UniformTypeIdentifiers
+#if canImport(UIKit)
+  import UIKit // For UIImage extensions.
+#elseif canImport(AppKit)
+  import AppKit // For NSImage extensions.
+#endif
+
+private let imageCompressionQuality: CGFloat = 0.8
+
+/// An enum describing failures that can occur when converting image types to model content data.
+/// For some image types like `CIImage`, creating valid model content requires creating a JPEG
+/// representation of the image that may not yet exist, which may be computationally expensive.
+public enum ImageConversionError: Error {
+  /// The image (the receiver of the call `toModelContentParts()`) was invalid.
+  case invalidUnderlyingImage
+
+  /// A valid image destination could not be allocated.
+  case couldNotAllocateDestination
+
+  /// JPEG image data conversion failed, accompanied by the original image, which may be an
+  /// instance of `NSImageRep`, `UIImage`, `CGImage`, or `CIImage`.
+  case couldNotConvertToJPEG(Any)
+}
+
+#if canImport(UIKit)
+  /// Enables images to be representable as ``ThrowingPartsRepresentable``.
+  @available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+  extension UIImage: ThrowingPartsRepresentable {
+    public func tryPartsValue() throws -> [ModelContent.Part] {
+      guard let data = jpegData(compressionQuality: imageCompressionQuality) else {
+        throw ImageConversionError.couldNotConvertToJPEG(self)
+      }
+      return [ModelContent.Part.data(mimetype: "image/jpeg", data)]
+    }
+  }
+
+#elseif canImport(AppKit)
+  /// Enables images to be representable as ``ThrowingPartsRepresentable``.
+  @available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+  extension NSImage: ThrowingPartsRepresentable {
+    public func tryPartsValue() throws -> [ModelContent.Part] {
+      guard let cgImage = cgImage(forProposedRect: nil, context: nil, hints: nil) else {
+        throw ImageConversionError.invalidUnderlyingImage
+      }
+      let bmp = NSBitmapImageRep(cgImage: cgImage)
+      guard let data = bmp.representation(using: .jpeg, properties: [.compressionFactor: 0.8])
+      else {
+        throw ImageConversionError.couldNotConvertToJPEG(bmp)
+      }
+      return [ModelContent.Part.data(mimetype: "image/jpeg", data)]
+    }
+  }
+#endif
+
+/// Enables `CGImages` to be representable as model content.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension CGImage: ThrowingPartsRepresentable {
+  public func tryPartsValue() throws -> [ModelContent.Part] {
+    let output = NSMutableData()
+    guard let imageDestination = CGImageDestinationCreateWithData(
+      output, UTType.jpeg.identifier as CFString, 1, nil
+    ) else {
+      throw ImageConversionError.couldNotAllocateDestination
+    }
+    CGImageDestinationAddImage(imageDestination, self, nil)
+    CGImageDestinationSetProperties(imageDestination, [
+      kCGImageDestinationLossyCompressionQuality: imageCompressionQuality,
+    ] as CFDictionary)
+    if CGImageDestinationFinalize(imageDestination) {
+      return [.data(mimetype: "image/jpeg", output as Data)]
+    }
+    throw ImageConversionError.couldNotConvertToJPEG(self)
+  }
+}
+
+/// Enables `CIImages` to be representable as model content.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension CIImage: ThrowingPartsRepresentable {
+  public func tryPartsValue() throws -> [ModelContent.Part] {
+    let context = CIContext()
+    let jpegData = (colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB))
+      .flatMap {
+        // The docs specify kCGImageDestinationLossyCompressionQuality as a supported option, but
+        // Swift's type system does not allow this.
+        // [kCGImageDestinationLossyCompressionQuality: imageCompressionQuality]
+        context.jpegRepresentation(of: self, colorSpace: $0, options: [:])
+      }
+    if let jpegData = jpegData {
+      return [.data(mimetype: "image/jpeg", jpegData)]
+    }
+    throw ImageConversionError.couldNotConvertToJPEG(self)
+  }
+}
diff --git a/FirebaseVertexAI/Sources/PartsRepresentable.swift b/FirebaseVertexAI/Sources/PartsRepresentable.swift
new file mode 100644
index 00000000000..05ba0d9dabc
--- /dev/null
+++ b/FirebaseVertexAI/Sources/PartsRepresentable.swift
@@ -0,0 +1,66 @@
+// Copyright 2023 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 Foundation
+
+/// A protocol describing any data that could be serialized to model-interpretable input data,
+/// where the serialization process might fail with an error.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public protocol ThrowingPartsRepresentable {
+  func tryPartsValue() throws -> [ModelContent.Part]
+}
+
+/// A protocol describing any data that could be serialized to model-interpretable input data,
+/// where the serialization process cannot fail with an error. For a failable conversion, see
+/// ``ThrowingPartsRepresentable``
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public protocol PartsRepresentable: ThrowingPartsRepresentable {
+  var partsValue: [ModelContent.Part] { get }
+}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public extension PartsRepresentable {
+  func tryPartsValue() throws -> [ModelContent.Part] {
+    return partsValue
+  }
+}
+
+/// Enables a ``ModelContent.Part`` to be passed in as ``ThrowingPartsRepresentable``.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension ModelContent.Part: ThrowingPartsRepresentable {
+  public typealias ErrorType = Never
+  public func tryPartsValue() throws -> [ModelContent.Part] {
+    return [self]
+  }
+}
+
+/// Enable an `Array` of ``ThrowingPartsRepresentable`` values to be passed in as a single
+/// ``ThrowingPartsRepresentable``.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension [ThrowingPartsRepresentable]: ThrowingPartsRepresentable {
+  public func tryPartsValue() throws -> [ModelContent.Part] {
+    return try compactMap { element in
+      try element.tryPartsValue()
+    }
+    .flatMap { $0 }
+  }
+}
+
+/// Enables a `String` to be passed in as ``ThrowingPartsRepresentable``.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension String: PartsRepresentable {
+  public var partsValue: [ModelContent.Part] {
+    return [.text(self)]
+  }
+}
diff --git a/FirebaseVertexAI/Sources/Safety.swift b/FirebaseVertexAI/Sources/Safety.swift
new file mode 100644
index 00000000000..a81ac7d8e6e
--- /dev/null
+++ b/FirebaseVertexAI/Sources/Safety.swift
@@ -0,0 +1,197 @@
+// Copyright 2023 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 Foundation
+
+/// A type defining potentially harmful media categories and their model-assigned ratings. A value
+/// of this type may be assigned to a category for every model-generated response, not just
+/// responses that exceed a certain threshold.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public struct SafetyRating: Equatable, Hashable {
+  /// The category describing the potential harm a piece of content may pose. See
+  /// ``SafetySetting/HarmCategory`` for a list of possible values.
+  public let category: SafetySetting.HarmCategory
+
+  /// The model-generated probability that a given piece of content falls under the harm category
+  /// described in ``SafetySetting/HarmCategory``. This does not indicate the severity of harm for a
+  /// piece of content. See ``HarmProbability`` for a list of possible values.
+  public let probability: HarmProbability
+
+  /// Initializes a new `SafetyRating` instance with the given category and probability.
+  /// Use this initializer for SwiftUI previews or tests.
+  public init(category: SafetySetting.HarmCategory, probability: HarmProbability) {
+    self.category = category
+    self.probability = probability
+  }
+
+  /// The probability that a given model output falls under a harmful content category. This does
+  /// not indicate the severity of harm for a piece of content.
+  public enum HarmProbability: String {
+    /// Unknown. A new server value that isn't recognized by the SDK.
+    case unknown = "UNKNOWN"
+
+    /// The probability was not specified in the server response.
+    case unspecified = "HARM_PROBABILITY_UNSPECIFIED"
+
+    /// The probability is zero or close to zero. For benign content, the probability across all
+    /// categories will be this value.
+    case negligible = "NEGLIGIBLE"
+
+    /// The probability is small but non-zero.
+    case low = "LOW"
+
+    /// The probability is moderate.
+    case medium = "MEDIUM"
+
+    /// The probability is high. The content described is very likely harmful.
+    case high = "HIGH"
+  }
+}
+
+/// Safety feedback for an entire request.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public struct SafetyFeedback {
+  /// Safety rating evaluated from content.
+  public let rating: SafetyRating
+
+  /// Safety settings applied to the request.
+  public let setting: SafetySetting
+
+  /// Internal initializer.
+  init(rating: SafetyRating, setting: SafetySetting) {
+    self.rating = rating
+    self.setting = setting
+  }
+}
+
+/// A type used to specify a threshold for harmful content, beyond which the model will return a
+/// fallback response instead of generated content.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public struct SafetySetting {
+  /// A type describing safety attributes, which include harmful categories and topics that can
+  /// be considered sensitive.
+  public enum HarmCategory: String {
+    /// Unknown. A new server value that isn't recognized by the SDK.
+    case unknown = "HARM_CATEGORY_UNKNOWN"
+
+    /// Unspecified by the server.
+    case unspecified = "HARM_CATEGORY_UNSPECIFIED"
+
+    /// Harassment content.
+    case harassment = "HARM_CATEGORY_HARASSMENT"
+
+    /// Negative or harmful comments targeting identity and/or protected attributes.
+    case hateSpeech = "HARM_CATEGORY_HATE_SPEECH"
+
+    /// Contains references to sexual acts or other lewd content.
+    case sexuallyExplicit = "HARM_CATEGORY_SEXUALLY_EXPLICIT"
+
+    /// Promotes or enables access to harmful goods, services, or activities.
+    case dangerousContent = "HARM_CATEGORY_DANGEROUS_CONTENT"
+  }
+
+  /// Block at and beyond a specified ``SafetyRating/HarmProbability``.
+  public enum BlockThreshold: String {
+    /// Unknown. A new server value that isn't recognized by the SDK.
+    case unknown = "UNKNOWN"
+
+    /// Threshold is unspecified.
+    case unspecified = "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
+
+    // Content with `.negligible` will be allowed.
+    case blockLowAndAbove = "BLOCK_LOW_AND_ABOVE"
+
+    /// Content with `.negligible` and `.low` will be allowed.
+    case blockMediumAndAbove = "BLOCK_MEDIUM_AND_ABOVE"
+
+    /// Content with `.negligible`, `.low`, and `.medium` will be allowed.
+    case blockOnlyHigh = "BLOCK_ONLY_HIGH"
+
+    /// All content will be allowed.
+    case blockNone = "BLOCK_NONE"
+  }
+
+  enum CodingKeys: String, CodingKey {
+    case harmCategory = "category"
+    case threshold
+  }
+
+  /// The category this safety setting should be applied to.
+  public let harmCategory: HarmCategory
+
+  /// The threshold describing what content should be blocked.
+  public let threshold: BlockThreshold
+
+  /// Initializes a new safety setting with the given category and threshold.
+  public init(harmCategory: HarmCategory, threshold: BlockThreshold) {
+    self.harmCategory = harmCategory
+    self.threshold = threshold
+  }
+}
+
+// MARK: - Codable Conformances
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension SafetyRating.HarmProbability: Codable {
+  public init(from decoder: Decoder) throws {
+    let value = try decoder.singleValueContainer().decode(String.self)
+    guard let decodedProbability = SafetyRating.HarmProbability(rawValue: value) else {
+      Logging.default
+        .error("[FirebaseVertexAI] Unrecognized HarmProbability with value \"\(value)\".")
+      self = .unknown
+      return
+    }
+
+    self = decodedProbability
+  }
+}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension SafetyRating: Decodable {}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension SafetyFeedback: Decodable {}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension SafetySetting.HarmCategory: Codable {
+  public init(from decoder: Decoder) throws {
+    let value = try decoder.singleValueContainer().decode(String.self)
+    guard let decodedCategory = SafetySetting.HarmCategory(rawValue: value) else {
+      Logging.default
+        .error("[FirebaseVertexAI] Unrecognized HarmCategory with value \"\(value)\".")
+      self = .unknown
+      return
+    }
+
+    self = decodedCategory
+  }
+}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension SafetySetting.BlockThreshold: Codable {
+  public init(from decoder: Decoder) throws {
+    let value = try decoder.singleValueContainer().decode(String.self)
+    guard let decodedThreshold = SafetySetting.BlockThreshold(rawValue: value) else {
+      Logging.default
+        .error("[FirebaseVertexAI] Unrecognized BlockThreshold with value \"\(value)\".")
+      self = .unknown
+      return
+    }
+
+    self = decodedThreshold
+  }
+}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+extension SafetySetting: Codable {}
diff --git a/FirebaseVertexAI/Sources/VertexAI.swift b/FirebaseVertexAI/Sources/VertexAI.swift
new file mode 100644
index 00000000000..2017ea04b5f
--- /dev/null
+++ b/FirebaseVertexAI/Sources/VertexAI.swift
@@ -0,0 +1,153 @@
+// Copyright 2024 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 FirebaseAppCheckInterop
+import FirebaseAuthInterop
+import FirebaseCore
+import Foundation
+
+// Avoids exposing internal FirebaseCore APIs to Swift users.
+@_implementationOnly import FirebaseCoreExtension
+
+/// The Vertex AI for Firebase SDK provides access to Gemini models directly from your app.
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+public class VertexAI: NSObject {
+  // MARK: - Public APIs
+
+  /// The default `VertexAI` instance.
+  ///
+  /// - Parameter location: The region identifier, defaulting to `us-central1`; see [Vertex AI
+  /// regions
+  ///   ](https://cloud.google.com/vertex-ai/generative-ai/docs/learn/locations#available-regions)
+  ///   for a list of supported regions.
+  /// - Returns: An instance of `VertexAI`, configured with the default `FirebaseApp`.
+  public static func vertexAI(location: String = "us-central1") -> VertexAI {
+    guard let app = FirebaseApp.app() else {
+      fatalError("No instance of the default Firebase app was found.")
+    }
+
+    return vertexAI(app: app, location: location)
+  }
+
+  /// Creates an instance of `VertexAI` configured with a custom `FirebaseApp`.
+  ///
+  ///  - Parameters:
+  ///   - app: The custom `FirebaseApp` used for initialization.
+  ///   - location: The region identifier, defaulting to `us-central1`; see
+  ///     [Vertex AI locations]
+  ///     (https://firebase.google.com/docs/vertex-ai/locations?platform=ios#available-locations)
+  ///     for a list of supported locations.
+  /// - Returns: A `VertexAI` instance, configured with the custom `FirebaseApp`.
+  public static func vertexAI(app: FirebaseApp, location: String = "us-central1") -> VertexAI {
+    guard let provider = ComponentType<VertexAIProvider>.instance(for: VertexAIProvider.self,
+                                                                  in: app.container) else {
+      fatalError("No \(VertexAIProvider.self) instance found for Firebase app: \(app.name)")
+    }
+
+    return provider.vertexAI(location)
+  }
+
+  /// Initializes a generative model with the given parameters.
+  ///
+  /// - Note: Refer to [Gemini models](https://firebase.google.com/docs/vertex-ai/gemini-models) for
+  /// guidance on choosing an appropriate model for your use case.
+  ///
+  /// - Parameters:
+  ///   - modelName: The name of the model to use, for example `"gemini-1.5-flash-preview-0514"`;
+  ///     see [available model names
+  ///     ](https://firebase.google.com/docs/vertex-ai/gemini-models#available-model-names) for a
+  ///     list of supported model names.
+  ///   - generationConfig: The content generation parameters your model should use.
+  ///   - safetySettings: A value describing what types of harmful content your model should allow.
+  ///   - tools: A list of ``Tool`` objects that the model may use to generate the next response.
+  ///   - toolConfig: Tool configuration for any `Tool` specified in the request.
+  ///   - systemInstruction: Instructions that direct the model to behave a certain way; currently
+  ///     only text content is supported.
+  ///   - requestOptions: Configuration parameters for sending requests to the backend.
+  public func generativeModel(modelName: String,
+                              generationConfig: GenerationConfig? = nil,
+                              safetySettings: [SafetySetting]? = nil,
+                              tools: [Tool]? = nil,
+                              toolConfig: ToolConfig? = nil,
+                              systemInstruction: ModelContent? = nil,
+                              requestOptions: RequestOptions = RequestOptions())
+    -> GenerativeModel {
+    guard let projectID = app.options.projectID else {
+      fatalError("The Firebase app named \"\(app.name)\" has no project ID in its configuration.")
+    }
+
+    let modelResourceName = modelResourceName(
+      modelName: modelName,
+      projectID: projectID,
+      location: location
+    )
+
+    guard let apiKey = app.options.apiKey else {
+      fatalError("The Firebase app named \"\(app.name)\" has no API key in its configuration.")
+    }
+
+    return GenerativeModel(
+      name: modelResourceName,
+      projectID: projectID,
+      apiKey: apiKey,
+      generationConfig: generationConfig,
+      safetySettings: safetySettings,
+      tools: tools,
+      toolConfig: toolConfig,
+      systemInstruction: systemInstruction,
+      requestOptions: requestOptions,
+      appCheck: appCheck,
+      auth: auth
+    )
+  }
+
+  // MARK: - Private
+
+  /// The `FirebaseApp` associated with this `VertexAI` instance.
+  private let app: FirebaseApp
+
+  private let appCheck: AppCheckInterop?
+
+  private let auth: AuthInterop?
+
+  let location: String
+
+  init(app: FirebaseApp, location: String) {
+    self.app = app
+    self.location = location
+    appCheck = ComponentType<AppCheckInterop>.instance(for: AppCheckInterop.self, in: app.container)
+    auth = ComponentType<AuthInterop>.instance(for: AuthInterop.self, in: app.container)
+  }
+
+  private func modelResourceName(modelName: String, projectID: String, location: String) -> String {
+    guard !modelName.isEmpty && modelName
+      .allSatisfy({ !$0.isWhitespace && !$0.isNewline && $0 != "/" }) else {
+      fatalError("""
+      Invalid model name "\(modelName)" specified; see \
+      https://firebase.google.com/docs/vertex-ai/gemini-model#available-models for a list of \
+      available models.
+      """)
+    }
+    guard !location.isEmpty && location
+      .allSatisfy({ !$0.isWhitespace && !$0.isNewline && $0 != "/" }) else {
+      fatalError("""
+      Invalid location "\(location)" specified; see \
+      https://firebase.google.com/docs/vertex-ai/locations?platform=ios#available-locations \
+      for a list of available locations.
+      """)
+    }
+
+    return "projects/\(projectID)/locations/\(location)/publishers/google/models/\(modelName)"
+  }
+}
diff --git a/FirebaseVertexAI/Sources/VertexAIComponent.swift b/FirebaseVertexAI/Sources/VertexAIComponent.swift
new file mode 100644
index 00000000000..c43cab5f1f6
--- /dev/null
+++ b/FirebaseVertexAI/Sources/VertexAIComponent.swift
@@ -0,0 +1,83 @@
+// Copyright 2024 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 FirebaseAppCheckInterop
+import FirebaseAuthInterop
+import FirebaseCore
+import Foundation
+
+// Avoids exposing internal FirebaseCore APIs to Swift users.
+@_implementationOnly import FirebaseCoreExtension
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+@objc(FIRVertexAIProvider)
+protocol VertexAIProvider {
+  @objc func vertexAI(_ location: String) -> VertexAI
+}
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+@objc(FIRVertexAIComponent)
+class VertexAIComponent: NSObject, Library, VertexAIProvider {
+  // MARK: - Private Variables
+
+  /// The app associated with all `VertexAI` instances in this container.
+  /// This is `unowned` instead of `weak` so it can be used without unwrapping in `vertexAI(...)`
+  unowned let app: FirebaseApp
+
+  /// A map of active  `VertexAI` instances for `app`, keyed by model resource names
+  /// (e.g., "projects/my-project-id/locations/us-central1/publishers/google/models/gemini-pro").
+  private var instances: [String: VertexAI] = [:]
+
+  /// Lock to manage access to the `instances` array to avoid race conditions.
+  private var instancesLock: os_unfair_lock = .init()
+
+  // MARK: - Initializers
+
+  required init(app: FirebaseApp) {
+    self.app = app
+  }
+
+  // MARK: - Library conformance
+
+  static func componentsToRegister() -> [Component] {
+    let appCheckInterop = Dependency(with: AppCheckInterop.self, isRequired: false)
+    let authInterop = Dependency(with: AuthInterop.self, isRequired: false)
+    return [Component(VertexAIProvider.self,
+                      instantiationTiming: .lazy,
+                      dependencies: [
+                        appCheckInterop,
+                        authInterop,
+                      ]) { container, isCacheable in
+        guard let app = container.app else { return nil }
+        isCacheable.pointee = true
+        return self.init(app: app)
+      }]
+  }
+
+  // MARK: - VertexAIProvider conformance
+
+  func vertexAI(_ location: String) -> VertexAI {
+    os_unfair_lock_lock(&instancesLock)
+
+    // Unlock before the function returns.
+    defer { os_unfair_lock_unlock(&instancesLock) }
+
+    if let instance = instances[location] {
+      return instance
+    }
+    let newInstance = VertexAI(app: app, location: location)
+    instances[location] = newInstance
+    return newInstance
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/ChatTests.swift b/FirebaseVertexAI/Tests/Unit/ChatTests.swift
new file mode 100644
index 00000000000..2ce063c7ef2
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/ChatTests.swift
@@ -0,0 +1,77 @@
+// Copyright 2023 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 Foundation
+import XCTest
+
+@testable import FirebaseVertexAI
+
+@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, *)
+final class ChatTests: XCTestCase {
+  var urlSession: URLSession!
+
+  override func setUp() {
+    let configuration = URLSessionConfiguration.default
+    configuration.protocolClasses = [MockURLProtocol.self]
+    urlSession = URLSession(configuration: configuration)
+  }
+
+  override func tearDown() {
+    MockURLProtocol.requestHandler = nil
+  }
+
+  func testMergingText() async throws {
+    let fileURL = try XCTUnwrap(Bundle.module.url(
+      forResource: "streaming-success-basic-reply-long",
+      withExtension: "txt"
+    ))
+
+    MockURLProtocol.requestHandler = { request in
+      let response = HTTPURLResponse(
+        url: request.url!,
+        statusCode: 200,
+        httpVersion: nil,
+        headerFields: nil
+      )!
+      return (response, fileURL.lines)
+    }
+
+    let model = GenerativeModel(
+      name: "my-model",
+      projectID: "my-project-id",
+      apiKey: "API_KEY",
+      tools: nil,
+      requestOptions: RequestOptions(),
+      appCheck: nil,
+      auth: nil,
+      urlSession: urlSession
+    )
+    let chat = Chat(model: model, history: [])
+    let input = "Test input"
+    let stream = chat.sendMessageStream(input)
+
+    // Ensure the values are parsed correctly
+    for try await value in stream {
+      XCTAssertNotNil(value.text)
+    }
+
+    XCTAssertEqual(chat.history.count, 2)
+    XCTAssertEqual(chat.history[0].parts[0].text, input)
+
+    let finalText = "1 2 3 4 5 6 7 8"
+    let assembledExpectation = ModelContent(role: "model", parts: finalText)
+    XCTAssertEqual(chat.history[0].parts[0].text, input)
+    XCTAssertEqual(chat.history[1], assembledExpectation)
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/CountTokenResponses/failure-model-not-found.json b/FirebaseVertexAI/Tests/Unit/CountTokenResponses/failure-model-not-found.json
new file mode 100644
index 00000000000..50fcb725667
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/CountTokenResponses/failure-model-not-found.json
@@ -0,0 +1,13 @@
+{
+  "error": {
+    "code": 404,
+    "message": "models/test-model-name is not found for API version v1beta, or is not supported for countTokens. Call ListModels to see the list of available models and their supported methods.",
+    "status": "NOT_FOUND",
+    "details": [
+      {
+        "@type": "type.googleapis.com/google.rpc.DebugInfo",
+        "detail": "[ORIGINAL ERROR] generic::not_found: models/test-model-name is not found for API version v1beta, or is not supported for countTokens. Call ListModels to see the list of available models and their supported methods. [google.rpc.error_details_ext] { message: \"models/test-model-name is not found for API version v1beta, or is not supported for countTokens. Call ListModels to see the list of available models and their supported methods.\" }"
+      }
+    ]
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/CountTokenResponses/success-no-billable-characters.json b/FirebaseVertexAI/Tests/Unit/CountTokenResponses/success-no-billable-characters.json
new file mode 100644
index 00000000000..03425d51db0
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/CountTokenResponses/success-no-billable-characters.json
@@ -0,0 +1,3 @@
+{
+  "totalTokens": 258
+}
diff --git a/FirebaseVertexAI/Tests/Unit/CountTokenResponses/success-total-tokens.json b/FirebaseVertexAI/Tests/Unit/CountTokenResponses/success-total-tokens.json
new file mode 100644
index 00000000000..d2ad6e4ff30
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/CountTokenResponses/success-total-tokens.json
@@ -0,0 +1,4 @@
+{
+  "totalTokens": 6,
+  "totalBillableCharacters": 16
+}
diff --git a/FirebaseVertexAI/Tests/Unit/Fakes/AuthInteropFake.swift b/FirebaseVertexAI/Tests/Unit/Fakes/AuthInteropFake.swift
new file mode 100644
index 00000000000..6d0d33e1b41
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/Fakes/AuthInteropFake.swift
@@ -0,0 +1,49 @@
+// Copyright 2024 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 FirebaseAuthInterop
+import Foundation
+
+@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, *)
+class AuthInteropFake: NSObject, AuthInterop {
+  let token: String?
+  let error: Error?
+
+  func getToken(forcingRefresh forceRefresh: Bool) async throws -> String? {
+    if let error {
+      throw error
+    }
+
+    return token
+  }
+
+  func getUserID() -> String? {
+    fatalError("\(#function) not implemented.")
+  }
+
+  private init(token: String?, error: Error?) {
+    self.token = token
+    self.error = error
+  }
+
+  convenience init(error: Error) {
+    self.init(token: nil, error: error)
+  }
+
+  convenience init(token: String?) {
+    self.init(token: token, error: nil)
+  }
+}
+
+struct AuthErrorFake: Error {}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-empty-content.txt b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-empty-content.txt
new file mode 100644
index 00000000000..5762b515325
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-empty-content.txt
@@ -0,0 +1,1 @@
+data: {"candidates": [{"content": {},"index": 0}],"promptFeedback": {"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-error-mid-stream.txt b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-error-mid-stream.txt
new file mode 100644
index 00000000000..aeb4eb09389
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-error-mid-stream.txt
@@ -0,0 +1,17 @@
+data: {"candidates": [{"content": {"parts": [{"text": "First "}]},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}],"promptFeedback": {"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}}
+
+data: {"candidates": [{"content": {"parts": [{"text": "Second "}]},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]}
+
+{
+  "error": {
+    "code": 499,
+    "message": "The operation was cancelled.",
+    "status": "CANCELLED",
+    "details": [
+      {
+        "@type": "type.googleapis.com/google.rpc.DebugInfo",
+        "detail": "[ORIGINAL ERROR] generic::cancelled: "
+      }
+    ]
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-finish-reason-safety.txt b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-finish-reason-safety.txt
new file mode 100644
index 00000000000..b73c75cf505
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-finish-reason-safety.txt
@@ -0,0 +1,2 @@
+data: {"candidates": [{"content": {"parts": [{"text": "No"}]},"finishReason": "SAFETY","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "HIGH"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}],"promptFeedback": {"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}}
+
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-invalid-json.txt b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-invalid-json.txt
new file mode 100644
index 00000000000..3aea78474cb
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-invalid-json.txt
@@ -0,0 +1,1 @@
+data: {"this": [{"is": {"not": [{"a": "valid"}]}, "response": {}}]}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-malformed-content.txt b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-malformed-content.txt
new file mode 100644
index 00000000000..273b6cdc5ca
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-malformed-content.txt
@@ -0,0 +1,1 @@
+data: {"candidates": [{"content": {"missing-parts": true},"index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_TOXICITY","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_VIOLENCE","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}],"promptFeedback": {"safetyRatings": [{"category": "HARM_CATEGORY_TOXICITY","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_VIOLENCE","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-prompt-blocked-safety.txt b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-prompt-blocked-safety.txt
new file mode 100644
index 00000000000..58c914af08e
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-prompt-blocked-safety.txt
@@ -0,0 +1,2 @@
+data: {"promptFeedback": {"blockReason": "SAFETY","safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "HIGH"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}}
+
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-recitation-no-content.txt b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-recitation-no-content.txt
new file mode 100644
index 00000000000..60ec91d6dfc
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-recitation-no-content.txt
@@ -0,0 +1,6 @@
+data: {"candidates": [{"content": {"parts": [{"text": "Some information"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}],"promptFeedback": {"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}}
+
+data: {"candidates": [{"content": {"parts": [{"text": "Some information cited from an external source"}],"role": "model"},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "LOW"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}],"citationMetadata": {"citationSources": [{"startIndex": 30,"endIndex": 179,"uri": "https://www.example.com/some-citation","license": ""}]}}]}
+
+data: {"candidates": [{"finishReason": "RECITATION","index": 0}]}
+
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-unknown-finish-enum.txt b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-unknown-finish-enum.txt
new file mode 100644
index 00000000000..6194abd8957
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-failure-unknown-finish-enum.txt
@@ -0,0 +1,11 @@
+data: {"candidates": [{"content": {"parts": [{"text": "**Cats:**\n\n- **Physical Characteristics:**\n  - Size: Cats come"}]},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}],"promptFeedback": {"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}}
+
+data: {"candidates": [{"content": {"parts": [{"text": " in a wide range of sizes, from small breeds like the Singapura to large breeds like the Maine Coon.\n  - Fur: Cats have soft, furry coats"}]},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]}
+
+data: {"candidates": [{"content": {"parts": [{"text": " that can vary in length and texture depending on the breed.\n  - Eyes: Cats have large, expressive eyes that can be various colors, including green, blue, yellow, and hazel.\n  - Ears: Cats have pointed, erect ears that are sensitive to sound.\n  - Tail: Cats have long"}]},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]}
+
+data: {"candidates": [{"content": {"parts": [{"text": ", flexible tails that they use for balance and communication.\n\n- **Behavior and Personality:**\n  - Independent: Cats are often described as independent animals that enjoy spending time alone.\n  - Affectionate: Despite their independent nature, cats can be very affectionate and form strong bonds with their owners.\n  - Playful: Cats are naturally playful and enjoy engaging in activities such as chasing toys, climbing, and pouncing.\n  - Curious: Cats are curious creatures that love to explore their surroundings.\n  - Vocal: Cats communicate through a variety of vocalizations, including meows, purrs, hisses, and grow"}]},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]}
+
+data: {"candidates": [{"content": {"parts": [{"text": "ls.\n\n- **Health and Care:**\n  - Diet: Cats are obligate carnivores, meaning they require animal-based protein for optimal health.\n  - Grooming: Cats spend a significant amount of time grooming themselves to keep their fur clean and free of mats.\n  - Exercise: Cats need regular exercise to stay healthy and active. This can be achieved through play sessions or access to outdoor space.\n  - Veterinary Care: Regular veterinary checkups are essential for maintaining a cat's health and detecting any potential health issues early on.\n\n**Dogs:**\n\n- **Physical Characteristics:**\n  - Size: Dogs come in a wide range of sizes, from small breeds like the Chihuahua to giant breeds like the Great Dane.\n  - Fur: Dogs have fur coats that can vary in length, texture, and color depending on the breed.\n  - Eyes: Dogs have expressive eyes that can be various colors, including brown, blue, green, and hazel.\n  - Ears: Dogs have floppy or erect ears that are sensitive to sound.\n  - Tail: Dogs have long, wagging tails that they use for communication and expressing emotions.\n\n- **Behavior and Personality:**\n  - Loyal: Dogs are known for their loyalty and"}]},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}]}
+
+data: {"candidates": [{"content": {"parts": [{"text": " devotion to their owners.\n  - Friendly: Dogs are generally friendly and outgoing animals that enjoy interacting with people and other animals.\n  - Playful: Dogs are playful and energetic creatures that love to engage in activities such as fetching, running, and playing with toys.\n  - Trainable: Dogs are highly trainable and can learn a variety of commands and tricks.\n  - Vocal: Dogs communicate through a variety of vocalizations, including barking, howling, whining, and growling.\n\n- **Health and Care:**\n  - Diet: Dogs are omnivores and can eat a variety of foods, including meat, vegetables, and grains.\n  - Grooming: Dogs require regular grooming to keep their fur clean and free of mats. The frequency of grooming depends on the breed and coat type.\n  - Exercise: Dogs need regular exercise to stay healthy and active. The amount of exercise required varies depending on the breed and age of the dog.\n  - Veterinary Care: Regular veterinary checkups are essential for maintaining a dog's health and detecting any potential health issues early on."}]},"finishReason": "FAKE_ENUM","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT_NEW_ENUM","probability": "NEGLIGIBLE_UNKNOWN_ENUM"}]}]}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-success-basic-reply-long.txt b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-success-basic-reply-long.txt
new file mode 100644
index 00000000000..218cae0b985
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-success-basic-reply-long.txt
@@ -0,0 +1,15 @@
+data: {"candidates": [{"content": {"role": "model","parts": [{"text": "1 "}]}}]}
+
+data: {"candidates": [{"content": {"role": "model","parts": [{"text": "2 "}]},"safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE","probabilityScore": 0.0394904,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.04468087},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE","probabilityScore": 0.034553625,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.03890198},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE","probabilityScore": 0.09401018,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.025809621},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE","probabilityScore": 0.036562506,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.047691282}]}]}
+
+data: {"candidates": [{"content": {"role": "model","parts": [{"text": "3 "}]},"safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE","probabilityScore": 0.03507868,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.045183755},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE","probabilityScore": 0.027742893,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.043528143},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE","probabilityScore": 0.08803312,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.026105914},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE","probabilityScore": 0.06681233,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.053899158}]}]}
+
+data: {"candidates": [{"content": {"role": "model","parts": [{"text": "4 "}]},"safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE","probabilityScore": 0.037750278,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.05089372},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE","probabilityScore": 0.040087357,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.05888469},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE","probabilityScore": 0.071202725,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.034100424},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE","probabilityScore": 0.07613248,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.051749535}]}]}
+
+data: {"candidates": [{"content": {"role": "model","parts": [{"text": "5 "}]},"safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE","probabilityScore": 0.04672496,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.059210256},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE","probabilityScore": 0.04977345,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.05623635},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE","probabilityScore": 0.083890386,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.03825006},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE","probabilityScore": 0.08359067,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.05975658}]}]}
+
+data: {"candidates": [{"content": {"role": "model","parts": [{"text": "6 "}]},"safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE","probabilityScore": 0.07779744,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.06052939},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE","probabilityScore": 0.041930523,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.056756895},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE","probabilityScore": 0.12787028,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.05350215},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE","probabilityScore": 0.09203286,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.048676573}]}]}
+
+data: {"candidates": [{"content": {"role": "model","parts": [{"text": "7 "}]},"safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE","probabilityScore": 0.071202725,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.05291181},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE","probabilityScore": 0.031439852,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.04509957},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE","probabilityScore": 0.11417085,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.04922211},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE","probabilityScore": 0.09451043,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.052716404}]}]}
+
+data: {"candidates": [{"content": {"role": "model","parts": [{"text": "8"}]},"finishReason": "STOP","safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE","probabilityScore": 0.06221698,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.045777276},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE","probabilityScore": 0.03085051,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.04560694},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE","probabilityScore": 0.0992954,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.040769264},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE","probabilityScore": 0.100701615,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.061424047}]}],"usageMetadata": {"promptTokenCount": 6,"candidatesTokenCount": 326,"totalTokenCount": 332}}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-success-basic-reply-short.txt b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-success-basic-reply-short.txt
new file mode 100644
index 00000000000..78f569ddecf
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-success-basic-reply-short.txt
@@ -0,0 +1,1 @@
+data: {"candidates": [{"content": {"role": "model","parts": [{"text": "Mountain View, California"}]},"finishReason": "STOP","safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE","probabilityScore": 0.02854415,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.052424565},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE","probabilityScore": 0.24926445,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.0996453},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE","probabilityScore": 0.087096825,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.043123372},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE","probabilityScore": 0.14402841,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.086169556}]}],"usageMetadata": {"promptTokenCount": 6,"candidatesTokenCount": 4,"totalTokenCount": 10}}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-success-citations.txt b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-success-citations.txt
new file mode 100644
index 00000000000..fdc1f20d8b6
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-success-citations.txt
@@ -0,0 +1,7 @@
+data: {"candidates": [{"content": {"role": "model","parts": [{"text": "Some information"}]},"safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE","probabilityScore": 0.043204036,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.082549304},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE","probabilityScore": 0.046291895,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.071461484},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE","probabilityScore": 0.100701615,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.06164962},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE","probabilityScore": 0.13150747,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.040087357}]}]}
+
+data: {"candidates": [{"content": {"role": "model","parts": [{"text": "  Some information cited from an external source"}]},"safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE","probabilityScore": 0.18982129,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.1337543},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE","probabilityScore": 0.13637818,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.021906368},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE","probabilityScore": 0.25404602,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.09073549},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE","probabilityScore": 0.24202643,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.06548521}],"citationMetadata": {"citations": [{"endIndex": 128,"uri": "https://www.example.com/citation-1"},{"startIndex": 130,"endIndex": 265,"uri": "https://www.example.com/citation-2"}]}}]}
+
+data: {"candidates": [{"content": {"role": "model","parts": [{"text": " More information cited from an external source"}]},"safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE","probabilityScore": 0.07850098,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.039416388},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE","probabilityScore": 0.08035747,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.04885778},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE","probabilityScore": 0.12273335,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.059646938},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE","probabilityScore": 0.053206205,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.04099903}],"citationMetadata": {"citations": [{"startIndex": 272,"endIndex": 431,"uri": "https://www.example.com/citation-3","license": "mit"}]}}]}
+
+data: {"candidates": [{"content": {"role": "model","parts": [{"text": " More information "}]},"finishReason": "STOP","safetyRatings": [{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE","probabilityScore": 0.16013464,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.11716747},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE","probabilityScore": 0.10818896,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.021990221},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE","probabilityScore": 0.2158462,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.07682221},{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE","probabilityScore": 0.19636348,"severity": "HARM_SEVERITY_NEGLIGIBLE","severityScore": 0.08021325}]}],"usageMetadata": {"promptTokenCount": 9,"candidatesTokenCount": 53,"totalTokenCount": 62}}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-success-unknown-safety-enum.txt b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-success-unknown-safety-enum.txt
new file mode 100644
index 00000000000..8f7a8ff3c1c
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/streaming-success-unknown-safety-enum.txt
@@ -0,0 +1,2 @@
+data: {"candidates": [{"content": {"parts": [{"text": "Cheyenne"}]},"finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_SOMETHING_NEW","probability": "NEGLIGIBLE"}]}],"promptFeedback": {"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}}
+
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-api-key.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-api-key.json
new file mode 100644
index 00000000000..e9b472fd031
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-api-key.json
@@ -0,0 +1,21 @@
+{
+  "error": {
+    "code": 400,
+    "message": "API key not valid. Please pass a valid API key.",
+    "status": "INVALID_ARGUMENT",
+    "details": [
+      {
+        "@type": "type.googleapis.com/google.rpc.ErrorInfo",
+        "reason": "API_KEY_INVALID",
+        "domain": "googleapis.com",
+        "metadata": {
+          "service": "firebaseml.googleapis.com"
+        }
+      },
+      {
+        "@type": "type.googleapis.com/google.rpc.DebugInfo",
+        "detail": "Invalid API key: AIzv00G7VmUCUeC-5OglO3hcXM"
+      }
+    ]
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-empty-content.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-empty-content.json
new file mode 100644
index 00000000000..4e1889660f2
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-empty-content.json
@@ -0,0 +1,28 @@
+{
+  "candidates": [
+    {
+      "content": {},
+      "index": 0
+    }
+  ],
+  "promptFeedback": {
+    "safetyRatings": [
+      {
+        "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+        "probability": "NEGLIGIBLE"
+      },
+      {
+        "category": "HARM_CATEGORY_HATE_SPEECH",
+        "probability": "NEGLIGIBLE"
+      },
+      {
+        "category": "HARM_CATEGORY_HARASSMENT",
+        "probability": "NEGLIGIBLE"
+      },
+      {
+        "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+        "probability": "NEGLIGIBLE"
+      }
+    ]
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-finish-reason-safety-no-content.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-finish-reason-safety-no-content.json
new file mode 100644
index 00000000000..03958d4afc1
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-finish-reason-safety-no-content.json
@@ -0,0 +1,46 @@
+{
+    "candidates": [
+        {
+            "finishReason": "SAFETY",
+            "index": 0,
+            "safetyRatings": [
+                {
+                    "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+                    "probability": "NEGLIGIBLE"
+                },
+                {
+                    "category": "HARM_CATEGORY_HATE_SPEECH",
+                    "probability": "HIGH"
+                },
+                {
+                    "category": "HARM_CATEGORY_HARASSMENT",
+                    "probability": "NEGLIGIBLE"
+                },
+                {
+                    "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+                    "probability": "NEGLIGIBLE"
+                }
+            ]
+        }
+    ],
+    "promptFeedback": {
+        "safetyRatings": [
+            {
+                "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+                "probability": "NEGLIGIBLE"
+            },
+            {
+                "category": "HARM_CATEGORY_HATE_SPEECH",
+                "probability": "NEGLIGIBLE"
+            },
+            {
+                "category": "HARM_CATEGORY_HARASSMENT",
+                "probability": "NEGLIGIBLE"
+            },
+            {
+                "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+                "probability": "NEGLIGIBLE"
+            }
+        ]
+    }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-finish-reason-safety.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-finish-reason-safety.json
new file mode 100644
index 00000000000..3249c739cf8
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-finish-reason-safety.json
@@ -0,0 +1,53 @@
+{
+    "candidates": [
+        {
+            "content": {
+                "parts": [
+                    {
+                        "text": "No"
+                    }
+                ]
+            },
+            "finishReason": "SAFETY",
+            "index": 0,
+            "safetyRatings": [
+                {
+                    "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+                    "probability": "NEGLIGIBLE"
+                },
+                {
+                    "category": "HARM_CATEGORY_HATE_SPEECH",
+                    "probability": "HIGH"
+                },
+                {
+                    "category": "HARM_CATEGORY_HARASSMENT",
+                    "probability": "NEGLIGIBLE"
+                },
+                {
+                    "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+                    "probability": "NEGLIGIBLE"
+                }
+            ]
+        }
+    ],
+    "promptFeedback": {
+        "safetyRatings": [
+            {
+                "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+                "probability": "NEGLIGIBLE"
+            },
+            {
+                "category": "HARM_CATEGORY_HATE_SPEECH",
+                "probability": "NEGLIGIBLE"
+            },
+            {
+                "category": "HARM_CATEGORY_HARASSMENT",
+                "probability": "NEGLIGIBLE"
+            },
+            {
+                "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+                "probability": "NEGLIGIBLE"
+            }
+        ]
+    }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-firebaseml-api-not-enabled.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-firebaseml-api-not-enabled.json
new file mode 100644
index 00000000000..7526cd7ae18
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-firebaseml-api-not-enabled.json
@@ -0,0 +1,27 @@
+{
+  "error": {
+    "code": 403,
+    "message": "Firebase ML API has not been used in project 1234567890 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/firebaseml.googleapis.com/overview?project=1234567890 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.",
+    "status": "PERMISSION_DENIED",
+    "details": [
+      {
+        "@type": "type.googleapis.com/google.rpc.Help",
+        "links": [
+          {
+            "description": "Google developers console API activation",
+            "url": "https://console.developers.google.com/apis/api/firebaseml.googleapis.com/overview?project=1234567890"
+          }
+        ]
+      },
+      {
+        "@type": "type.googleapis.com/google.rpc.ErrorInfo",
+        "reason": "SERVICE_DISABLED",
+        "domain": "googleapis.com",
+        "metadata": {
+          "service": "firebaseml.googleapis.com",
+          "consumer": "projects/1234567890"
+        }
+      }
+    ]
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-image-rejected.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-image-rejected.json
new file mode 100644
index 00000000000..9dacdc71e7a
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-image-rejected.json
@@ -0,0 +1,13 @@
+{
+  "error": {
+    "code": 400,
+    "message": "Request contains an invalid argument.",
+    "status": "INVALID_ARGUMENT",
+    "details": [
+      {
+        "@type": "type.googleapis.com/google.rpc.DebugInfo",
+        "detail": "[ORIGINAL ERROR] generic::invalid_argument: invalid status photos.thumbnailer.Status.Code::5: Source image 0 too short"
+      }
+    ]
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-invalid-response.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-invalid-response.json
new file mode 100644
index 00000000000..49d05e1840b
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-invalid-response.json
@@ -0,0 +1,14 @@
+{
+  "this": [
+    {
+      "is": {
+        "not": [
+          {
+            "a": "valid"
+          }
+        ]
+      },
+      "response": {}
+    }
+  ]
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-malformed-content.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-malformed-content.json
new file mode 100644
index 00000000000..737f2e08548
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-malformed-content.json
@@ -0,0 +1,30 @@
+{
+  "candidates": [
+    {
+      "content": {
+        "invalid-field": true
+      },
+      "index": 0
+    }
+  ],
+  "promptFeedback": {
+    "safetyRatings": [
+      {
+        "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+        "probability": "NEGLIGIBLE"
+      },
+      {
+        "category": "HARM_CATEGORY_HATE_SPEECH",
+        "probability": "NEGLIGIBLE"
+      },
+      {
+        "category": "HARM_CATEGORY_HARASSMENT",
+        "probability": "NEGLIGIBLE"
+      },
+      {
+        "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+        "probability": "NEGLIGIBLE"
+      }
+    ]
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-prompt-blocked-safety.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-prompt-blocked-safety.json
new file mode 100644
index 00000000000..9d2abbb23d6
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-prompt-blocked-safety.json
@@ -0,0 +1,23 @@
+{
+  "promptFeedback": {
+    "blockReason": "SAFETY",
+    "safetyRatings": [
+      {
+        "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+        "probability": "NEGLIGIBLE"
+      },
+      {
+        "category": "HARM_CATEGORY_HATE_SPEECH",
+        "probability": "HIGH"
+      },
+      {
+        "category": "HARM_CATEGORY_HARASSMENT",
+        "probability": "NEGLIGIBLE"
+      },
+      {
+        "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+        "probability": "NEGLIGIBLE"
+      }
+    ]
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-unknown-enum-finish-reason.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-unknown-enum-finish-reason.json
new file mode 100644
index 00000000000..1adbc70cae4
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-unknown-enum-finish-reason.json
@@ -0,0 +1,53 @@
+{
+  "candidates": [
+    {
+      "content": {
+        "parts": [
+          {
+            "text": "Some text"
+          }
+        ]
+      },
+      "finishReason": "FAKE_NEW_FINISH_REASON",
+      "index": 0,
+      "safetyRatings": [
+        {
+          "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+          "probability": "NEGLIGIBLE"
+        },
+        {
+          "category": "HARM_CATEGORY_HATE_SPEECH",
+          "probability": "NEGLIGIBLE"
+        },
+        {
+          "category": "HARM_CATEGORY_HARASSMENT",
+          "probability": "NEGLIGIBLE"
+        },
+        {
+          "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+          "probability": "NEGLIGIBLE"
+        }
+      ]
+    }
+  ],
+  "promptFeedback": {
+    "safetyRatings": [
+      {
+        "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+        "probability": "NEGLIGIBLE"
+      },
+      {
+        "category": "HARM_CATEGORY_HATE_SPEECH",
+        "probability": "NEGLIGIBLE"
+      },
+      {
+        "category": "HARM_CATEGORY_HARASSMENT",
+        "probability": "NEGLIGIBLE"
+      },
+      {
+        "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+        "probability": "NEGLIGIBLE"
+      }
+    ]
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-unknown-enum-prompt-blocked.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-unknown-enum-prompt-blocked.json
new file mode 100644
index 00000000000..a00cbb72cfc
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-unknown-enum-prompt-blocked.json
@@ -0,0 +1,23 @@
+{
+  "promptFeedback": {
+    "blockReason": "FAKE_NEW_BLOCK_REASON",
+    "safetyRatings": [
+      {
+        "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+        "probability": "NEGLIGIBLE"
+      },
+      {
+        "category": "HARM_CATEGORY_HATE_SPEECH",
+        "probability": "NEGLIGIBLE"
+      },
+      {
+        "category": "HARM_CATEGORY_HARASSMENT",
+        "probability": "NEGLIGIBLE"
+      },
+      {
+        "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+        "probability": "NEGLIGIBLE"
+      }
+    ]
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-unknown-model.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-unknown-model.json
new file mode 100644
index 00000000000..60b3f55c978
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-failure-unknown-model.json
@@ -0,0 +1,13 @@
+{
+  "error": {
+    "code": 404,
+    "message": "models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods.",
+    "status": "NOT_FOUND",
+    "details": [
+      {
+        "@type": "type.googleapis.com/google.rpc.DebugInfo",
+        "detail": "[ORIGINAL ERROR] generic::not_found: models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods. [google.rpc.error_details_ext] { message: \"models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods.\" }"
+      }
+    ]
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-basic-reply-long.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-basic-reply-long.json
new file mode 100644
index 00000000000..325d172b894
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-basic-reply-long.json
@@ -0,0 +1,50 @@
+{
+  "candidates": [
+    {
+      "content": {
+        "role": "model",
+        "parts": [
+          {
+            "text": "You can ask me a wide range of questions, including:\n\n* **General knowledge questions:** What is the capital of France? Who painted the Mona Lisa?\n* **Science questions:** What is the chemical formula for water? How does photosynthesis work?\n* **History questions:** When did World War II start? Who was the first president of the United States?\n* **Math questions:** What is the square root of 16? How do you solve for x in the equation x + 5 = 10?\n* **Current events questions:** What is happening in Ukraine? Who is the current president of the United States?\n* **Personal questions:** What are your hobbies? What are your favorite books?\n* **Hypothetical questions:** What would happen if all the ice caps melted? What if humans could live on Mars?\n* **Questions about me:** What is your name? How old are you? What are your capabilities?\n\n**Tips for asking questions:**\n\n* Be specific and clear in your questions.\n* Use correct grammar and spelling.\n* Try to ask open-ended questions that allow for multiple answers.\n* Be patient and wait for my response. I am still under development, so I may take a few seconds to process your question.\n\nPlease note that I am still under development and may not be able to answer all questions accurately. However, I will do my best to provide you with the most relevant information I have available."
+          }
+        ]
+      },
+      "finishReason": "STOP",
+      "safetyRatings": [
+        {
+          "category": "HARM_CATEGORY_HATE_SPEECH",
+          "probability": "NEGLIGIBLE",
+          "probabilityScore": 0.047869004,
+          "severity": "HARM_SEVERITY_NEGLIGIBLE",
+          "severityScore": 0.050705366
+        },
+        {
+          "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+          "probability": "NEGLIGIBLE",
+          "probabilityScore": 0.052134257,
+          "severity": "HARM_SEVERITY_NEGLIGIBLE",
+          "severityScore": 0.036288295
+        },
+        {
+          "category": "HARM_CATEGORY_HARASSMENT",
+          "probability": "NEGLIGIBLE",
+          "probabilityScore": 0.08464396,
+          "severity": "HARM_SEVERITY_NEGLIGIBLE",
+          "severityScore": 0.033907957
+        },
+        {
+          "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+          "probability": "NEGLIGIBLE",
+          "probabilityScore": 0.06290424,
+          "severity": "HARM_SEVERITY_NEGLIGIBLE",
+          "severityScore": 0.050611436
+        }
+      ]
+    }
+  ],
+  "usageMetadata": {
+    "promptTokenCount": 6,
+    "candidatesTokenCount": 303,
+    "totalTokenCount": 309
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-basic-reply-short.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-basic-reply-short.json
new file mode 100644
index 00000000000..3e3ddb80fd6
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-basic-reply-short.json
@@ -0,0 +1,50 @@
+{
+  "candidates": [
+    {
+      "content": {
+        "role": "model",
+        "parts": [
+          {
+            "text": "Mountain View, California, United States"
+          }
+        ]
+      },
+      "finishReason": "STOP",
+      "safetyRatings": [
+        {
+          "category": "HARM_CATEGORY_HATE_SPEECH",
+          "probability": "NEGLIGIBLE",
+          "probabilityScore": 0.029035643,
+          "severity": "HARM_SEVERITY_NEGLIGIBLE",
+          "severityScore": 0.05613278
+        },
+        {
+          "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+          "probability": "NEGLIGIBLE",
+          "probabilityScore": 0.2641685,
+          "severity": "HARM_SEVERITY_NEGLIGIBLE",
+          "severityScore": 0.082253955
+        },
+        {
+          "category": "HARM_CATEGORY_HARASSMENT",
+          "probability": "NEGLIGIBLE",
+          "probabilityScore": 0.087252244,
+          "severity": "HARM_SEVERITY_NEGLIGIBLE",
+          "severityScore": 0.04509957
+        },
+        {
+          "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+          "probability": "NEGLIGIBLE",
+          "probabilityScore": 0.1431877,
+          "severity": "HARM_SEVERITY_NEGLIGIBLE",
+          "severityScore": 0.11027937
+        }
+      ]
+    }
+  ],
+  "usageMetadata": {
+    "promptTokenCount": 6,
+    "candidatesTokenCount": 7,
+    "totalTokenCount": 13
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-citations.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-citations.json
new file mode 100644
index 00000000000..68fc9648773
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-citations.json
@@ -0,0 +1,69 @@
+{
+  "candidates": [
+    {
+      "content": {
+        "role": "model",
+        "parts": [
+          {
+            "text": "Some information cited from an external source"
+          }
+        ]
+      },
+      "finishReason": "STOP",
+      "safetyRatings": [
+        {
+          "category": "HARM_CATEGORY_HATE_SPEECH",
+          "probability": "NEGLIGIBLE",
+          "probabilityScore": 0.16926852,
+          "severity": "HARM_SEVERITY_NEGLIGIBLE",
+          "severityScore": 0.08181271
+        },
+        {
+          "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+          "probability": "NEGLIGIBLE",
+          "probabilityScore": 0.15636235,
+          "severity": "HARM_SEVERITY_NEGLIGIBLE",
+          "severityScore": 0.02981654
+        },
+        {
+          "category": "HARM_CATEGORY_HARASSMENT",
+          "probability": "NEGLIGIBLE",
+          "probabilityScore": 0.33133608,
+          "severity": "HARM_SEVERITY_NEGLIGIBLE",
+          "severityScore": 0.10875559
+        },
+        {
+          "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+          "probability": "NEGLIGIBLE",
+          "probabilityScore": 0.17766814,
+          "severity": "HARM_SEVERITY_NEGLIGIBLE",
+          "severityScore": 0.18787657
+        }
+      ],
+      "citationMetadata": {
+        "citations": [
+          {
+            "endIndex": 128,
+            "uri": "https://www.example.com/some-citation-1"
+          },
+          {
+            "startIndex": 130,
+            "endIndex": 265,
+            "uri": "https://www.example.com/some-citation-2"
+          },
+          {
+            "startIndex": 272,
+            "endIndex": 431,
+            "uri": "https://www.example.com/some-citation-3",
+            "license": "mit"
+          }
+        ]
+      }
+    }
+  ],
+  "usageMetadata": {
+    "promptTokenCount": 15,
+    "candidatesTokenCount": 253,
+    "totalTokenCount": 268
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-function-call-empty-arguments.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-function-call-empty-arguments.json
new file mode 100644
index 00000000000..dc0b75ad930
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-function-call-empty-arguments.json
@@ -0,0 +1,18 @@
+{
+  "candidates": [
+    {
+      "content": {
+        "parts": [
+          {
+            "functionCall": {
+              "name": "current_time"
+            }
+          }
+        ],
+        "role": "model"
+      },
+      "finishReason": "STOP",
+      "index": 0
+    }
+  ]
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-function-call-mixed-content.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-function-call-mixed-content.json
new file mode 100644
index 00000000000..6e7ce27fe58
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-function-call-mixed-content.json
@@ -0,0 +1,37 @@
+{
+  "candidates": [
+    {
+      "content": {
+        "parts": [
+          {
+            "text": "The sum of [1, 2,"
+          },
+          {
+            "functionCall": {
+              "name": "sum",
+              "args": {
+                "y": 1,
+                "x": 2
+              }
+            }
+          },
+          {
+            "text": "3] is"
+          },
+          {
+            "functionCall": {
+              "name": "sum",
+              "args": {
+                "y": 3,
+                "x": 3
+              }
+            }
+          }
+        ],
+        "role": "model"
+      },
+      "finishReason": "STOP",
+      "index": 0
+    }
+  ]
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-function-call-no-arguments.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-function-call-no-arguments.json
new file mode 100644
index 00000000000..05f4f4d58f9
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-function-call-no-arguments.json
@@ -0,0 +1,19 @@
+{
+  "candidates": [
+    {
+      "content": {
+        "parts": [
+          {
+            "functionCall": {
+              "name": "current_time",
+              "args": {}
+            }
+          }
+        ],
+        "role": "model"
+      },
+      "finishReason": "STOP",
+      "index": 0
+    }
+  ]
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-function-call-parallel-calls.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-function-call-parallel-calls.json
new file mode 100644
index 00000000000..d535f8eeabc
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-function-call-parallel-calls.json
@@ -0,0 +1,40 @@
+{
+  "candidates": [
+    {
+      "content": {
+        "parts": [
+          {
+            "functionCall": {
+              "name": "sum",
+              "args": {
+                "y": 1,
+                "x": 2
+              }
+            }
+          },
+          {
+            "functionCall": {
+              "name": "sum",
+              "args": {
+                "y": 3,
+                "x": 4
+              }
+            }
+          },
+          {
+            "functionCall": {
+              "name": "sum",
+              "args": {
+                "y": 5,
+                "x": 6
+              }
+            }
+          }
+        ],
+        "role": "model"
+      },
+      "finishReason": "STOP",
+      "index": 0
+    }
+  ]
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-function-call-with-arguments.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-function-call-with-arguments.json
new file mode 100644
index 00000000000..025735a3a39
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-function-call-with-arguments.json
@@ -0,0 +1,22 @@
+{
+  "candidates": [
+    {
+      "content": {
+        "parts": [
+          {
+            "functionCall": {
+              "name": "sum",
+              "args": {
+                "y": 5,
+                "x": 4
+              }
+            }
+          }
+        ],
+        "role": "model"
+      },
+      "finishReason": "STOP",
+      "index": 0
+    }
+  ]
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-missing-safety-ratings.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-missing-safety-ratings.json
new file mode 100644
index 00000000000..645d3e6425e
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-missing-safety-ratings.json
@@ -0,0 +1,16 @@
+{
+  "candidates": [
+    {
+      "content": {
+        "parts": [
+          {
+            "text": "This is the generated content."
+          }
+        ]
+      },
+      "index": 0
+    }
+  ],
+  "promptFeedback": {
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-quote-reply.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-quote-reply.json
new file mode 100644
index 00000000000..e5bf4f0b497
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-quote-reply.json
@@ -0,0 +1,54 @@
+{
+  "candidates": [
+    {
+      "content": {
+        "parts": [
+          {
+            "text": "Google's mission is to \"organize the world's information and make it universally accessible and useful.\""
+          }
+        ],
+        "role": "model"
+      },
+      "finishReason": "STOP",
+      "index": 0,
+      "safetyRatings": [
+        {
+          "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+          "probability": "NEGLIGIBLE"
+        },
+        {
+          "category": "HARM_CATEGORY_HATE_SPEECH",
+          "probability": "NEGLIGIBLE"
+        },
+        {
+          "category": "HARM_CATEGORY_HARASSMENT",
+          "probability": "NEGLIGIBLE"
+        },
+        {
+          "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+          "probability": "NEGLIGIBLE"
+        }
+      ]
+    }
+  ],
+  "promptFeedback": {
+    "safetyRatings": [
+      {
+        "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
+        "probability": "NEGLIGIBLE"
+      },
+      {
+        "category": "HARM_CATEGORY_HATE_SPEECH",
+        "probability": "NEGLIGIBLE"
+      },
+      {
+        "category": "HARM_CATEGORY_HARASSMENT",
+        "probability": "NEGLIGIBLE"
+      },
+      {
+        "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+        "probability": "NEGLIGIBLE"
+      }
+    ]
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-unknown-enum-safety-ratings.json b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-unknown-enum-safety-ratings.json
new file mode 100644
index 00000000000..3c47a21322c
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerateContentResponses/unary-success-unknown-enum-safety-ratings.json
@@ -0,0 +1,46 @@
+{
+  "candidates": [
+    {
+      "content": {
+        "parts": [
+          {
+            "text": "Some text"
+          }
+        ],
+        "role": "model"
+      },
+      "finishReason": "STOP",
+      "index": 0,
+      "safetyRatings": [
+        {
+          "category": "HARM_CATEGORY_HARASSMENT",
+          "probability": "MEDIUM"
+        },
+        {
+          "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+          "probability": "FAKE_NEW_HARM_PROBABILITY"
+        },
+        {
+          "category": "FAKE_NEW_HARM_CATEGORY",
+          "probability": "HIGH"
+        }
+      ]
+    }
+  ],
+  "promptFeedback": {
+    "safetyRatings": [
+      {
+        "category": "HARM_CATEGORY_HARASSMENT",
+        "probability": "MEDIUM"
+      },
+      {
+        "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
+        "probability": "FAKE_NEW_HARM_PROBABILITY"
+      },
+      {
+        "category": "FAKE_NEW_HARM_CATEGORY",
+        "probability": "HIGH"
+      }
+    ]
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerationConfigTests.swift b/FirebaseVertexAI/Tests/Unit/GenerationConfigTests.swift
new file mode 100644
index 00000000000..f925a70effa
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerationConfigTests.swift
@@ -0,0 +1,94 @@
+// Copyright 2024 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 FirebaseVertexAI
+import Foundation
+import XCTest
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+final class GenerationConfigTests: XCTestCase {
+  let encoder = JSONEncoder()
+
+  override func setUp() {
+    encoder.outputFormatting = .init(
+      arrayLiteral: .prettyPrinted, .sortedKeys, .withoutEscapingSlashes
+    )
+  }
+
+  // MARK: GenerationConfig Encoding
+
+  func testEncodeGenerationConfig_default() throws {
+    let generationConfig = GenerationConfig()
+
+    let jsonData = try encoder.encode(generationConfig)
+
+    let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
+    XCTAssertEqual(json, """
+    {
+
+    }
+    """)
+  }
+
+  func testEncodeGenerationConfig_allOptions() throws {
+    let temperature: Float = 0.5
+    let topP: Float = 0.75
+    let topK = 40
+    let candidateCount = 2
+    let maxOutputTokens = 256
+    let stopSequences = ["END", "DONE"]
+    let responseMIMEType = "text/plain"
+    let generationConfig = GenerationConfig(
+      temperature: temperature,
+      topP: topP,
+      topK: topK,
+      candidateCount: candidateCount,
+      maxOutputTokens: maxOutputTokens,
+      stopSequences: stopSequences,
+      responseMIMEType: responseMIMEType
+    )
+
+    let jsonData = try encoder.encode(generationConfig)
+
+    let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
+    XCTAssertEqual(json, """
+    {
+      "candidateCount" : \(candidateCount),
+      "maxOutputTokens" : \(maxOutputTokens),
+      "responseMIMEType" : "\(responseMIMEType)",
+      "stopSequences" : [
+        "END",
+        "DONE"
+      ],
+      "temperature" : \(temperature),
+      "topK" : \(topK),
+      "topP" : \(topP)
+    }
+    """)
+  }
+
+  func testEncodeGenerationConfig_responseMIMEType() throws {
+    let mimeType = "image/jpeg"
+    let generationConfig = GenerationConfig(responseMIMEType: mimeType)
+
+    let jsonData = try encoder.encode(generationConfig)
+
+    let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
+    XCTAssertEqual(json, """
+    {
+      "responseMIMEType" : "\(mimeType)"
+    }
+    """)
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift b/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift
new file mode 100644
index 00000000000..87d308b14a1
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/GenerativeModelTests.swift
@@ -0,0 +1,1406 @@
+// Copyright 2023 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 FirebaseAppCheckInterop
+import FirebaseAuthInterop
+import FirebaseCore
+import XCTest
+
+@testable import FirebaseVertexAI
+
+@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, *)
+final class GenerativeModelTests: XCTestCase {
+  let testPrompt = "What sorts of questions can I ask you?"
+  let safetyRatingsNegligible: [SafetyRating] = [
+    .init(category: .sexuallyExplicit, probability: .negligible),
+    .init(category: .hateSpeech, probability: .negligible),
+    .init(category: .harassment, probability: .negligible),
+    .init(category: .dangerousContent, probability: .negligible),
+  ].sorted()
+
+  var urlSession: URLSession!
+  var model: GenerativeModel!
+
+  override func setUp() async throws {
+    let configuration = URLSessionConfiguration.default
+    configuration.protocolClasses = [MockURLProtocol.self]
+    urlSession = try XCTUnwrap(URLSession(configuration: configuration))
+    model = GenerativeModel(
+      name: "my-model",
+      projectID: "my-project-id",
+      apiKey: "API_KEY",
+      tools: nil,
+      requestOptions: RequestOptions(),
+      appCheck: nil,
+      auth: nil,
+      urlSession: urlSession
+    )
+  }
+
+  override func tearDown() {
+    MockURLProtocol.requestHandler = nil
+  }
+
+  // MARK: - Generate Content
+
+  func testGenerateContent_success_basicReplyLong() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-basic-reply-long",
+        withExtension: "json"
+      )
+
+    let response = try await model.generateContent(testPrompt)
+
+    XCTAssertEqual(response.candidates.count, 1)
+    let candidate = try XCTUnwrap(response.candidates.first)
+    let finishReason = try XCTUnwrap(candidate.finishReason)
+    XCTAssertEqual(finishReason, .stop)
+    XCTAssertEqual(candidate.safetyRatings.sorted(), safetyRatingsNegligible)
+    XCTAssertEqual(candidate.content.parts.count, 1)
+    let part = try XCTUnwrap(candidate.content.parts.first)
+    let partText = try XCTUnwrap(part.text)
+    XCTAssertTrue(partText.hasPrefix("You can ask me a wide range of questions"))
+    XCTAssertEqual(response.text, partText)
+    XCTAssertEqual(response.functionCalls, [])
+  }
+
+  func testGenerateContent_success_basicReplyShort() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-basic-reply-short",
+        withExtension: "json"
+      )
+
+    let response = try await model.generateContent(testPrompt)
+
+    XCTAssertEqual(response.candidates.count, 1)
+    let candidate = try XCTUnwrap(response.candidates.first)
+    let finishReason = try XCTUnwrap(candidate.finishReason)
+    XCTAssertEqual(finishReason, .stop)
+    XCTAssertEqual(candidate.safetyRatings.sorted(), safetyRatingsNegligible)
+    XCTAssertEqual(candidate.content.parts.count, 1)
+    let part = try XCTUnwrap(candidate.content.parts.first)
+    XCTAssertEqual(part.text, "Mountain View, California, United States")
+    XCTAssertEqual(response.text, part.text)
+    XCTAssertEqual(response.functionCalls, [])
+  }
+
+  func testGenerateContent_success_citations() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-citations",
+        withExtension: "json"
+      )
+
+    let response = try await model.generateContent(testPrompt)
+
+    XCTAssertEqual(response.candidates.count, 1)
+    let candidate = try XCTUnwrap(response.candidates.first)
+    XCTAssertEqual(candidate.content.parts.count, 1)
+    XCTAssertEqual(response.text, "Some information cited from an external source")
+    let citationMetadata = try XCTUnwrap(candidate.citationMetadata)
+    XCTAssertEqual(citationMetadata.citationSources.count, 3)
+    let citationSource1 = try XCTUnwrap(citationMetadata.citationSources[0])
+    XCTAssertEqual(citationSource1.uri, "https://www.example.com/some-citation-1")
+    XCTAssertEqual(citationSource1.startIndex, 0)
+    XCTAssertEqual(citationSource1.endIndex, 128)
+    XCTAssertNil(citationSource1.license)
+    let citationSource2 = try XCTUnwrap(citationMetadata.citationSources[1])
+    XCTAssertEqual(citationSource2.uri, "https://www.example.com/some-citation-2")
+    XCTAssertEqual(citationSource2.startIndex, 130)
+    XCTAssertEqual(citationSource2.endIndex, 265)
+    XCTAssertNil(citationSource2.license)
+    let citationSource3 = try XCTUnwrap(citationMetadata.citationSources[2])
+    XCTAssertEqual(citationSource3.uri, "https://www.example.com/some-citation-3")
+    XCTAssertEqual(citationSource3.startIndex, 272)
+    XCTAssertEqual(citationSource3.endIndex, 431)
+    XCTAssertEqual(citationSource3.license, "mit")
+  }
+
+  func testGenerateContent_success_quoteReply() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-quote-reply",
+        withExtension: "json"
+      )
+
+    let response = try await model.generateContent(testPrompt)
+
+    XCTAssertEqual(response.candidates.count, 1)
+    let candidate = try XCTUnwrap(response.candidates.first)
+    let finishReason = try XCTUnwrap(candidate.finishReason)
+    XCTAssertEqual(finishReason, .stop)
+    XCTAssertEqual(candidate.safetyRatings.sorted(), safetyRatingsNegligible)
+    XCTAssertEqual(candidate.content.parts.count, 1)
+    let part = try XCTUnwrap(candidate.content.parts.first)
+    let partText = try XCTUnwrap(part.text)
+    XCTAssertTrue(partText.hasPrefix("Google"))
+    XCTAssertEqual(response.text, part.text)
+    let promptFeedback = try XCTUnwrap(response.promptFeedback)
+    XCTAssertNil(promptFeedback.blockReason)
+    XCTAssertEqual(promptFeedback.safetyRatings.sorted(), safetyRatingsNegligible)
+  }
+
+  func testGenerateContent_success_unknownEnum_safetyRatings() async throws {
+    let expectedSafetyRatings = [
+      SafetyRating(category: .harassment, probability: .medium),
+      SafetyRating(category: .dangerousContent, probability: .unknown),
+      SafetyRating(category: .unknown, probability: .high),
+    ]
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-unknown-enum-safety-ratings",
+        withExtension: "json"
+      )
+
+    let response = try await model.generateContent(testPrompt)
+
+    XCTAssertEqual(response.text, "Some text")
+    XCTAssertEqual(response.candidates.first?.safetyRatings, expectedSafetyRatings)
+    XCTAssertEqual(response.promptFeedback?.safetyRatings, expectedSafetyRatings)
+  }
+
+  func testGenerateContent_success_prefixedModelName() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-basic-reply-short",
+        withExtension: "json"
+      )
+    let model = GenerativeModel(
+      // Model name is prefixed with "models/".
+      name: "models/test-model",
+      projectID: "my-project-id",
+      apiKey: "API_KEY",
+      tools: nil,
+      requestOptions: RequestOptions(),
+      appCheck: nil,
+      auth: nil,
+      urlSession: urlSession
+    )
+
+    _ = try await model.generateContent(testPrompt)
+  }
+
+  func testGenerateContent_success_functionCall_emptyArguments() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-function-call-empty-arguments",
+        withExtension: "json"
+      )
+
+    let response = try await model.generateContent(testPrompt)
+
+    XCTAssertEqual(response.candidates.count, 1)
+    let candidate = try XCTUnwrap(response.candidates.first)
+    XCTAssertEqual(candidate.content.parts.count, 1)
+    let part = try XCTUnwrap(candidate.content.parts.first)
+    guard case let .functionCall(functionCall) = part else {
+      XCTFail("Part is not a FunctionCall.")
+      return
+    }
+    XCTAssertEqual(functionCall.name, "current_time")
+    XCTAssertTrue(functionCall.args.isEmpty)
+    XCTAssertEqual(response.functionCalls, [functionCall])
+  }
+
+  func testGenerateContent_success_functionCall_noArguments() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-function-call-no-arguments",
+        withExtension: "json"
+      )
+
+    let response = try await model.generateContent(testPrompt)
+
+    XCTAssertEqual(response.candidates.count, 1)
+    let candidate = try XCTUnwrap(response.candidates.first)
+    XCTAssertEqual(candidate.content.parts.count, 1)
+    let part = try XCTUnwrap(candidate.content.parts.first)
+    guard case let .functionCall(functionCall) = part else {
+      XCTFail("Part is not a FunctionCall.")
+      return
+    }
+    XCTAssertEqual(functionCall.name, "current_time")
+    XCTAssertTrue(functionCall.args.isEmpty)
+    XCTAssertEqual(response.functionCalls, [functionCall])
+  }
+
+  func testGenerateContent_success_functionCall_withArguments() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-function-call-with-arguments",
+        withExtension: "json"
+      )
+
+    let response = try await model.generateContent(testPrompt)
+
+    XCTAssertEqual(response.candidates.count, 1)
+    let candidate = try XCTUnwrap(response.candidates.first)
+    XCTAssertEqual(candidate.content.parts.count, 1)
+    let part = try XCTUnwrap(candidate.content.parts.first)
+    guard case let .functionCall(functionCall) = part else {
+      XCTFail("Part is not a FunctionCall.")
+      return
+    }
+    XCTAssertEqual(functionCall.name, "sum")
+    XCTAssertEqual(functionCall.args.count, 2)
+    let argX = try XCTUnwrap(functionCall.args["x"])
+    XCTAssertEqual(argX, .number(4))
+    let argY = try XCTUnwrap(functionCall.args["y"])
+    XCTAssertEqual(argY, .number(5))
+    XCTAssertEqual(response.functionCalls, [functionCall])
+  }
+
+  func testGenerateContent_success_functionCall_parallelCalls() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-function-call-parallel-calls",
+        withExtension: "json"
+      )
+
+    let response = try await model.generateContent(testPrompt)
+
+    XCTAssertEqual(response.candidates.count, 1)
+    let candidate = try XCTUnwrap(response.candidates.first)
+    XCTAssertEqual(candidate.content.parts.count, 3)
+    let functionCalls = response.functionCalls
+    XCTAssertEqual(functionCalls.count, 3)
+  }
+
+  func testGenerateContent_success_functionCall_mixedContent() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-function-call-mixed-content",
+        withExtension: "json"
+      )
+
+    let response = try await model.generateContent(testPrompt)
+
+    XCTAssertEqual(response.candidates.count, 1)
+    let candidate = try XCTUnwrap(response.candidates.first)
+    XCTAssertEqual(candidate.content.parts.count, 4)
+    let functionCalls = response.functionCalls
+    XCTAssertEqual(functionCalls.count, 2)
+    let text = try XCTUnwrap(response.text)
+    XCTAssertEqual(text, "The sum of [1, 2, 3] is")
+  }
+
+  func testGenerateContent_appCheck_validToken() async throws {
+    let appCheckToken = "test-valid-token"
+    model = GenerativeModel(
+      name: "my-model",
+      projectID: "my-project-id",
+      apiKey: "API_KEY",
+      tools: nil,
+      requestOptions: RequestOptions(),
+      appCheck: AppCheckInteropFake(token: appCheckToken),
+      auth: nil,
+      urlSession: urlSession
+    )
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-basic-reply-short",
+        withExtension: "json",
+        appCheckToken: appCheckToken
+      )
+
+    _ = try await model.generateContent(testPrompt)
+  }
+
+  func testGenerateContent_appCheck_tokenRefreshError() async throws {
+    model = GenerativeModel(
+      name: "my-model",
+      projectID: "my-project-id",
+      apiKey: "API_KEY",
+      tools: nil,
+      requestOptions: RequestOptions(),
+      appCheck: AppCheckInteropFake(error: AppCheckErrorFake()),
+      auth: nil,
+      urlSession: urlSession
+    )
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-basic-reply-short",
+        withExtension: "json",
+        appCheckToken: AppCheckInteropFake.placeholderTokenValue
+      )
+
+    _ = try await model.generateContent(testPrompt)
+  }
+
+  func testGenerateContent_auth_validAuthToken() async throws {
+    let authToken = "test-valid-token"
+    model = GenerativeModel(
+      name: "my-model",
+      projectID: "my-project-id",
+      apiKey: "API_KEY",
+      tools: nil,
+      requestOptions: RequestOptions(),
+      appCheck: nil,
+      auth: AuthInteropFake(token: authToken),
+      urlSession: urlSession
+    )
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-basic-reply-short",
+        withExtension: "json",
+        authToken: authToken
+      )
+
+    _ = try await model.generateContent(testPrompt)
+  }
+
+  func testGenerateContent_auth_nilAuthToken() async throws {
+    model = GenerativeModel(
+      name: "my-model",
+      projectID: "my-project-id",
+      apiKey: "API_KEY",
+      tools: nil,
+      requestOptions: RequestOptions(),
+      appCheck: nil,
+      auth: AuthInteropFake(token: nil),
+      urlSession: urlSession
+    )
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-basic-reply-short",
+        withExtension: "json",
+        authToken: nil
+      )
+
+    _ = try await model.generateContent(testPrompt)
+  }
+
+  func testGenerateContent_auth_authTokenRefreshError() async throws {
+    model = GenerativeModel(
+      name: "my-model",
+      projectID: "my-project-id",
+      apiKey: "API_KEY",
+      tools: nil,
+      requestOptions: RequestOptions(),
+      appCheck: nil,
+      auth: AuthInteropFake(error: AuthErrorFake()),
+      urlSession: urlSession
+    )
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-basic-reply-short",
+        withExtension: "json",
+        authToken: nil
+      )
+
+    do {
+      _ = try await model.generateContent(testPrompt)
+      XCTFail("Should throw internalError(AuthErrorFake); no error.")
+    } catch GenerateContentError.internalError(_ as AuthErrorFake) {
+      //
+    } catch {
+      XCTFail("Should throw internalError(AuthErrorFake); error thrown: \(error)")
+    }
+  }
+
+  func testGenerateContent_usageMetadata() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-basic-reply-short",
+        withExtension: "json"
+      )
+
+    let response = try await model.generateContent(testPrompt)
+
+    let usageMetadata = try XCTUnwrap(response.usageMetadata)
+    XCTAssertEqual(usageMetadata.promptTokenCount, 6)
+    XCTAssertEqual(usageMetadata.candidatesTokenCount, 7)
+    XCTAssertEqual(usageMetadata.totalTokenCount, 13)
+  }
+
+  func testGenerateContent_failure_invalidAPIKey() async throws {
+    let expectedStatusCode = 400
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-failure-api-key",
+        withExtension: "json",
+        statusCode: expectedStatusCode
+      )
+
+    do {
+      _ = try await model.generateContent(testPrompt)
+      XCTFail("Should throw GenerateContentError.internalError; no error thrown.")
+    } catch let GenerateContentError.internalError(error as RPCError) {
+      XCTAssertEqual(error.httpResponseCode, 400)
+      XCTAssertEqual(error.status, .invalidArgument)
+      XCTAssertEqual(error.message, "API key not valid. Please pass a valid API key.")
+      return
+    } catch {
+      XCTFail("Should throw GenerateContentError.internalError(RPCError); error thrown: \(error)")
+    }
+  }
+
+  func testGenerateContent_failure_firebaseMLAPINotEnabled() async throws {
+    let expectedStatusCode = 403
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-failure-firebaseml-api-not-enabled",
+        withExtension: "json",
+        statusCode: expectedStatusCode
+      )
+
+    do {
+      _ = try await model.generateContent(testPrompt)
+      XCTFail("Should throw GenerateContentError.internalError; no error thrown.")
+    } catch let GenerateContentError.internalError(error as RPCError) {
+      XCTAssertEqual(error.httpResponseCode, expectedStatusCode)
+      XCTAssertEqual(error.status, .permissionDenied)
+      XCTAssertTrue(error.message.starts(with: "Firebase ML API has not been used in project"))
+      XCTAssertTrue(error.isFirebaseMLServiceDisabledError())
+      return
+    } catch {
+      XCTFail("Should throw GenerateContentError.internalError(RPCError); error thrown: \(error)")
+    }
+  }
+
+  func testGenerateContent_failure_emptyContent() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-failure-empty-content",
+        withExtension: "json"
+      )
+
+    do {
+      _ = try await model.generateContent(testPrompt)
+      XCTFail("Should throw GenerateContentError.internalError; no error thrown.")
+    } catch let GenerateContentError
+      .internalError(underlying: invalidCandidateError as InvalidCandidateError) {
+      guard case let .emptyContent(decodingError) = invalidCandidateError else {
+        XCTFail("Not an InvalidCandidateError.emptyContent error: \(invalidCandidateError)")
+        return
+      }
+      _ = try XCTUnwrap(decodingError as? DecodingError,
+                        "Not a DecodingError: \(decodingError)")
+    } catch {
+      XCTFail("Should throw GenerateContentError.internalError; error thrown: \(error)")
+    }
+  }
+
+  func testGenerateContent_failure_finishReasonSafety() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-failure-finish-reason-safety",
+        withExtension: "json"
+      )
+
+    do {
+      _ = try await model.generateContent(testPrompt)
+      XCTFail("Should throw")
+    } catch let GenerateContentError.responseStoppedEarly(reason, response) {
+      XCTAssertEqual(reason, .safety)
+      XCTAssertEqual(response.text, "No")
+    } catch {
+      XCTFail("Should throw a responseStoppedEarly")
+    }
+  }
+
+  func testGenerateContent_failure_finishReasonSafety_noContent() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-failure-finish-reason-safety-no-content",
+        withExtension: "json"
+      )
+
+    do {
+      _ = try await model.generateContent(testPrompt)
+      XCTFail("Should throw")
+    } catch let GenerateContentError.responseStoppedEarly(reason, response) {
+      XCTAssertEqual(reason, .safety)
+      XCTAssertNil(response.text)
+    } catch {
+      XCTFail("Should throw a responseStoppedEarly")
+    }
+  }
+
+  func testGenerateContent_failure_imageRejected() async throws {
+    let expectedStatusCode = 400
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-failure-image-rejected",
+        withExtension: "json",
+        statusCode: 400
+      )
+
+    do {
+      _ = try await model.generateContent(testPrompt)
+      XCTFail("Should throw GenerateContentError.internalError; no error thrown.")
+    } catch let GenerateContentError.internalError(underlying: rpcError as RPCError) {
+      XCTAssertEqual(rpcError.status, .invalidArgument)
+      XCTAssertEqual(rpcError.httpResponseCode, expectedStatusCode)
+      XCTAssertEqual(rpcError.message, "Request contains an invalid argument.")
+    } catch {
+      XCTFail("Should throw GenerateContentError.internalError; error thrown: \(error)")
+    }
+  }
+
+  func testGenerateContent_failure_promptBlockedSafety() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-failure-prompt-blocked-safety",
+        withExtension: "json"
+      )
+
+    do {
+      _ = try await model.generateContent(testPrompt)
+      XCTFail("Should throw")
+    } catch let GenerateContentError.promptBlocked(response) {
+      XCTAssertNil(response.text)
+    } catch {
+      XCTFail("Should throw a promptBlocked")
+    }
+  }
+
+  func testGenerateContent_failure_unknownEnum_finishReason() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-failure-unknown-enum-finish-reason",
+        withExtension: "json"
+      )
+
+    do {
+      _ = try await model.generateContent(testPrompt)
+      XCTFail("Should throw")
+    } catch let GenerateContentError.responseStoppedEarly(reason, response) {
+      XCTAssertEqual(reason, .unknown)
+      XCTAssertEqual(response.text, "Some text")
+    } catch {
+      XCTFail("Should throw a responseStoppedEarly")
+    }
+  }
+
+  func testGenerateContent_failure_unknownEnum_promptBlocked() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-failure-unknown-enum-prompt-blocked",
+        withExtension: "json"
+      )
+
+    do {
+      _ = try await model.generateContent(testPrompt)
+      XCTFail("Should throw")
+    } catch let GenerateContentError.promptBlocked(response) {
+      let promptFeedback = try XCTUnwrap(response.promptFeedback)
+      XCTAssertEqual(promptFeedback.blockReason, .unknown)
+    } catch {
+      XCTFail("Should throw a promptBlocked")
+    }
+  }
+
+  func testGenerateContent_failure_unknownModel() async throws {
+    let expectedStatusCode = 404
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-failure-unknown-model",
+        withExtension: "json",
+        statusCode: 404
+      )
+
+    do {
+      _ = try await model.generateContent(testPrompt)
+      XCTFail("Should throw GenerateContentError.internalError; no error thrown.")
+    } catch let GenerateContentError.internalError(underlying: rpcError as RPCError) {
+      XCTAssertEqual(rpcError.status, .notFound)
+      XCTAssertEqual(rpcError.httpResponseCode, expectedStatusCode)
+      XCTAssertTrue(rpcError.message.hasPrefix("models/unknown is not found"))
+    } catch {
+      XCTFail("Should throw GenerateContentError.internalError; error thrown: \(error)")
+    }
+  }
+
+  func testGenerateContent_failure_nonHTTPResponse() async throws {
+    MockURLProtocol.requestHandler = try nonHTTPRequestHandler()
+
+    var responseError: Error?
+    var content: GenerateContentResponse?
+    do {
+      content = try await model.generateContent(testPrompt)
+    } catch {
+      responseError = error
+    }
+
+    XCTAssertNil(content)
+    XCTAssertNotNil(responseError)
+    let generateContentError = try XCTUnwrap(responseError as? GenerateContentError)
+    guard case let .internalError(underlyingError) = generateContentError else {
+      XCTFail("Not an internal error: \(generateContentError)")
+      return
+    }
+    XCTAssertEqual(underlyingError.localizedDescription, "Response was not an HTTP response.")
+  }
+
+  func testGenerateContent_failure_invalidResponse() async throws {
+    MockURLProtocol.requestHandler = try httpRequestHandler(
+      forResource: "unary-failure-invalid-response",
+      withExtension: "json"
+    )
+
+    var responseError: Error?
+    var content: GenerateContentResponse?
+    do {
+      content = try await model.generateContent(testPrompt)
+    } catch {
+      responseError = error
+    }
+
+    XCTAssertNil(content)
+    XCTAssertNotNil(responseError)
+    let generateContentError = try XCTUnwrap(responseError as? GenerateContentError)
+    guard case let .internalError(underlyingError) = generateContentError else {
+      XCTFail("Not an internal error: \(generateContentError)")
+      return
+    }
+    let decodingError = try XCTUnwrap(underlyingError as? DecodingError)
+    guard case let .dataCorrupted(context) = decodingError else {
+      XCTFail("Not a data corrupted error: \(decodingError)")
+      return
+    }
+    XCTAssert(context.debugDescription.hasPrefix("Failed to decode GenerateContentResponse"))
+  }
+
+  func testGenerateContent_failure_malformedContent() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-failure-malformed-content",
+        withExtension: "json"
+      )
+
+    var responseError: Error?
+    var content: GenerateContentResponse?
+    do {
+      content = try await model.generateContent(testPrompt)
+    } catch {
+      responseError = error
+    }
+
+    XCTAssertNil(content)
+    XCTAssertNotNil(responseError)
+    let generateContentError = try XCTUnwrap(responseError as? GenerateContentError)
+    guard case let .internalError(underlyingError) = generateContentError else {
+      XCTFail("Not an internal error: \(generateContentError)")
+      return
+    }
+    let invalidCandidateError = try XCTUnwrap(underlyingError as? InvalidCandidateError)
+    guard case let .malformedContent(malformedContentUnderlyingError) = invalidCandidateError else {
+      XCTFail("Not a malformed content error: \(invalidCandidateError)")
+      return
+    }
+    _ = try XCTUnwrap(
+      malformedContentUnderlyingError as? DecodingError,
+      "Not a decoding error: \(malformedContentUnderlyingError)"
+    )
+  }
+
+  func testGenerateContentMissingSafetyRatings() async throws {
+    MockURLProtocol.requestHandler = try httpRequestHandler(
+      forResource: "unary-success-missing-safety-ratings",
+      withExtension: "json"
+    )
+
+    let content = try await model.generateContent(testPrompt)
+    let promptFeedback = try XCTUnwrap(content.promptFeedback)
+    XCTAssertEqual(promptFeedback.safetyRatings.count, 0)
+    XCTAssertEqual(content.text, "This is the generated content.")
+  }
+
+  func testGenerateContent_requestOptions_customTimeout() async throws {
+    let expectedTimeout = 150.0
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-success-basic-reply-short",
+        withExtension: "json",
+        timeout: expectedTimeout
+      )
+    let requestOptions = RequestOptions(timeout: expectedTimeout)
+    model = GenerativeModel(
+      name: "my-model",
+      projectID: "my-project-id",
+      apiKey: "API_KEY",
+      tools: nil,
+      requestOptions: requestOptions,
+      appCheck: nil,
+      auth: nil,
+      urlSession: urlSession
+    )
+
+    let response = try await model.generateContent(testPrompt)
+
+    XCTAssertEqual(response.candidates.count, 1)
+  }
+
+  // MARK: - Generate Content (Streaming)
+
+  func testGenerateContentStream_failureInvalidAPIKey() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-failure-api-key",
+        withExtension: "json"
+      )
+
+    do {
+      let stream = model.generateContentStream("Hi")
+      for try await _ in stream {
+        XCTFail("No content is there, this shouldn't happen.")
+      }
+    } catch let GenerateContentError.internalError(error as RPCError) {
+      XCTAssertEqual(error.httpResponseCode, 400)
+      XCTAssertEqual(error.status, .invalidArgument)
+      XCTAssertEqual(error.message, "API key not valid. Please pass a valid API key.")
+      return
+    }
+
+    XCTFail("Should have caught an error.")
+  }
+
+  func testGenerateContentStream_failure_firebaseMLAPINotEnabled() async throws {
+    let expectedStatusCode = 403
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "unary-failure-firebaseml-api-not-enabled",
+        withExtension: "json",
+        statusCode: expectedStatusCode
+      )
+
+    do {
+      let stream = model.generateContentStream(testPrompt)
+      for try await _ in stream {
+        XCTFail("No content is there, this shouldn't happen.")
+      }
+    } catch let GenerateContentError.internalError(error as RPCError) {
+      XCTAssertEqual(error.httpResponseCode, expectedStatusCode)
+      XCTAssertEqual(error.status, .permissionDenied)
+      XCTAssertTrue(error.message.starts(with: "Firebase ML API has not been used in project"))
+      XCTAssertTrue(error.isFirebaseMLServiceDisabledError())
+      return
+    }
+
+    XCTFail("Should have caught an error.")
+  }
+
+  func testGenerateContentStream_failureEmptyContent() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "streaming-failure-empty-content",
+        withExtension: "txt"
+      )
+
+    do {
+      let stream = model.generateContentStream("Hi")
+      for try await _ in stream {
+        XCTFail("No content is there, this shouldn't happen.")
+      }
+    } catch GenerateContentError.internalError(_ as InvalidCandidateError) {
+      // Underlying error is as expected, nothing else to check.
+      return
+    }
+
+    XCTFail("Should have caught an error.")
+  }
+
+  func testGenerateContentStream_failureFinishReasonSafety() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "streaming-failure-finish-reason-safety",
+        withExtension: "txt"
+      )
+
+    do {
+      let stream = model.generateContentStream("Hi")
+      for try await _ in stream {
+        XCTFail("Content shouldn't be shown, this shouldn't happen.")
+      }
+    } catch let GenerateContentError.responseStoppedEarly(reason, _) {
+      XCTAssertEqual(reason, .safety)
+      return
+    }
+
+    XCTFail("Should have caught an error.")
+  }
+
+  func testGenerateContentStream_failurePromptBlockedSafety() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "streaming-failure-prompt-blocked-safety",
+        withExtension: "txt"
+      )
+
+    do {
+      let stream = model.generateContentStream("Hi")
+      for try await _ in stream {
+        XCTFail("Content shouldn't be shown, this shouldn't happen.")
+      }
+    } catch let GenerateContentError.promptBlocked(response) {
+      XCTAssertEqual(response.promptFeedback?.blockReason, .safety)
+      return
+    }
+
+    XCTFail("Should have caught an error.")
+  }
+
+  func testGenerateContentStream_failureUnknownFinishEnum() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "streaming-failure-unknown-finish-enum",
+        withExtension: "txt"
+      )
+
+    let stream = model.generateContentStream("Hi")
+    do {
+      for try await content in stream {
+        XCTAssertNotNil(content.text)
+      }
+    } catch let GenerateContentError.responseStoppedEarly(reason, _) {
+      XCTAssertEqual(reason, .unknown)
+      return
+    }
+
+    XCTFail("Should have caught an error.")
+  }
+
+  func testGenerateContentStream_successBasicReplyLong() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "streaming-success-basic-reply-long",
+        withExtension: "txt"
+      )
+
+    var responses = 0
+    let stream = model.generateContentStream("Hi")
+    for try await content in stream {
+      XCTAssertNotNil(content.text)
+      responses += 1
+    }
+
+    XCTAssertEqual(responses, 8)
+  }
+
+  func testGenerateContentStream_successBasicReplyShort() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "streaming-success-basic-reply-short",
+        withExtension: "txt"
+      )
+
+    var responses = 0
+    let stream = model.generateContentStream("Hi")
+    for try await content in stream {
+      XCTAssertNotNil(content.text)
+      responses += 1
+    }
+
+    XCTAssertEqual(responses, 1)
+  }
+
+  func testGenerateContentStream_successUnknownSafetyEnum() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "streaming-success-unknown-safety-enum",
+        withExtension: "txt"
+      )
+
+    var hadUnknown = false
+    let stream = model.generateContentStream("Hi")
+    for try await content in stream {
+      XCTAssertNotNil(content.text)
+      if let ratings = content.candidates.first?.safetyRatings,
+         ratings.contains(where: { $0.category == .unknown }) {
+        hadUnknown = true
+      }
+    }
+
+    XCTAssertTrue(hadUnknown)
+  }
+
+  func testGenerateContentStream_successWithCitations() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "streaming-success-citations",
+        withExtension: "txt"
+      )
+
+    let stream = model.generateContentStream("Hi")
+    var citations = [Citation]()
+    var responses = [GenerateContentResponse]()
+    for try await content in stream {
+      responses.append(content)
+      XCTAssertNotNil(content.text)
+      let candidate = try XCTUnwrap(content.candidates.first)
+      if let sources = candidate.citationMetadata?.citationSources {
+        citations.append(contentsOf: sources)
+      }
+    }
+
+    let lastCandidate = try XCTUnwrap(responses.last?.candidates.first)
+    XCTAssertEqual(lastCandidate.finishReason, .stop)
+    XCTAssertEqual(citations.count, 3)
+    XCTAssertTrue(citations
+      .contains(where: {
+        $0.startIndex == 0 && $0.endIndex == 128 && !$0.uri.isEmpty && $0.license == nil
+      }))
+    XCTAssertTrue(citations
+      .contains(where: {
+        $0.startIndex == 130 && $0.endIndex == 265 && !$0.uri.isEmpty && $0.license == nil
+      }))
+    XCTAssertTrue(citations
+      .contains(where: {
+        $0.startIndex == 272 && $0.endIndex == 431 && !$0.uri.isEmpty && $0.license == "mit"
+      }))
+  }
+
+  func testGenerateContentStream_appCheck_validToken() async throws {
+    let appCheckToken = "test-valid-token"
+    model = GenerativeModel(
+      name: "my-model",
+      projectID: "my-project-id",
+      apiKey: "API_KEY",
+      tools: nil,
+      requestOptions: RequestOptions(),
+      appCheck: AppCheckInteropFake(token: appCheckToken),
+      auth: nil,
+      urlSession: urlSession
+    )
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "streaming-success-basic-reply-short",
+        withExtension: "txt",
+        appCheckToken: appCheckToken
+      )
+
+    let stream = model.generateContentStream(testPrompt)
+    for try await _ in stream {}
+  }
+
+  func testGenerateContentStream_appCheck_tokenRefreshError() async throws {
+    model = GenerativeModel(
+      name: "my-model",
+      projectID: "my-project-id",
+      apiKey: "API_KEY",
+      tools: nil,
+      requestOptions: RequestOptions(),
+      appCheck: AppCheckInteropFake(error: AppCheckErrorFake()),
+      auth: nil,
+      urlSession: urlSession
+    )
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "streaming-success-basic-reply-short",
+        withExtension: "txt",
+        appCheckToken: AppCheckInteropFake.placeholderTokenValue
+      )
+
+    let stream = model.generateContentStream(testPrompt)
+    for try await _ in stream {}
+  }
+
+  func testGenerateContentStream_usageMetadata() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "streaming-success-basic-reply-short",
+        withExtension: "txt"
+      )
+    var responses = [GenerateContentResponse]()
+
+    let stream = model.generateContentStream(testPrompt)
+    for try await response in stream {
+      responses.append(response)
+    }
+
+    for (index, response) in responses.enumerated() {
+      if index == responses.endIndex - 1 {
+        let usageMetadata = try XCTUnwrap(response.usageMetadata)
+        XCTAssertEqual(usageMetadata.promptTokenCount, 6)
+        XCTAssertEqual(usageMetadata.candidatesTokenCount, 4)
+        XCTAssertEqual(usageMetadata.totalTokenCount, 10)
+      } else {
+        // Only the last streamed response contains usage metadata
+        XCTAssertNil(response.usageMetadata)
+      }
+    }
+  }
+
+  func testGenerateContentStream_errorMidStream() async throws {
+    MockURLProtocol.requestHandler = try httpRequestHandler(
+      forResource: "streaming-failure-error-mid-stream",
+      withExtension: "txt"
+    )
+
+    var responseCount = 0
+    do {
+      let stream = model.generateContentStream("Hi")
+      for try await content in stream {
+        XCTAssertNotNil(content.text)
+        responseCount += 1
+      }
+    } catch let GenerateContentError.internalError(rpcError as RPCError) {
+      XCTAssertEqual(rpcError.httpResponseCode, 499)
+      XCTAssertEqual(rpcError.status, .cancelled)
+
+      // Check the content count is correct.
+      XCTAssertEqual(responseCount, 2)
+      return
+    }
+
+    XCTFail("Expected an internalError with an RPCError.")
+  }
+
+  func testGenerateContentStream_nonHTTPResponse() async throws {
+    MockURLProtocol.requestHandler = try nonHTTPRequestHandler()
+
+    let stream = model.generateContentStream("Hi")
+    do {
+      for try await content in stream {
+        XCTFail("Unexpected content in stream: \(content)")
+      }
+    } catch let GenerateContentError.internalError(underlying) {
+      XCTAssertEqual(underlying.localizedDescription, "Response was not an HTTP response.")
+      return
+    }
+
+    XCTFail("Expected an internal error.")
+  }
+
+  func testGenerateContentStream_invalidResponse() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "streaming-failure-invalid-json",
+        withExtension: "txt"
+      )
+
+    let stream = model.generateContentStream(testPrompt)
+    do {
+      for try await content in stream {
+        XCTFail("Unexpected content in stream: \(content)")
+      }
+    } catch let GenerateContentError.internalError(underlying as DecodingError) {
+      guard case let .dataCorrupted(context) = underlying else {
+        XCTFail("Not a data corrupted error: \(underlying)")
+        return
+      }
+      XCTAssert(context.debugDescription.hasPrefix("Failed to decode GenerateContentResponse"))
+      return
+    }
+
+    XCTFail("Expected an internal error.")
+  }
+
+  func testGenerateContentStream_malformedContent() async throws {
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "streaming-failure-malformed-content",
+        withExtension: "txt"
+      )
+
+    let stream = model.generateContentStream(testPrompt)
+    do {
+      for try await content in stream {
+        XCTFail("Unexpected content in stream: \(content)")
+      }
+    } catch let GenerateContentError.internalError(underlyingError as InvalidCandidateError) {
+      guard case let .malformedContent(contentError) = underlyingError else {
+        XCTFail("Not a malformed content error: \(underlyingError)")
+        return
+      }
+
+      XCTAssert(contentError is DecodingError)
+      return
+    }
+
+    XCTFail("Expected an internal decoding error.")
+  }
+
+  func testGenerateContentStream_requestOptions_customTimeout() async throws {
+    let expectedTimeout = 150.0
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "streaming-success-basic-reply-short",
+        withExtension: "txt",
+        timeout: expectedTimeout
+      )
+    let requestOptions = RequestOptions(timeout: expectedTimeout)
+    model = GenerativeModel(
+      name: "my-model",
+      projectID: "my-project-id",
+      apiKey: "API_KEY",
+      tools: nil,
+      requestOptions: requestOptions,
+      appCheck: nil,
+      auth: nil,
+      urlSession: urlSession
+    )
+
+    var responses = 0
+    let stream = model.generateContentStream(testPrompt)
+    for try await content in stream {
+      XCTAssertNotNil(content.text)
+      responses += 1
+    }
+
+    XCTAssertEqual(responses, 1)
+  }
+
+  // MARK: - Count Tokens
+
+  func testCountTokens_succeeds() async throws {
+    MockURLProtocol.requestHandler = try httpRequestHandler(
+      forResource: "success-total-tokens",
+      withExtension: "json"
+    )
+
+    let response = try await model.countTokens("Why is the sky blue?")
+
+    XCTAssertEqual(response.totalTokens, 6)
+    XCTAssertEqual(response.totalBillableCharacters, 16)
+  }
+
+  func testCountTokens_succeeds_noBillableCharacters() async throws {
+    MockURLProtocol.requestHandler = try httpRequestHandler(
+      forResource: "success-no-billable-characters",
+      withExtension: "json"
+    )
+
+    let response = try await model.countTokens(ModelContent.Part.data(
+      mimetype: "image/jpeg",
+      Data()
+    ))
+
+    XCTAssertEqual(response.totalTokens, 258)
+    XCTAssertEqual(response.totalBillableCharacters, 0)
+  }
+
+  func testCountTokens_modelNotFound() async throws {
+    MockURLProtocol.requestHandler = try httpRequestHandler(
+      forResource: "failure-model-not-found", withExtension: "json",
+      statusCode: 404
+    )
+
+    do {
+      _ = try await model.countTokens("Why is the sky blue?")
+      XCTFail("Request should not have succeeded.")
+    } catch let CountTokensError.internalError(rpcError as RPCError) {
+      XCTAssertEqual(rpcError.httpResponseCode, 404)
+      XCTAssertEqual(rpcError.status, .notFound)
+      XCTAssert(rpcError.message.hasPrefix("models/test-model-name is not found"))
+      return
+    }
+
+    XCTFail("Expected internal RPCError.")
+  }
+
+  func testCountTokens_requestOptions_customTimeout() async throws {
+    let expectedTimeout = 150.0
+    MockURLProtocol
+      .requestHandler = try httpRequestHandler(
+        forResource: "success-total-tokens",
+        withExtension: "json",
+        timeout: expectedTimeout
+      )
+    let requestOptions = RequestOptions(timeout: expectedTimeout)
+    model = GenerativeModel(
+      name: "my-model",
+      projectID: "my-project-id",
+      apiKey: "API_KEY",
+      tools: nil,
+      requestOptions: requestOptions,
+      appCheck: nil,
+      auth: nil,
+      urlSession: urlSession
+    )
+
+    let response = try await model.countTokens(testPrompt)
+
+    XCTAssertEqual(response.totalTokens, 6)
+  }
+
+  // MARK: - Model Resource Name
+
+  func testModelResourceName_noPrefix() async throws {
+    let modelName = "my-model"
+    let modelResourceName = "models/\(modelName)"
+
+    model = GenerativeModel(
+      name: modelName,
+      projectID: "my-project-id",
+      apiKey: "API_KEY",
+      tools: nil,
+      requestOptions: RequestOptions(),
+      appCheck: nil,
+      auth: nil
+    )
+
+    XCTAssertEqual(model.modelResourceName, modelResourceName)
+  }
+
+  func testModelResourceName_modelsPrefix() async throws {
+    let modelResourceName = "models/my-model"
+
+    model = GenerativeModel(
+      name: modelResourceName,
+      projectID: "my-project-id",
+      apiKey: "API_KEY",
+      tools: nil,
+      requestOptions: RequestOptions(),
+      appCheck: nil,
+      auth: nil
+    )
+
+    XCTAssertEqual(model.modelResourceName, modelResourceName)
+  }
+
+  func testModelResourceName_tunedModelsPrefix() async throws {
+    let tunedModelResourceName = "tunedModels/my-model"
+
+    model = GenerativeModel(
+      name: tunedModelResourceName,
+      projectID: "my-project-id",
+      apiKey: "API_KEY",
+      tools: nil,
+      requestOptions: RequestOptions(),
+      appCheck: nil,
+      auth: nil
+    )
+
+    XCTAssertEqual(model.modelResourceName, tunedModelResourceName)
+  }
+
+  // MARK: - Helpers
+
+  private func nonHTTPRequestHandler() throws -> ((URLRequest) -> (
+    URLResponse,
+    AsyncLineSequence<URL.AsyncBytes>?
+  )) {
+    return { request in
+      // This is *not* an HTTPURLResponse
+      let response = URLResponse(
+        url: request.url!,
+        mimeType: nil,
+        expectedContentLength: 0,
+        textEncodingName: nil
+      )
+      return (response, nil)
+    }
+  }
+
+  private func httpRequestHandler(forResource name: String,
+                                  withExtension ext: String,
+                                  statusCode: Int = 200,
+                                  timeout: TimeInterval = URLRequest.defaultTimeoutInterval(),
+                                  appCheckToken: String? = nil,
+                                  authToken: String? = nil) throws -> ((URLRequest) throws -> (
+    URLResponse,
+    AsyncLineSequence<URL.AsyncBytes>?
+  )) {
+    let fileURL = try XCTUnwrap(Bundle.module.url(forResource: name, withExtension: ext))
+    return { request in
+      let requestURL = try XCTUnwrap(request.url)
+      XCTAssertEqual(requestURL.path.occurrenceCount(of: "models/"), 1)
+      XCTAssertEqual(request.timeoutInterval, timeout)
+      let apiClientTags = try XCTUnwrap(request.value(forHTTPHeaderField: "x-goog-api-client"))
+        .components(separatedBy: " ")
+      XCTAssert(apiClientTags.contains(GenerativeAIService.languageTag))
+      XCTAssert(apiClientTags.contains(GenerativeAIService.firebaseVersionTag))
+      XCTAssertEqual(request.value(forHTTPHeaderField: "X-Firebase-AppCheck"), appCheckToken)
+      if let authToken {
+        XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Firebase \(authToken)")
+      } else {
+        XCTAssertNil(request.value(forHTTPHeaderField: "Authorization"))
+      }
+      let response = try XCTUnwrap(HTTPURLResponse(
+        url: requestURL,
+        statusCode: statusCode,
+        httpVersion: nil,
+        headerFields: nil
+      ))
+      return (response, fileURL.lines)
+    }
+  }
+}
+
+private extension String {
+  /// Returns the number of occurrences of `substring` in the `String`.
+  func occurrenceCount(of substring: String) -> Int {
+    return components(separatedBy: substring).count - 1
+  }
+}
+
+private extension URLRequest {
+  /// Returns the default `timeoutInterval` for a `URLRequest`.
+  static func defaultTimeoutInterval() -> TimeInterval {
+    let placeholderURL = URL(string: "https://example.com")!
+    return URLRequest(url: placeholderURL).timeoutInterval
+  }
+}
+
+@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, *)
+class AppCheckInteropFake: NSObject, AppCheckInterop {
+  /// The placeholder token value returned when an error occurs
+  static let placeholderTokenValue = "placeholder-token"
+
+  var token: String
+  var error: Error?
+
+  private init(token: String, error: Error?) {
+    self.token = token
+    self.error = error
+  }
+
+  convenience init(token: String) {
+    self.init(token: token, error: nil)
+  }
+
+  convenience init(error: Error) {
+    self.init(token: AppCheckInteropFake.placeholderTokenValue, error: error)
+  }
+
+  func getToken(forcingRefresh: Bool) async -> any FIRAppCheckTokenResultInterop {
+    return AppCheckTokenResultInteropFake(token: token, error: error)
+  }
+
+  func tokenDidChangeNotificationName() -> String {
+    fatalError("\(#function) not implemented.")
+  }
+
+  func notificationTokenKey() -> String {
+    fatalError("\(#function) not implemented.")
+  }
+
+  func notificationAppNameKey() -> String {
+    fatalError("\(#function) not implemented.")
+  }
+
+  private class AppCheckTokenResultInteropFake: NSObject, FIRAppCheckTokenResultInterop {
+    var token: String
+    var error: Error?
+
+    init(token: String, error: Error?) {
+      self.token = token
+      self.error = error
+    }
+  }
+}
+
+struct AppCheckErrorFake: Error {}
+
+@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, *)
+extension SafetyRating: Comparable {
+  public static func < (lhs: FirebaseVertexAI.SafetyRating,
+                        rhs: FirebaseVertexAI.SafetyRating) -> Bool {
+    return lhs.category.rawValue < rhs.category.rawValue
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/JSONValueTests.swift b/FirebaseVertexAI/Tests/Unit/JSONValueTests.swift
new file mode 100644
index 00000000000..74196230887
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/JSONValueTests.swift
@@ -0,0 +1,145 @@
+// Copyright 2024 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 XCTest
+
+@testable import FirebaseVertexAI
+
+final class JSONValueTests: XCTestCase {
+  let decoder = JSONDecoder()
+  let encoder = JSONEncoder()
+
+  let numberKey = "pi"
+  let numberValue = 3.14159
+  let numberValueEncoded = "3.14159"
+  let stringKey = "hello"
+  let stringValue = "Hello, world!"
+
+  override func setUp() {
+    encoder.outputFormatting = .sortedKeys
+  }
+
+  func testDecodeNull() throws {
+    let jsonData = try XCTUnwrap("null".data(using: .utf8))
+
+    let jsonObject = try XCTUnwrap(decoder.decode(JSONValue.self, from: jsonData))
+
+    XCTAssertEqual(jsonObject, .null)
+  }
+
+  func testDecodeNumber() throws {
+    let jsonData = try XCTUnwrap("\(numberValue)".data(using: .utf8))
+
+    let jsonObject = try XCTUnwrap(decoder.decode(JSONValue.self, from: jsonData))
+
+    XCTAssertEqual(jsonObject, .number(numberValue))
+  }
+
+  func testDecodeString() throws {
+    let jsonData = try XCTUnwrap("\"\(stringValue)\"".data(using: .utf8))
+
+    let jsonObject = try XCTUnwrap(decoder.decode(JSONValue.self, from: jsonData))
+
+    XCTAssertEqual(jsonObject, .string(stringValue))
+  }
+
+  func testDecodeBool() throws {
+    let expectedBool = true
+    let jsonData = try XCTUnwrap("\(expectedBool)".data(using: .utf8))
+
+    let jsonObject = try XCTUnwrap(decoder.decode(JSONValue.self, from: jsonData))
+
+    XCTAssertEqual(jsonObject, .bool(expectedBool))
+  }
+
+  func testDecodeObject() throws {
+    let expectedObject: JSONObject = [
+      numberKey: .number(numberValue),
+      stringKey: .string(stringValue),
+    ]
+    let json = """
+    {
+      "\(numberKey)": \(numberValue),
+      "\(stringKey)": "\(stringValue)"
+    }
+    """
+    let jsonData = try XCTUnwrap(json.data(using: .utf8))
+
+    let jsonObject = try XCTUnwrap(decoder.decode(JSONValue.self, from: jsonData))
+
+    XCTAssertEqual(jsonObject, .object(expectedObject))
+  }
+
+  func testDecodeArray() throws {
+    let expectedArray: [JSONValue] = [.null, .number(numberValue)]
+    let jsonData = try XCTUnwrap("[ null, \(numberValue) ]".data(using: .utf8))
+
+    let jsonObject = try XCTUnwrap(decoder.decode(JSONValue.self, from: jsonData))
+
+    XCTAssertEqual(jsonObject, .array(expectedArray))
+  }
+
+  func testEncodeNull() throws {
+    let jsonData = try encoder.encode(JSONValue.null)
+
+    let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
+    XCTAssertEqual(json, "null")
+  }
+
+  func testEncodeNumber() throws {
+    let jsonData = try encoder.encode(JSONValue.number(numberValue))
+
+    let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
+    XCTAssertEqual(json, "\(numberValue)")
+  }
+
+  func testEncodeString() throws {
+    let jsonData = try encoder.encode(JSONValue.string(stringValue))
+
+    let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
+    XCTAssertEqual(json, "\"\(stringValue)\"")
+  }
+
+  func testEncodeBool() throws {
+    let boolValue = true
+
+    let jsonData = try encoder.encode(JSONValue.bool(boolValue))
+
+    let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
+    XCTAssertEqual(json, "\(boolValue)")
+  }
+
+  func testEncodeObject() throws {
+    let objectValue: JSONObject = [
+      numberKey: .number(numberValue),
+      stringKey: .string(stringValue),
+    ]
+
+    let jsonData = try encoder.encode(JSONValue.object(objectValue))
+
+    let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
+    XCTAssertEqual(
+      json,
+      "{\"\(stringKey)\":\"\(stringValue)\",\"\(numberKey)\":\(numberValueEncoded)}"
+    )
+  }
+
+  func testEncodeArray() throws {
+    let arrayValue: [JSONValue] = [.null, .number(numberValue)]
+
+    let jsonData = try encoder.encode(JSONValue.array(arrayValue))
+
+    let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
+    XCTAssertEqual(json, "[null,\(numberValueEncoded)]")
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/MockURLProtocol.swift b/FirebaseVertexAI/Tests/Unit/MockURLProtocol.swift
new file mode 100644
index 00000000000..30528cdf35a
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/MockURLProtocol.swift
@@ -0,0 +1,62 @@
+// Copyright 2023 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 Foundation
+import XCTest
+
+@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, *)
+class MockURLProtocol: URLProtocol {
+  static var requestHandler: ((URLRequest) throws -> (
+    URLResponse,
+    AsyncLineSequence<URL.AsyncBytes>?
+  ))?
+
+  override class func canInit(with request: URLRequest) -> Bool { return true }
+
+  override class func canonicalRequest(for request: URLRequest) -> URLRequest { return request }
+
+  override func startLoading() {
+    guard let requestHandler = MockURLProtocol.requestHandler else {
+      fatalError("`requestHandler` is nil.")
+    }
+    guard let client = client else {
+      fatalError("`client` is nil.")
+    }
+
+    Task {
+      let (response, stream) = try requestHandler(self.request)
+      client.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+      if let stream = stream {
+        do {
+          for try await line in stream {
+            guard let data = line.data(using: .utf8) else {
+              fatalError("Failed to convert \"\(line)\" to UTF8 data.")
+            }
+            client.urlProtocol(self, didLoad: data)
+            // Add a newline character since AsyncLineSequence strips them when reading line by
+            // line;
+            // without the following, the whole file is delivered as a single line.
+            client.urlProtocol(self, didLoad: "\n".data(using: .utf8)!)
+          }
+        } catch {
+          client.urlProtocol(self, didFailWithError: error)
+          XCTFail("Unexpected failure reading lines from stream: \(error.localizedDescription)")
+        }
+      }
+      client.urlProtocolDidFinishLoading(self)
+    }
+  }
+
+  override func stopLoading() {}
+}
diff --git a/FirebaseVertexAI/Tests/Unit/ModelContentTests.swift b/FirebaseVertexAI/Tests/Unit/ModelContentTests.swift
new file mode 100644
index 00000000000..3c252fb04f5
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/ModelContentTests.swift
@@ -0,0 +1,49 @@
+// Copyright 2024 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 Foundation
+import XCTest
+
+@testable import FirebaseVertexAI
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+final class ModelContentTests: XCTestCase {
+  let encoder = JSONEncoder()
+
+  override func setUp() {
+    encoder.outputFormatting = .init(
+      arrayLiteral: .prettyPrinted, .sortedKeys, .withoutEscapingSlashes
+    )
+  }
+
+  // MARK: ModelContent.Part Encoding
+
+  func testEncodeFileDataPart() throws {
+    let mimeType = "image/jpeg"
+    let fileURI = "gs://test-bucket/image.jpg"
+    let fileDataPart = ModelContent.Part.fileData(mimetype: mimeType, uri: fileURI)
+
+    let jsonData = try encoder.encode(fileDataPart)
+
+    let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8))
+    XCTAssertEqual(json, """
+    {
+      "fileData" : {
+        "file_uri" : "\(fileURI)",
+        "mime_type" : "\(mimeType)"
+      }
+    }
+    """)
+  }
+}
diff --git a/FirebaseVertexAI/Tests/Unit/PartsRepresentableTests.swift b/FirebaseVertexAI/Tests/Unit/PartsRepresentableTests.swift
new file mode 100644
index 00000000000..4243c2441be
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/PartsRepresentableTests.swift
@@ -0,0 +1,138 @@
+// Copyright 2024 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 CoreGraphics
+import CoreImage
+import FirebaseVertexAI
+import XCTest
+#if canImport(UIKit)
+  import UIKit
+#else
+  import AppKit
+#endif
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+final class PartsRepresentableTests: XCTestCase {
+  func testModelContentFromCGImageIsNotEmpty() throws {
+    // adapted from https://forums.swift.org/t/creating-a-cgimage-from-color-array/18634/2
+    var srgbArray = [UInt32](repeating: 0xFFFF_FFFF, count: 8 * 8)
+    let image = srgbArray.withUnsafeMutableBytes { ptr -> CGImage in
+      let ctx = CGContext(
+        data: ptr.baseAddress,
+        width: 8,
+        height: 8,
+        bitsPerComponent: 8,
+        bytesPerRow: 4 * 8,
+        space: CGColorSpace(name: CGColorSpace.sRGB)!,
+        bitmapInfo: CGBitmapInfo.byteOrder32Little.rawValue +
+          CGImageAlphaInfo.premultipliedFirst.rawValue
+      )!
+      return ctx.makeImage()!
+    }
+    let modelContent = try image.tryPartsValue()
+    XCTAssert(modelContent.count > 0, "Expected non-empty model content for CGImage: \(image)")
+  }
+
+  func testModelContentFromCIImageIsNotEmpty() throws {
+    let image = CIImage(color: CIColor.red)
+      .cropped(to: CGRect(origin: CGPointZero, size: CGSize(width: 16, height: 16)))
+    let modelContent = try image.tryPartsValue()
+    XCTAssert(modelContent.count > 0, "Expected non-empty model content for CGImage: \(image)")
+  }
+
+  func testModelContentFromInvalidCIImageThrows() throws {
+    let image = CIImage.empty()
+    do {
+      let _ = try image.tryPartsValue()
+    } catch {
+      guard let imageError = (error as? ImageConversionError) else {
+        XCTFail("Got unexpected error type: \(error)")
+        return
+      }
+      switch imageError {
+      case let .couldNotConvertToJPEG(source):
+        // String(describing:) works around a type error.
+        XCTAssertEqual(String(describing: source), String(describing: image))
+        return
+      case _:
+        XCTFail("Expected image conversion error, got \(imageError) instead")
+        return
+      }
+    }
+    XCTFail("Expected model content from invlaid image to error")
+  }
+
+  #if canImport(UIKit)
+    func testModelContentFromInvalidUIImageThrows() throws {
+      let image = UIImage()
+      do {
+        _ = try image.tryPartsValue()
+      } catch {
+        guard let imageError = (error as? ImageConversionError) else {
+          XCTFail("Got unexpected error type: \(error)")
+          return
+        }
+        switch imageError {
+        case let .couldNotConvertToJPEG(source):
+          // String(describing:) works around a type error.
+          XCTAssertEqual(String(describing: source), String(describing: image))
+          return
+        case _:
+          XCTFail("Expected image conversion error, got \(imageError) instead")
+          return
+        }
+      }
+      XCTFail("Expected model content from invlaid image to error")
+    }
+
+    func testModelContentFromUIImageIsNotEmpty() throws {
+      let coreImage = CIImage(color: CIColor.red)
+        .cropped(to: CGRect(origin: CGPointZero, size: CGSize(width: 16, height: 16)))
+      let image = UIImage(ciImage: coreImage)
+      let modelContent = try image.tryPartsValue()
+      XCTAssert(modelContent.count > 0, "Expected non-empty model content for UIImage: \(image)")
+    }
+  #else
+    func testModelContentFromNSImageIsNotEmpty() throws {
+      let coreImage = CIImage(color: CIColor.red)
+        .cropped(to: CGRect(origin: CGPointZero, size: CGSize(width: 16, height: 16)))
+      let rep = NSCIImageRep(ciImage: coreImage)
+      let image = NSImage(size: rep.size)
+      image.addRepresentation(rep)
+      let modelContent = try image.tryPartsValue()
+      XCTAssert(modelContent.count > 0, "Expected non-empty model content for NSImage: \(image)")
+    }
+
+    func testModelContentFromInvalidNSImageThrows() throws {
+      let image = NSImage()
+      do {
+        _ = try image.tryPartsValue()
+      } catch {
+        guard let imageError = (error as? ImageConversionError) else {
+          XCTFail("Got unexpected error type: \(error)")
+          return
+        }
+        switch imageError {
+        case .invalidUnderlyingImage:
+          // Pass
+          return
+        case _:
+          XCTFail("Expected image conversion error, got \(imageError) instead")
+          return
+        }
+      }
+      XCTFail("Expected model content from invlaid image to error")
+    }
+  #endif
+}
diff --git a/FirebaseVertexAI/Tests/Unit/VertexAIAPITests.swift b/FirebaseVertexAI/Tests/Unit/VertexAIAPITests.swift
new file mode 100644
index 00000000000..76a649d964d
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/VertexAIAPITests.swift
@@ -0,0 +1,225 @@
+// Copyright 2023 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 FirebaseCore
+import FirebaseVertexAI
+import XCTest
+#if canImport(AppKit)
+  import AppKit // For NSImage extensions.
+#elseif canImport(UIKit)
+  import UIKit // For UIImage extensions.
+#endif
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+final class VertexAIAPITests: XCTestCase {
+  func codeSamples() async throws {
+    let app = FirebaseApp.app()
+    let config = GenerationConfig(temperature: 0.2,
+                                  topP: 0.1,
+                                  topK: 16,
+                                  candidateCount: 4,
+                                  maxOutputTokens: 256,
+                                  stopSequences: ["..."],
+                                  responseMIMEType: "text/plain")
+    let filters = [SafetySetting(harmCategory: .dangerousContent, threshold: .blockOnlyHigh)]
+    let systemInstruction = ModelContent(role: "system", parts: [.text("Talk like a pirate.")])
+
+    // Instantiate Vertex AI SDK - Default App
+    let vertexAI = VertexAI.vertexAI()
+    let _ = VertexAI.vertexAI(location: "my-location")
+
+    // Instantiate Vertex AI SDK - Custom App
+    let _ = VertexAI.vertexAI(app: app!)
+    let _ = VertexAI.vertexAI(app: app!, location: "my-location")
+
+    // Permutations without optional arguments.
+
+    let _ = vertexAI.generativeModel(modelName: "gemini-1.0-pro")
+
+    let _ = vertexAI.generativeModel(
+      modelName: "gemini-1.0-pro",
+      safetySettings: filters
+    )
+
+    let _ = vertexAI.generativeModel(
+      modelName: "gemini-1.0-pro",
+      generationConfig: config
+    )
+
+    let _ = vertexAI.generativeModel(
+      modelName: "gemini-1.0-pro",
+      systemInstruction: systemInstruction
+    )
+
+    // All arguments passed.
+    let genAI = vertexAI.generativeModel(
+      modelName: "gemini-1.0-pro",
+      generationConfig: config, // Optional
+      safetySettings: filters, // Optional
+      systemInstruction: systemInstruction // Optional
+    )
+
+    // Full Typed Usage
+    let pngData = Data() // ....
+    let contents = [ModelContent(role: "user",
+                                 parts: [
+                                   .text("Is it a cat?"),
+                                   .png(pngData),
+                                 ])]
+
+    do {
+      let response = try await genAI.generateContent(contents)
+      print(response.text ?? "Couldn't get text... check status")
+    } catch {
+      print("Error generating content: \(error)")
+    }
+
+    // Content input combinations.
+    let _ = try await genAI.generateContent("Constant String")
+    let str = "String Variable"
+    let _ = try await genAI.generateContent(str)
+    let _ = try await genAI.generateContent([str])
+    let _ = try await genAI.generateContent(str, "abc", "def")
+    let _ = try await genAI.generateContent(
+      str,
+      ModelContent.Part.fileData(mimetype: "image/jpeg", uri: "gs://test-bucket/image.jpg")
+    )
+    #if canImport(UIKit)
+      _ = try await genAI.generateContent(UIImage())
+      _ = try await genAI.generateContent([UIImage()])
+      _ = try await genAI
+        .generateContent([str, UIImage(), ModelContent.Part.text(str)])
+      _ = try await genAI.generateContent(str, UIImage(), "def", UIImage())
+      _ = try await genAI.generateContent([str, UIImage(), "def", UIImage()])
+      _ = try await genAI.generateContent([ModelContent("def", UIImage()),
+                                           ModelContent("def", UIImage())])
+    #elseif canImport(AppKit)
+      _ = try await genAI.generateContent(NSImage())
+      _ = try await genAI.generateContent([NSImage()])
+      _ = try await genAI.generateContent(str, NSImage(), "def", NSImage())
+      _ = try await genAI.generateContent([str, NSImage(), "def", NSImage()])
+    #endif
+
+    // ThrowingPartsRepresentable combinations.
+    let _ = ModelContent(parts: [.text(str)])
+    let _ = ModelContent(role: "model", parts: [.text(str)])
+    let _ = ModelContent(parts: "Constant String")
+    let _ = ModelContent(parts: str)
+    // Note: This requires the `try` for some reason. Casting to explicit [PartsRepresentable] also
+    // doesn't work.
+    let _ = try ModelContent(parts: [str])
+    // Note: without `as [any ThrowingPartsRepresentable]` this will fail to compile with "Cannot
+    // convert value of type 'String' to expected element type
+    // 'Array<ModelContent.Part>.ArrayLiteralElement'. Not sure if there's a way we can get it to
+    // work.
+    let _ = try ModelContent(parts: [str, ModelContent.Part.data(
+      mimetype: "foo",
+      Data()
+    )] as [any ThrowingPartsRepresentable])
+    #if canImport(UIKit)
+      _ = try ModelContent(role: "user", parts: UIImage())
+      _ = try ModelContent(role: "user", parts: [UIImage()])
+      // Note: without `as [any ThrowingPartsRepresentable]` this will fail to compile with "Cannot
+      // convert
+      // value of type `[Any]` to expected type `[any ThrowingPartsRepresentable]`. Not sure if
+      // there's a
+      // way we can get it to work.
+      _ = try ModelContent(parts: [str, UIImage()] as [any ThrowingPartsRepresentable])
+      // Alternatively, you can explicitly declare the type in a variable and pass it in.
+      let representable2: [any ThrowingPartsRepresentable] = [str, UIImage()]
+      _ = try ModelContent(parts: representable2)
+      _ = try ModelContent(parts: [str, UIImage(),
+                                   ModelContent.Part.text(str)] as [any ThrowingPartsRepresentable])
+    #elseif canImport(AppKit)
+      _ = try ModelContent(role: "user", parts: NSImage())
+      _ = try ModelContent(role: "user", parts: [NSImage()])
+      // Note: without `as [any ThrowingPartsRepresentable]` this will fail to compile with "Cannot
+      // convert
+      // value of type `[Any]` to expected type `[any ThrowingPartsRepresentable]`. Not sure if
+      // there's a
+      // way we can get it to work.
+      _ = try ModelContent(parts: [str, NSImage()] as [any ThrowingPartsRepresentable])
+      // Alternatively, you can explicitly declare the type in a variable and pass it in.
+      let representable2: [any ThrowingPartsRepresentable] = [str, NSImage()]
+      _ = try ModelContent(parts: representable2)
+      _ =
+        try ModelContent(parts: [str, NSImage(),
+                                 ModelContent.Part.text(str)] as [any ThrowingPartsRepresentable])
+    #endif
+
+    // countTokens API
+    let _: CountTokensResponse = try await genAI.countTokens("What color is the Sky?")
+    #if canImport(UIKit)
+      let _: CountTokensResponse = try await genAI.countTokens("What color is the Sky?",
+                                                               UIImage())
+      let _: CountTokensResponse = try await genAI.countTokens([
+        ModelContent("What color is the Sky?", UIImage()),
+        ModelContent(UIImage(), "What color is the Sky?", UIImage()),
+      ])
+    #endif
+
+    // Chat
+    _ = genAI.startChat()
+    _ = genAI.startChat(history: [ModelContent(parts: "abc")])
+  }
+
+  // Public API tests for GenerateContentResponse.
+  func generateContentResponseAPI() {
+    let response = GenerateContentResponse(candidates: [])
+
+    let _: [CandidateResponse] = response.candidates
+    let _: PromptFeedback? = response.promptFeedback
+
+    // Usage Metadata
+    guard let usageMetadata = response.usageMetadata else { fatalError() }
+    let _: Int = usageMetadata.promptTokenCount
+    let _: Int = usageMetadata.candidatesTokenCount
+    let _: Int = usageMetadata.totalTokenCount
+
+    // Computed Properties
+    let _: String? = response.text
+    let _: [FunctionCall] = response.functionCalls
+  }
+
+  // Result builder alternative
+
+  /*
+   let pngData = Data() // ....
+   let contents = [GenAIContent(role: "user",
+                                parts: [
+                                 .text("Is it a cat?"),
+                                 .png(pngData)
+                                ])]
+
+   // Turns into...
+
+   let contents = GenAIContent {
+     Role("user") {
+       Text("Is this a cat?")
+       Image(png: pngData)
+     }
+   }
+
+   GenAIContent {
+     ForEach(myInput) { input in
+       Role(input.role) {
+         input.contents
+       }
+     }
+   }
+
+   // Thoughts: this looks great from a code demo, but since I assume most content will be
+   // user generated, the result builder may not be the best API.
+   */
+}
diff --git a/FirebaseVertexAI/Tests/Unit/VertexComponentTests.swift b/FirebaseVertexAI/Tests/Unit/VertexComponentTests.swift
new file mode 100644
index 00000000000..20f2b15bee8
--- /dev/null
+++ b/FirebaseVertexAI/Tests/Unit/VertexComponentTests.swift
@@ -0,0 +1,115 @@
+// Copyright 2024 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 Foundation
+
+import FirebaseCore
+@testable import FirebaseVertexAI
+
+import SharedTestUtilities
+
+import XCTest
+
+@available(iOS 15.0, macOS 11.0, macCatalyst 15.0, *)
+class VertexComponentTests: XCTestCase {
+  static var app: FirebaseApp!
+
+  override class func setUp() {
+    super.setUp()
+    if app == nil {
+      let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000",
+                                    gcmSenderID: "00000000000000000-00000000000-000000000")
+      options.projectID = "myProjectID"
+      FirebaseApp.configure(options: options)
+      app = FirebaseApp(instanceWithName: "test", options: options)
+    }
+  }
+
+  // MARK: Interoperability Tests
+
+  /// Tests that the right number of components are being provided for the container.
+  func testComponentsBeingRegistered() throws {
+    let components = VertexAIComponent.componentsToRegister()
+    XCTAssert(components.count == 1)
+  }
+
+  /// Tests that a vertex instance can be created properly by the VertexAIComponent.
+  func testVertexInstanceCreation() throws {
+    let app = try XCTUnwrap(VertexComponentTests.app)
+    let component = VertexAIComponent(app: app)
+    let vertex = component.vertexAI("my-location")
+    XCTAssertNotNil(vertex)
+  }
+
+  /// Tests that the component container caches instances of VertexAIComponent.
+  func testMultipleComponentInstancesCreated() throws {
+    let registrants = NSMutableSet(array: [VertexAIComponent.self])
+    let container = FirebaseComponentContainer(
+      app: VertexComponentTests.app,
+      registrants: registrants
+    )
+
+    let provider1 = ComponentType<VertexAIProvider>.instance(for: VertexAIProvider.self,
+                                                             in: container)
+    XCTAssertNotNil(provider1)
+
+    let provider2 = ComponentType<VertexAIProvider>.instance(for: VertexAIProvider.self,
+                                                             in: container)
+    XCTAssertNotNil(provider2)
+
+    // Ensure they're the same instance.
+    XCTAssert(provider1 === provider2)
+  }
+
+  /// Tests that instances of vertex created are different.
+  func testMultipleVertexInstancesCreated() throws {
+    let app = try XCTUnwrap(VertexComponentTests.app)
+    let registrants = NSMutableSet(array: [VertexAIComponent.self])
+    let container = FirebaseComponentContainer(app: app, registrants: registrants)
+
+    let provider = ComponentType<VertexAIProvider>.instance(for: VertexAIProvider.self,
+                                                            in: container)
+    XCTAssertNotNil(provider)
+
+    let vertex1 = provider?.vertexAI("randomLocation")
+    let vertex2 = provider?.vertexAI("randomLocation")
+    XCTAssertNotNil(vertex1)
+
+    // Ensure they're the same instance.
+    XCTAssert(vertex1 === vertex2)
+
+    let vertex3 = provider?.vertexAI("differentLocation")
+    XCTAssertNotNil(vertex3)
+
+    XCTAssert(vertex1 !== vertex3)
+  }
+
+  /// Test that vertex instances get deallocated.
+  func testVertexLifecycle() throws {
+    weak var weakApp: FirebaseApp?
+    weak var weakVertex: VertexAI?
+    try autoreleasepool {
+      let options = FirebaseOptions(googleAppID: "0:0000000000000:ios:0000000000000000",
+                                    gcmSenderID: "00000000000000000-00000000000-000000000")
+      options.projectID = "myProjectID"
+      let app1 = FirebaseApp(instanceWithName: "transitory app", options: options)
+      weakApp = try XCTUnwrap(app1)
+      let vertex = VertexAI(app: app1, location: "transitory location")
+      weakVertex = vertex
+      XCTAssertNotNil(weakVertex)
+    }
+    XCTAssertNil(weakApp)
+    XCTAssertNil(weakVertex)
+  }
+}
diff --git a/Firestore/core/src/remote/connectivity_monitor_apple.mm b/Firestore/core/src/remote/connectivity_monitor_apple.mm
index 3fccfc6ef16..282733cd405 100644
--- a/Firestore/core/src/remote/connectivity_monitor_apple.mm
+++ b/Firestore/core/src/remote/connectivity_monitor_apple.mm
@@ -18,8 +18,7 @@
 
 #if defined(__APPLE__)
 
-#if TARGET_OS_IOS || TARGET_OS_TV || \
-    (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
 #import <UIKit/UIKit.h>
 #endif
 
@@ -50,7 +49,7 @@ NetworkStatus ToNetworkStatus(SCNetworkReachabilityFlags flags) {
     return NetworkStatus::Unavailable;
   }
 
-#if TARGET_OS_IPHONE || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IPHONE || TARGET_OS_VISION
   if (flags & kSCNetworkReachabilityFlagsIsWWAN) {
     return NetworkStatus::AvailableViaCellular;
   }
@@ -113,8 +112,7 @@ explicit ConnectivityMonitorApple(
       return;
     }
 
-#if TARGET_OS_IOS || TARGET_OS_TV || \
-    (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
     this->observer_ = [[NSNotificationCenter defaultCenter]
         addObserverForName:UIApplicationWillEnterForegroundNotification
                     object:nil
@@ -126,8 +124,7 @@ explicit ConnectivityMonitorApple(
   }
 
   ~ConnectivityMonitorApple() {
-#if TARGET_OS_IOS || TARGET_OS_TV || \
-    (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
     [[NSNotificationCenter defaultCenter] removeObserver:this->observer_];
 #endif
 
@@ -142,8 +139,7 @@ explicit ConnectivityMonitorApple(
     }
   }
 
-#if TARGET_OS_IOS || TARGET_OS_TV || \
-    (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
   void OnEnteredForeground() {
     SCNetworkReachabilityFlags flags{};
     if (!SCNetworkReachabilityGetFlags(reachability_, &flags)) return;
@@ -171,8 +167,7 @@ void OnReachabilityChanged(SCNetworkReachabilityFlags flags) {
 
  private:
   SCNetworkReachabilityRef reachability_ = nil;
-#if TARGET_OS_IOS || TARGET_OS_TV || \
-    (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION
   id<NSObject> observer_ = nil;
 #endif
 };
diff --git a/Firestore/core/src/util/filesystem_apple.mm b/Firestore/core/src/util/filesystem_apple.mm
index 9802017a6f8..5a553476e8a 100644
--- a/Firestore/core/src/util/filesystem_apple.mm
+++ b/Firestore/core/src/util/filesystem_apple.mm
@@ -45,8 +45,7 @@
 }
 
 StatusOr<Path> Filesystem::AppDataDir(absl::string_view app_name) {
-#if TARGET_OS_IOS || TARGET_OS_OSX || \
-    (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_OSX || TARGET_OS_VISION
   NSArray<NSString*>* directories = NSSearchPathForDirectoriesInDomains(
       NSApplicationSupportDirectory, NSUserDomainMask, YES);
   return Path::FromNSString(directories[0]).AppendUtf8(app_name);
@@ -62,7 +61,7 @@
 }
 
 StatusOr<Path> Filesystem::LegacyDocumentsDir(absl::string_view app_name) {
-#if TARGET_OS_IOS || (defined(TARGET_OS_VISION) && TARGET_OS_VISION)
+#if TARGET_OS_IOS || TARGET_OS_VISION
   NSArray<NSString*>* directories = NSSearchPathForDirectoriesInDomains(
       NSDocumentDirectory, NSUserDomainMask, YES);
   return Path::FromNSString(directories[0]).AppendUtf8(app_name);
diff --git a/Gemfile.lock b/Gemfile.lock
index d09fdd38cce..6ccfe2cac25 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -143,12 +143,14 @@ GEM
     open4 (1.3.4)
     public_suffix (4.0.6)
     rchardet (1.8.0)
-    rexml (3.2.5)
+    rexml (3.2.8)
+      strscan (>= 3.0.9)
     ruby-macho (2.5.1)
     ruby2_keywords (0.0.5)
     sawyer (0.8.2)
       addressable (>= 2.3.5)
       faraday (> 0.8, < 2.0)
+    strscan (3.1.0)
     terminal-table (3.0.2)
       unicode-display_width (>= 1.1.1, < 3)
     typhoeus (1.4.1)
diff --git a/GoogleAppMeasurement.podspec b/GoogleAppMeasurement.podspec
index 86fb40d7dbf..915d098653b 100644
--- a/GoogleAppMeasurement.podspec
+++ b/GoogleAppMeasurement.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
     s.name             = 'GoogleAppMeasurement'
-    s.version          = '10.27.0'
+    s.version          = '10.28.0'
     s.summary          = 'Shared measurement methods for Google libraries. Not intended for direct use.'
 
     s.description      = <<-DESC
@@ -16,7 +16,7 @@ Pod::Spec.new do |s|
     s.authors          = 'Google, Inc.'
 
     s.source           = {
-        :http => 'https://dl.google.com/firebase/ios/analytics/e188a43fe59129de/GoogleAppMeasurement-10.25.0.tar.gz'
+        :http => 'https://dl.google.com/firebase/ios/analytics/b31a17a887da1647/GoogleAppMeasurement-10.27.0.tar.gz'
     }
 
     s.cocoapods_version = '>= 1.12.0'
@@ -37,7 +37,7 @@ Pod::Spec.new do |s|
     s.default_subspecs = 'AdIdSupport'
 
     s.subspec 'AdIdSupport' do |ss|
-        ss.dependency 'GoogleAppMeasurement/WithoutAdIdSupport', '10.27.0'
+        ss.dependency 'GoogleAppMeasurement/WithoutAdIdSupport', '10.28.0'
         ss.vendored_frameworks = 'Frameworks/GoogleAppMeasurementIdentitySupport.xcframework'
     end
 
diff --git a/GoogleAppMeasurementOnDeviceConversion.podspec b/GoogleAppMeasurementOnDeviceConversion.podspec
index 5af422da6ba..e5c399f8aa5 100644
--- a/GoogleAppMeasurementOnDeviceConversion.podspec
+++ b/GoogleAppMeasurementOnDeviceConversion.podspec
@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
     s.name             = 'GoogleAppMeasurementOnDeviceConversion'
-    s.version          = '10.27.0'
+    s.version          = '10.28.0'
     s.summary          = <<-SUMMARY
     On device conversion measurement plugin for Google App Measurement. Not
     intended for direct use.
@@ -17,7 +17,7 @@ Pod::Spec.new do |s|
     s.authors          = 'Google, Inc.'
 
     s.source           = {
-        :http => 'https://dl.google.com/firebase/ios/analytics/0ab6fae3eaa42752/GoogleAppMeasurementOnDeviceConversion-10.25.0.tar.gz'
+        :http => 'https://dl.google.com/firebase/ios/analytics/da68fd1ea340dbbd/GoogleAppMeasurementOnDeviceConversion-10.27.0.tar.gz'
     }
 
     s.cocoapods_version = '>= 1.12.0'
diff --git a/IntegrationTesting/CocoapodsIntegrationTest/TestEnvironments/Cocoapods_multiprojects_frameworks/Podfile b/IntegrationTesting/CocoapodsIntegrationTest/TestEnvironments/Cocoapods_multiprojects_frameworks/Podfile
index e45e8cd4908..cf2128071c6 100644
--- a/IntegrationTesting/CocoapodsIntegrationTest/TestEnvironments/Cocoapods_multiprojects_frameworks/Podfile
+++ b/IntegrationTesting/CocoapodsIntegrationTest/TestEnvironments/Cocoapods_multiprojects_frameworks/Podfile
@@ -3,7 +3,7 @@ source 'https://github.com/firebase/SpecsStaging.git'
 source 'https://cdn.cocoapods.org/'
 
 # Uncomment the next line to define a global platform for your project
-platform :ios, '11.0'
+platform :ios, '13.0'
 
 target 'CocoapodsIntegrationTest' do
   # Comment the next line if you don't want to use dynamic frameworks
diff --git a/Package.swift b/Package.swift
index 3dee49c3da8..f72d576286c 100644
--- a/Package.swift
+++ b/Package.swift
@@ -19,7 +19,7 @@
 import class Foundation.ProcessInfo
 import PackageDescription
 
-let firebaseVersion = "10.27.0"
+let firebaseVersion = "10.28.0"
 
 let package = Package(
   name: "Firebase",
@@ -137,6 +137,10 @@ let package = Package(
       name: "FirebaseStorage",
       targets: ["FirebaseStorage"]
     ),
+    .library(
+      name: "FirebaseVertexAI-Preview",
+      targets: ["FirebaseVertexAI"]
+    ),
   ],
   dependencies: [
     .package(
@@ -174,7 +178,6 @@ let package = Package(
       url: "https://github.com/paulb777/ocmock.git",
       revision: "173955e93e6ee6999a10729ab67e4b4efdd1db6d"
     ),
-//    .package(
 //      url: "https://github.com/erikdoe/ocmock.git",
 //      revision: "c5eeaa6dde7c308a5ce48ae4d4530462dd3a1110"
 //    ),
@@ -322,8 +325,8 @@ let package = Package(
     ),
     .binaryTarget(
       name: "FirebaseAnalytics",
-      url: "https://dl.google.com/firebase/ios/swiftpm/10.25.0/FirebaseAnalytics.zip",
-      checksum: "b2a313c4d58a6eee38b4e5b50462a889fbeb5bc9c1fd35d5f1579ec8e0b68463"
+      url: "https://dl.google.com/firebase/ios/swiftpm/10.27.0/FirebaseAnalytics.zip",
+      checksum: "0d5e3c63e34a5e0a8f782641ca128b3bb3be74b1eb58f55a9a4b40064cd84e88"
     ),
     .target(
       name: "FirebaseAnalyticsSwiftTarget",
@@ -1367,6 +1370,31 @@ let package = Package(
         .headerSearchPath("../../.."),
       ]
     ),
+
+    // MARK: - Firebase Vertex AI
+
+    .target(
+      name: "FirebaseVertexAI",
+      dependencies: [
+        "FirebaseAppCheckInterop",
+        "FirebaseAuthInterop",
+        "FirebaseCore",
+        "FirebaseCoreExtension",
+      ],
+      path: "FirebaseVertexAI/Sources"
+    ),
+    .testTarget(
+      name: "FirebaseVertexAIUnit",
+      dependencies: ["FirebaseVertexAI", "SharedTestUtilities"],
+      path: "FirebaseVertexAI/Tests/Unit",
+      resources: [
+        .process("CountTokenResponses"),
+        .process("GenerateContentResponses"),
+      ],
+      cSettings: [
+        .headerSearchPath("../../../"),
+      ]
+    ),
   ] + firestoreTargets(),
   cLanguageStandard: .c99,
   cxxLanguageStandard: CXXLanguageStandard.gnucxx14
@@ -1383,7 +1411,7 @@ func googleAppMeasurementDependency() -> Package.Dependency {
     return .package(url: appMeasurementURL, branch: "main")
   }
 
-  return .package(url: appMeasurementURL, exact: "10.25.0")
+  return .package(url: appMeasurementURL, exact: "10.27.0")
 }
 
 func abseilDependency() -> Package.Dependency {
@@ -1553,8 +1581,8 @@ func firestoreTargets() -> [Target] {
     } else {
       return .binaryTarget(
         name: "FirebaseFirestoreInternal",
-        url: "https://dl.google.com/firebase/ios/bin/firestore/10.25.0/rc0/FirebaseFirestoreInternal.zip",
-        checksum: "afa9ec6af0712406a1fcd4a69d01d31156308de23d3a8e2622721c72fd72485e"
+        url: "https://dl.google.com/firebase/ios/bin/firestore/10.27.0/rc0/FirebaseFirestoreInternal.zip",
+        checksum: "38f6d1bb13fabca97f53c0ef5bf283b2fbbbf460eac8fa875cd3faab597097b8"
       )
     }
   }()
diff --git a/ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json b/ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json
index 6ce3d42323c..3f5a0a497bb 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseABTestingBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/FirebaseABTesting-999c4183ee13b8d3.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/FirebaseABTesting-f0810693c46fdadc.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/FirebaseABTesting-7c24443801777936.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/FirebaseABTesting-8b2f53436c64a872.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/FirebaseABTesting-e87c686cee02758a.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/FirebaseABTesting-6a65ab8b888172af.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/FirebaseABTesting-197f0cb4125363b6.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebaseAdMobBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAdMobBinary.json
index 6c257b6edbc..938f7bcf344 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseAdMobBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseAdMobBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/Google-Mobile-Ads-SDK-ea1e4524d0df93f6.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/Google-Mobile-Ads-SDK-28d9adec807e6c50.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/Google-Mobile-Ads-SDK-4088408e21a81c67.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/Google-Mobile-Ads-SDK-3a13e621232a5f94.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/Google-Mobile-Ads-SDK-8b0d1ce3d1162b67.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/Google-Mobile-Ads-SDK-046511c3fd0189eb.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/Google-Mobile-Ads-SDK-50008c143ad8f268.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json
index 78710f2e78f..0159b998d2c 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseAnalyticsBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/FirebaseAnalytics-ed7624b45fb9f7f1.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/FirebaseAnalytics-e3194dd8c803ccd4.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/FirebaseAnalytics-8af4daf086589ec7.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/FirebaseAnalytics-974d555fcd032bea.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/FirebaseAnalytics-95669fcf109f74a2.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/FirebaseAnalytics-c0db6cb0e858e397.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/FirebaseAnalytics-e8ebe991b5743f71.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebaseAnalyticsOnDeviceConversionBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAnalyticsOnDeviceConversionBinary.json
index 9d844f1e5eb..f61800b4d8a 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseAnalyticsOnDeviceConversionBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseAnalyticsOnDeviceConversionBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/FirebaseAnalyticsOnDeviceConversion-8e03f5b073a147b0.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/FirebaseAnalyticsOnDeviceConversion-8849e6e43cbbd3c0.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/FirebaseAnalyticsOnDeviceConversion-3ab4488b5238043b.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/FirebaseAnalyticsOnDeviceConversion-ed2151b105873898.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/FirebaseAnalyticsOnDeviceConversion-091f5252d693a9f9.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/FirebaseAnalyticsOnDeviceConversion-7bbb73d46383a042.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/FirebaseAnalyticsOnDeviceConversion-eca2f83d40e0278d.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json
index b75acaa55d4..3e205c90d8a 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseAppCheckBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/FirebaseAppCheck-24146838e19ccc61.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/FirebaseAppCheck-e2e664c6ec135fc4.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/FirebaseAppCheck-81762cfe63fc1817.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/FirebaseAppCheck-4bade8711d550a40.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/FirebaseAppCheck-d19e46a728b1ac4f.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/FirebaseAppCheck-8339fde989fe8f24.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/FirebaseAppCheck-3ce0f074bfcd2596.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebaseAppDistributionBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAppDistributionBinary.json
index d9037d92218..9be49b5afde 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseAppDistributionBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseAppDistributionBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/FirebaseAppDistribution-fc3d9f20f693b734.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/FirebaseAppDistribution-e38dad9115dff3af.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/FirebaseAppDistribution-cd27707b993aae9d.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/FirebaseAppDistribution-8d5ae519bf3807da.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/FirebaseAppDistribution-cefc3327ddfceda6.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/FirebaseAppDistribution-7931e42d39575534.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/FirebaseAppDistribution-79dc2b1348d9aee9.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json b/ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json
index 5f44d8e69cf..ac56bd410b8 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseAuthBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/FirebaseAuth-2bbb81f2b4cbac77.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/FirebaseAuth-466e0e14d0c342e8.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/FirebaseAuth-a223b8ebda8fd2c2.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/FirebaseAuth-216efd548d80077e.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/FirebaseAuth-e43e66353617f093.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/FirebaseAuth-8a9591e6daa7e207.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/FirebaseAuth-7e18a510d0a5b02e.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json b/ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json
index e72e5751024..6335b020932 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseCrashlyticsBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/FirebaseCrashlytics-913794b28b7424dc.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/FirebaseCrashlytics-0805720ebb051475.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/FirebaseCrashlytics-4035e9332410161e.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/FirebaseCrashlytics-a17b8ea7cb348df9.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/FirebaseCrashlytics-d29d3285a7d9fa1d.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/FirebaseCrashlytics-165beb64483b4278.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/FirebaseCrashlytics-53604573442e756b.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json b/ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json
index a8a3944d148..322a9f5ae51 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseDatabaseBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/FirebaseDatabase-61799b2e188bed5b.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/FirebaseDatabase-a27e18a8a37d7d0a.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/FirebaseDatabase-0ac7f999ddc3f338.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/FirebaseDatabase-b3a0f37d3bbf9b85.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/FirebaseDatabase-5b22f689cb66d83a.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/FirebaseDatabase-e1a9d1f0c4222cf7.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/FirebaseDatabase-aea9249d81841ee1.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebaseDynamicLinksBinary.json b/ReleaseTooling/CarthageJSON/FirebaseDynamicLinksBinary.json
index a912b76f1de..24a9d47db85 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseDynamicLinksBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseDynamicLinksBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/FirebaseDynamicLinks-ffffc66283665cc3.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/FirebaseDynamicLinks-6aa708ba01e222f5.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/FirebaseDynamicLinks-1c3a48e8f12fc824.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/FirebaseDynamicLinks-4d0eab3bc03dbfa7.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/FirebaseDynamicLinks-7cf4ae5e96882ca8.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/FirebaseDynamicLinks-c3bdeb37651a5d5d.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/FirebaseDynamicLinks-bcb5df6ec32f6684.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json b/ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json
index cc338c3e3b8..74ec7cf0eee 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseFirestoreBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/FirebaseFirestore-7d1481e62eb86231.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/FirebaseFirestore-aebec35b37a4feed.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/FirebaseFirestore-c5fda3ae8ab62345.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/FirebaseFirestore-bc3c67a3c464cc42.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/FirebaseFirestore-73ba0700b1aa6d6a.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/FirebaseFirestore-02eb8da05f81fca5.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/FirebaseFirestore-46fa68ddf287f76e.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json b/ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json
index 7f17ba4aa5f..012a13d44c5 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseFunctionsBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/FirebaseFunctions-30a434d6b70589d3.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/FirebaseFunctions-56fc811f8293f2ec.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/FirebaseFunctions-c143cedcb64ae0d6.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/FirebaseFunctions-89f2ce118be4d7c0.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/FirebaseFunctions-47189f2c99cdf806.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/FirebaseFunctions-17c4b760141e38ad.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/FirebaseFunctions-688a38b567392fcf.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json b/ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json
index 5094644f88a..0274522e8a7 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseGoogleSignInBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/GoogleSignIn-fa5daf30aae63bc6.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/GoogleSignIn-03314eec1dbb9708.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/GoogleSignIn-fb2542a2c86f843a.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/GoogleSignIn-3e022a3cbea42c13.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/GoogleSignIn-a5b49807be66100b.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/GoogleSignIn-0d2e746eb3ff9f92.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/GoogleSignIn-5cb2a2f1f74efd5e.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json b/ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json
index e2622da1868..3279945547a 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseInAppMessagingBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/FirebaseInAppMessaging-0a6028f71dc4dad9.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/FirebaseInAppMessaging-29ae2009ef1950b2.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/FirebaseInAppMessaging-b90e8ce0168460cd.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/FirebaseInAppMessaging-ded8d27be0804e67.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/FirebaseInAppMessaging-91e5426eade46bca.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/FirebaseInAppMessaging-10801bd111df59de.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/FirebaseInAppMessaging-91d4dd9878a06b7e.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebaseMLModelDownloaderBinary.json b/ReleaseTooling/CarthageJSON/FirebaseMLModelDownloaderBinary.json
index 5af9887ea07..3f007d047ce 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseMLModelDownloaderBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseMLModelDownloaderBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/FirebaseMLModelDownloader-acc3b6f7b4beb3d8.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/FirebaseMLModelDownloader-7d8218cf3d3ef34e.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/FirebaseMLModelDownloader-0373c9fd0cc9bd65.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/FirebaseMLModelDownloader-d6b2523fc880d0e3.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/FirebaseMLModelDownloader-559cb113c0cfd8f2.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/FirebaseMLModelDownloader-9c909894999c92e4.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/FirebaseMLModelDownloader-9abf9b0e24bfb921.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json b/ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json
index a17296fa641..9467e88c3da 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseMessagingBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/FirebaseMessaging-3289b9f5d636eda0.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/FirebaseMessaging-d1e21fc776fa88ed.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/FirebaseMessaging-e6c8a212895199fa.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/FirebaseMessaging-d86ddfbb5cf01c0c.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/FirebaseMessaging-59ef1cc63c660712.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/FirebaseMessaging-76c02a69e3fe1008.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/FirebaseMessaging-439a17dcc8b8172b.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json b/ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json
index 56af49deb45..ae05d818a2a 100644
--- a/ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebasePerformanceBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/FirebasePerformance-daf49762760bcd28.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/FirebasePerformance-04ba5863cb67f726.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/FirebasePerformance-bc7b306425147b0a.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/FirebasePerformance-624ad2fbf9ae8d09.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/FirebasePerformance-36ac6dfb99caa11b.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/FirebasePerformance-f9f5be8ffad5cbb0.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/FirebasePerformance-0ffe559f7554d8a5.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json b/ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json
index 7819482c9e0..8d28b2872b6 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseRemoteConfigBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/FirebaseRemoteConfig-33cb2acece724af5.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/FirebaseRemoteConfig-8ebff21169280a12.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/FirebaseRemoteConfig-93bad5de6b479bb5.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/FirebaseRemoteConfig-70f13aecff6c35d7.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/FirebaseRemoteConfig-edd1b427b8bbe782.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/FirebaseRemoteConfig-10b62ee5663aaab3.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/FirebaseRemoteConfig-2237eb5fcd4a4525.zip",
diff --git a/ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json b/ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json
index a62ac3a11be..784101c8944 100644
--- a/ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json
+++ b/ReleaseTooling/CarthageJSON/FirebaseStorageBinary.json
@@ -18,6 +18,7 @@
   "10.23.0": "https://dl.google.com/dl/firebase/ios/carthage/10.23.0/FirebaseStorage-251d7827e3fc52e4.zip",
   "10.24.0": "https://dl.google.com/dl/firebase/ios/carthage/10.24.0/FirebaseStorage-f4d55d7d55242a20.zip",
   "10.25.0": "https://dl.google.com/dl/firebase/ios/carthage/10.25.0/FirebaseStorage-472cc5950e16fed9.zip",
+  "10.27.0": "https://dl.google.com/dl/firebase/ios/carthage/10.27.0/FirebaseStorage-bc934ea10a13d857.zip",
   "10.3.0": "https://dl.google.com/dl/firebase/ios/carthage/10.3.0/FirebaseStorage-ac463d14593d10a8.zip",
   "10.4.0": "https://dl.google.com/dl/firebase/ios/carthage/10.4.0/FirebaseStorage-fdf8479115660ce6.zip",
   "10.5.0": "https://dl.google.com/dl/firebase/ios/carthage/10.5.0/FirebaseStorage-04f255ea8c3a7420.zip",
diff --git a/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift b/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift
index 881d312b883..5344d13997b 100755
--- a/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift
+++ b/ReleaseTooling/Sources/FirebaseManifest/FirebaseManifest.swift
@@ -21,7 +21,7 @@ import Foundation
 /// The version and releasing fields of the non-Firebase pods should be reviewed every release.
 /// The array should be ordered so that any pod's dependencies precede it in the list.
 public let shared = Manifest(
-  version: "10.27.0",
+  version: "10.28.0",
   pods: [
     Pod("FirebaseSharedSwift"),
     Pod("FirebaseCoreInternal"),
@@ -44,7 +44,7 @@ public let shared = Manifest(
     Pod("FirebaseAuth", zip: true),
     Pod("FirebaseCrashlytics", zip: true),
     Pod("FirebaseDatabase", platforms: ["ios", "macos", "tvos"], zip: true),
-    Pod("FirebaseDynamicLinks", platforms: ["ios"], zip: true),
+    Pod("FirebaseDynamicLinks", allowWarnings: true, platforms: ["ios"], zip: true),
     Pod("FirebaseFirestoreInternal", allowWarnings: true, platforms: ["ios", "macos", "tvos"]),
     Pod("FirebaseFirestore", allowWarnings: true, platforms: ["ios", "macos", "tvos"], zip: true),
     Pod("FirebaseFunctions", zip: true),
diff --git a/SwiftPM-PlatformExclude/FirebaseFirestoreWrap/dummy.m b/SwiftPM-PlatformExclude/FirebaseFirestoreWrap/dummy.m
index dfc56395493..4b3c19101cf 100644
--- a/SwiftPM-PlatformExclude/FirebaseFirestoreWrap/dummy.m
+++ b/SwiftPM-PlatformExclude/FirebaseFirestoreWrap/dummy.m
@@ -17,7 +17,7 @@
 #warning "Firebase Firestore does not support watchOS"
 #endif
 
-#if (defined(TARGET_OS_VISION) && TARGET_OS_VISION) && FIREBASE_BINARY_FIRESTORE
+#if TARGET_OS_VISION && FIREBASE_BINARY_FIRESTORE
 #error "Firebase Firestore's binary SPM distribution does not support \
 visionOS. To enable the source distribution, quit Xcode and open the desired \
 project from the command line with the FIREBASE_SOURCE_FIRESTORE environment \
diff --git a/SwiftPM-PlatformExclude/FirebaseInAppMessagingWrap/dummy.m b/SwiftPM-PlatformExclude/FirebaseInAppMessagingWrap/dummy.m
index df4b2c913bd..d58ce4ca51c 100644
--- a/SwiftPM-PlatformExclude/FirebaseInAppMessagingWrap/dummy.m
+++ b/SwiftPM-PlatformExclude/FirebaseInAppMessagingWrap/dummy.m
@@ -13,6 +13,6 @@
 // limitations under the License.
 
 #import <TargetConditionals.h>
-#if !(TARGET_OS_IOS || TARGET_OS_TV || (defined(TARGET_OS_VISION) && TARGET_OS_VISION))
+#if !(TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_VISION)
 #warning "Firebase In App Messaging only supports the iOS, tvOS and visionOS platforms."
 #endif
diff --git a/scripts/build.sh b/scripts/build.sh
index c4128c13a16..288ad6f3ac2 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -169,10 +169,12 @@ tvos_flags=(
   -destination 'platform=tvOS Simulator,name=Apple TV'
 )
 watchos_flags=(
+  -sdk 'watchsimulator'
   -destination 'platform=watchOS Simulator,name=Apple Watch Series 7 (45mm)'
 )
 visionos_flags=(
-  -destination 'platform=visionOS Simulator'
+  -sdk 'xrsimulator'
+  -destination 'platform=visionOS Simulator,name=Apple Vision Pro'
 )
 catalyst_flags=(
   ARCHS=x86_64 VALID_ARCHS=x86_64 SUPPORTS_MACCATALYST=YES -sdk macosx
@@ -416,12 +418,16 @@ case "$product-$platform-$method" in
     fi
     ;;
 
+  # TODO: This is actually building for iOS instead of watchOS. Update to use
+  # watchOS xcb_flags along with the watchOS sdk option. Also nanopb and promises
+  # podspecs need minimum version updates.
+
   MessagingSampleStandaloneWatchApp-*-*)
     if check_secrets; then
       RunXcodebuild \
         -workspace 'FirebaseMessaging/Apps/SampleStandaloneWatchApp/SampleStandaloneWatchApp.xcworkspace' \
         -scheme "SampleStandaloneWatchApp Watch App" \
-        "${xcb_flags[@]}" \
+        -destination 'platform=watchOS Simulator,name=Apple Watch Series 7 (45mm)' \
         build
     fi
     ;;
@@ -497,6 +503,14 @@ case "$product-$platform-$method" in
       build
     ;;
 
+  VertexSample-*-*)
+    RunXcodebuild \
+      -project 'FirebaseVertexAI/Sample/VertexAISample.xcodeproj' \
+      -scheme "VertexAISample" \
+      "${xcb_flags[@]}" \
+      build
+    ;;
+
   Sessions-*-integration)
     # Perform "pod install" to install the relevant dependencies
     # ./FirebaseSessions/generate_testapp.sh
diff --git a/scripts/create_spec_repo/Sources/SpecRepoBuilder/main.swift b/scripts/create_spec_repo/Sources/SpecRepoBuilder/main.swift
index 4134ca56564..a6ec9ede9fb 100644
--- a/scripts/create_spec_repo/Sources/SpecRepoBuilder/main.swift
+++ b/scripts/create_spec_repo/Sources/SpecRepoBuilder/main.swift
@@ -270,7 +270,7 @@ struct SpecRepoBuilder: ParsableCommand {
   func pushPodspec(forPod pod: URL, sdkRepo: String, sources: [String],
                    flags: [String], shell: Shell = Shell.shared) throws -> Int32 {
     let sourcesArg = sources.joined(separator: ",")
-    let flagsArgArr = allowWarnings ?flags + ["--allow-warnings"] : flags
+    let flagsArgArr = allowWarnings ? flags + ["--allow-warnings"] : flags
     let flagsArg = flagsArgArr.joined(separator: " ")
 
     do {
diff --git a/scripts/make_release_notes.py b/scripts/make_release_notes.py
index b6bd71ecab8..c5ac31be86a 100755
--- a/scripts/make_release_notes.py
+++ b/scripts/make_release_notes.py
@@ -42,6 +42,7 @@
     'FirebaseFunctions/CHANGELOG.md': '{{cloud_functions}}',
     'FirebaseRemoteConfig/CHANGELOG.md': '{{remote_config}}',
     'FirebasePerformance/CHANGELOG.md': '{{perfmon}}',
+    'FirebaseVertexAI/CHANGELOG.md': '{{firebase_vertexai}}',
 }
 
 


Test patch:
diff --git a/Firestore/core/test/unit/FSTGoogleTestTests.mm b/Firestore/core/test/unit/FSTGoogleTestTests.mm
index f0120857d76..2b4c51389a9 100644
--- a/Firestore/core/test/unit/FSTGoogleTestTests.mm
+++ b/Firestore/core/test/unit/FSTGoogleTestTests.mm
@@ -235,12 +235,8 @@ void XCTestMethod(XCTestCase* self, SEL _cmd) {
     XCTAssertTrue(true);
     return;
   } else if (result->Skipped()) {
-#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 130400 || \
-    __TV_OS_VERSION_MAX_ALLOWED >= 130400 ||     \
-    __MAC_OS_X_VERSION_MAX_ALLOWED >= 101504
     // Let Xcode know that the test was skipped.
     XCTSkip();
-#endif
   }
 
   // Test failed :-(. Record the failure such that Xcode will navigate directly
@@ -251,9 +247,6 @@ void XCTestMethod(XCTestCase* self, SEL _cmd) {
     const char* path = part.file_name() ? part.file_name() : "";
     int line = part.line_number() > 0 ? part.line_number() : 0;
 
-#if __IPHONE_OS_VERSION_MAX_ALLOWED >= 140000 || \
-    __MAC_OS_X_VERSION_MAX_ALLOWED >= 101500
-    // Xcode 12
     auto* location = [[XCTSourceCodeLocation alloc] initWithFilePath:@(path)
                                                           lineNumber:line];
     auto* context = [[XCTSourceCodeContext alloc] initWithLocation:location];
@@ -264,15 +257,6 @@ void XCTestMethod(XCTestCase* self, SEL _cmd) {
                                  associatedError:nil
                                      attachments:@[]];
     [self recordIssue:issue];
-
-#else
-    // Xcode 11 and prior. recordFailureWithDescription:inFile:atLine:expected:
-    // is deprecated in Xcode 12.
-    [self recordFailureWithDescription:@(part.message())
-                                inFile:@(path)
-                                atLine:line
-                              expected:true];
-#endif
   }
 }
 
diff --git a/SwiftPMTests/swift-test/all-imports.swift b/SwiftPMTests/swift-test/all-imports.swift
index 6486c1be833..c103f128d9d 100644
--- a/SwiftPMTests/swift-test/all-imports.swift
+++ b/SwiftPMTests/swift-test/all-imports.swift
@@ -76,9 +76,8 @@ class importTest: XCTestCase {
 
     let versionParts = FirebaseVersion().split(separator: ".")
     XCTAssert(versionParts.count == 3)
-    XCTAssertEqual(Int(versionParts[0]), 10)
+    XCTAssertNotNil(Int(versionParts[0]))
     XCTAssertNotNil(Int(versionParts[1]))
-    XCTAssertNotNil(Int(versionParts[2]))
 
     print("System version? Answer: \(GULAppEnvironmentUtil.systemVersion())")
   }
diff --git a/scripts/health_metrics/pod_test_code_coverage_report.sh b/scripts/health_metrics/pod_test_code_coverage_report.sh
index 1908c92d0d6..ff742a89965 100755
--- a/scripts/health_metrics/pod_test_code_coverage_report.sh
+++ b/scripts/health_metrics/pod_test_code_coverage_report.sh
@@ -60,6 +60,8 @@ if [ $SDK == "FirebasePerformance" ]; then
 elif [ $SDK == "FirebaseFirestore" ]; then
   scripts/install_prereqs.sh Firestore ${PLATFORM} xcodebuild
   scripts/third_party/travis/retry.sh scripts/build.sh Firestore ${PLATFORM} xcodebuild
+elif [ $SDK == "FirebaseDynamicLinks" ]; then
+  scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb "${SDK}".podspec --verbose --allow-warnings --platforms="$(tr '[:upper:]' '[:lower:]'<<<${PLATFORM})" --test-specs="${TEST_SPEC}"
 else
   # Run unit tests of pods and put xcresult bundles into OUTPUT_PATH, which
   # should be a targeted dir of actions/upload-artifact in workflows.
diff --git a/scripts/spm_test_schemes/FirebaseVertexAIUnit.xcscheme b/scripts/spm_test_schemes/FirebaseVertexAIUnit.xcscheme
new file mode 100644
index 00000000000..cb4b5adae36
--- /dev/null
+++ b/scripts/spm_test_schemes/FirebaseVertexAIUnit.xcscheme
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "1200"
+   version = "1.3">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "NO"
+            buildForArchiving = "NO"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "FirebaseVertexAIUnit"
+               BuildableName = "FirebaseVertexAIUnit"
+               BlueprintName = "FirebaseVertexAIUnit"
+               ReferencedContainer = "container:">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES">
+      <Testables>
+         <TestableReference
+            skipped = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "FirebaseVertexAIUnit"
+               BuildableName = "FirebaseVertexAIUnit"
+               BlueprintName = "FirebaseVertexAIUnit"
+               ReferencedContainer = "container:">
+            </BuildableReference>
+         </TestableReference>
+      </Testables>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "FirebaseVertexAIUnit"
+            BuildableName = "FirebaseVertexAIUnit"
+            BlueprintName = "FirebaseVertexAIUnit"
+            ReferencedContainer = "container:">
+         </BuildableReference>
+      </MacroExpansion>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>