From 44ea204839a1adaa806e78b07c5976ba0e4079b2 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 2 Sep 2025 19:24:17 -0400 Subject: [PATCH 01/19] [Firebase AI] Add `CodeExecution` tool support --- FirebaseAI/Sources/AILog.swift | 1 + FirebaseAI/Sources/Tool.swift | 17 +++-- .../Sources/Types/Internal/InternalPart.swift | 35 +++++++++ FirebaseAI/Sources/Types/Public/Part.swift | 73 +++++++++++++++++++ .../Types/Public/Tools/CodeExecution.swift | 17 +++++ 5 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 FirebaseAI/Sources/Types/Public/Tools/CodeExecution.swift diff --git a/FirebaseAI/Sources/AILog.swift b/FirebaseAI/Sources/AILog.swift index 4019c2cd0ff..bba677162c7 100644 --- a/FirebaseAI/Sources/AILog.swift +++ b/FirebaseAI/Sources/AILog.swift @@ -62,6 +62,7 @@ enum AILog { case decodedInvalidCitationPublicationDate = 3011 case generateContentResponseUnrecognizedContentModality = 3012 case decodedUnsupportedImagenPredictionType = 3013 + case codeExecutionResultUnrecognizedOutcome = 3014 // SDK State Errors case generateContentResponseNoCandidates = 4000 diff --git a/FirebaseAI/Sources/Tool.swift b/FirebaseAI/Sources/Tool.swift index 16c05b3a2e4..7f782fa9208 100644 --- a/FirebaseAI/Sources/Tool.swift +++ b/FirebaseAI/Sources/Tool.swift @@ -71,17 +71,18 @@ public struct GoogleSearch: Sendable { public struct Tool: Sendable { /// A list of `FunctionDeclarations` available to the model. let functionDeclarations: [FunctionDeclaration]? + /// Specifies the Google Search configuration. let googleSearch: GoogleSearch? - init(functionDeclarations: [FunctionDeclaration]?) { - self.functionDeclarations = functionDeclarations - googleSearch = nil - } + let codeExecution: CodeExecution? - init(googleSearch: GoogleSearch) { + init(functionDeclarations: [FunctionDeclaration]? = nil, + googleSearch: GoogleSearch? = nil, + codeExecution: CodeExecution? = nil) { + self.functionDeclarations = functionDeclarations self.googleSearch = googleSearch - functionDeclarations = nil + self.codeExecution = codeExecution } /// Creates a tool that allows the model to perform function calling. @@ -126,6 +127,10 @@ public struct Tool: Sendable { public static func googleSearch(_ googleSearch: GoogleSearch = GoogleSearch()) -> Tool { return self.init(googleSearch: googleSearch) } + + public static func codeExecution() -> Tool { + return self.init(codeExecution: CodeExecution()) + } } /// Configuration for specifying function calling behavior. diff --git a/FirebaseAI/Sources/Types/Internal/InternalPart.swift b/FirebaseAI/Sources/Types/Internal/InternalPart.swift index bb62dd4c0b5..b314a7322a1 100644 --- a/FirebaseAI/Sources/Types/Internal/InternalPart.swift +++ b/FirebaseAI/Sources/Types/Internal/InternalPart.swift @@ -63,6 +63,41 @@ struct FunctionResponse: Codable, Equatable, Sendable { } } +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +struct ExecutableCode: Codable, Equatable, Sendable { + let language: String + let code: String + + init(language: String, code: String) { + self.language = language + self.code = code + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +struct CodeExecutionResult: Codable, Equatable, Sendable { + struct Outcome: CodableProtoEnum, Sendable, Equatable { + enum Kind: String { + case ok = "OUTCOME_OK" + case failed = "OUTCOME_FAILED" + case deadlineExceeded = "OUTCOME_DEADLINE_EXCEEDED" + } + + let rawValue: String + + static let unrecognizedValueMessageCode = + AILog.MessageCode.codeExecutionResultUnrecognizedOutcome + } + + let outcome: Outcome + let output: String? + + init(outcome: Outcome, output: String) { + self.outcome = outcome + self.output = output + } +} + @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) struct ErrorPart: Part, Error { let error: Error diff --git a/FirebaseAI/Sources/Types/Public/Part.swift b/FirebaseAI/Sources/Types/Public/Part.swift index fb743d1025d..6351d56c361 100644 --- a/FirebaseAI/Sources/Types/Public/Part.swift +++ b/FirebaseAI/Sources/Types/Public/Part.swift @@ -202,3 +202,76 @@ public struct FunctionResponsePart: Part { self.thoughtSignature = thoughtSignature } } + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public struct ExecutableCodePart: Part { + let executableCode: ExecutableCode + let _isThought: Bool? + let thoughtSignature: String? + + public var language: String { executableCode.language } + + public var code: String { executableCode.code } + + public var isThought: Bool { _isThought ?? false } + + public init(language: String, code: String) { + self.init(ExecutableCode(language: language, code: code), isThought: nil, thoughtSignature: nil) + } + + init(_ executableCode: ExecutableCode, isThought: Bool?, thoughtSignature: String?) { + self.executableCode = executableCode + _isThought = isThought + self.thoughtSignature = thoughtSignature + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public struct CodeExecutionResultPart: Part { + let codeExecutionResult: CodeExecutionResult + let _isThought: Bool? + let thoughtSignature: String? + + public var outcome: CodeExecutionResultPart.Outcome { + CodeExecutionResultPart.Outcome(codeExecutionResult.outcome) + } + + public var output: String { codeExecutionResult.output ?? "" } + + public var isThought: Bool { _isThought ?? false } + + public init(outcome: CodeExecutionResultPart.Outcome, output: String) { + self.init( + codeExecutionResult: CodeExecutionResult(outcome: outcome.internalOutcome, output: output), + _isThought: nil, + thoughtSignature: nil + ) + } + + init(codeExecutionResult: CodeExecutionResult, _isThought: Bool?, thoughtSignature: String?) { + self.codeExecutionResult = codeExecutionResult + self._isThought = _isThought + self.thoughtSignature = thoughtSignature + } +} + +@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) +public extension CodeExecutionResultPart { + struct Outcome: Sendable, Equatable { + let internalOutcome: CodeExecutionResult.Outcome + + public static let ok = CodeExecutionResultPart.Outcome(CodeExecutionResult.Outcome(kind: .ok)) + + public static let failed = + CodeExecutionResultPart.Outcome(CodeExecutionResult.Outcome(kind: .failed)) + + public static let deadlineExceeded = + CodeExecutionResultPart.Outcome(CodeExecutionResult.Outcome(kind: .deadlineExceeded)) + + public var rawValue: String { internalOutcome.rawValue } + + init(_ outcome: CodeExecutionResult.Outcome) { + internalOutcome = outcome + } + } +} diff --git a/FirebaseAI/Sources/Types/Public/Tools/CodeExecution.swift b/FirebaseAI/Sources/Types/Public/Tools/CodeExecution.swift new file mode 100644 index 00000000000..93b0fe9f75a --- /dev/null +++ b/FirebaseAI/Sources/Types/Public/Tools/CodeExecution.swift @@ -0,0 +1,17 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +public struct CodeExecution: Sendable, Encodable { + init() {} +} From 4e3f5871cbe679e84939756e52ca2357e6f29adc Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Tue, 2 Sep 2025 20:11:02 -0400 Subject: [PATCH 02/19] Implement `ExecutableCode` and `CodeExecutionResult` part coding --- FirebaseAI/Sources/ModelContent.swift | 24 +++++++++++++++++++ .../GenerateContentIntegrationTests.swift | 16 +++++++++++++ 2 files changed, 40 insertions(+) diff --git a/FirebaseAI/Sources/ModelContent.swift b/FirebaseAI/Sources/ModelContent.swift index 1a0aa6f5f09..0203b5b6a7a 100644 --- a/FirebaseAI/Sources/ModelContent.swift +++ b/FirebaseAI/Sources/ModelContent.swift @@ -39,6 +39,8 @@ struct InternalPart: Equatable, Sendable { case fileData(FileData) case functionCall(FunctionCall) case functionResponse(FunctionResponse) + case executableCode(ExecutableCode) + case codeExecutionResult(CodeExecutionResult) } let data: OneOfData @@ -85,6 +87,16 @@ public struct ModelContent: Equatable, Sendable { return FunctionResponsePart( functionResponse, isThought: part.isThought, thoughtSignature: part.thoughtSignature ) + case let .executableCode(executableCode): + return ExecutableCodePart( + executableCode, isThought: part.isThought, thoughtSignature: part.thoughtSignature + ) + case let .codeExecutionResult(codeExecutionResult): + return CodeExecutionResultPart( + codeExecutionResult: codeExecutionResult, + _isThought: part.isThought, + thoughtSignature: part.thoughtSignature + ) } } } @@ -194,6 +206,8 @@ extension InternalPart.OneOfData: Codable { case fileData case functionCall case functionResponse + case executableCode + case codeExecutionResult } public func encode(to encoder: Encoder) throws { @@ -209,6 +223,10 @@ extension InternalPart.OneOfData: Codable { try container.encode(functionCall, forKey: .functionCall) case let .functionResponse(functionResponse): try container.encode(functionResponse, forKey: .functionResponse) + case let .executableCode(executableCode): + try container.encode(executableCode, forKey: .executableCode) + case let .codeExecutionResult(codeExecutionResult): + try container.encode(codeExecutionResult, forKey: .codeExecutionResult) } } @@ -224,6 +242,12 @@ extension InternalPart.OneOfData: Codable { self = try .functionCall(values.decode(FunctionCall.self, forKey: .functionCall)) } else if values.contains(.functionResponse) { self = try .functionResponse(values.decode(FunctionResponse.self, forKey: .functionResponse)) + } else if values.contains(.executableCode) { + self = try .executableCode(values.decode(ExecutableCode.self, forKey: .executableCode)) + } else if values.contains(.codeExecutionResult) { + self = try .codeExecutionResult( + values.decode(CodeExecutionResult.self, forKey: .codeExecutionResult) + ) } else { let unexpectedKeys = values.allKeys.map { $0.stringValue } throw DecodingError.dataCorrupted(DecodingError.Context( diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index ef0f19be217..9dd8ff36cc2 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -418,6 +418,22 @@ struct GenerateContentIntegrationTests { } } + @Test(arguments: InstanceConfig.allConfigs) + func generateContent_codeExecution_succeeds(_ config: InstanceConfig) async throws { + let model = FirebaseAI.componentInstance(config).generativeModel( + modelName: ModelNames.gemini2Flash, + generationConfig: generationConfig, + tools: [.codeExecution()] + ) + let prompt = """ + What is the sum of the first 5 prime numbers? Generate and run code for the calculation. + """ + + _ = try await model.generateContent(prompt) + + // TODO: Add checks for the response contents + } + // MARK: Streaming Tests @Test(arguments: [ From a38e44c769587b582c6082d36dc5478877b58a8c Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 3 Sep 2025 10:00:54 -0400 Subject: [PATCH 03/19] Add checks for response content in integration test --- .../Tests/TestApp/Sources/Constants.swift | 1 + .../GenerateContentIntegrationTests.swift | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/FirebaseAI/Tests/TestApp/Sources/Constants.swift b/FirebaseAI/Tests/TestApp/Sources/Constants.swift index ef7d9e7c061..823062e16dd 100644 --- a/FirebaseAI/Tests/TestApp/Sources/Constants.swift +++ b/FirebaseAI/Tests/TestApp/Sources/Constants.swift @@ -25,6 +25,7 @@ public enum ModelNames { public static let gemini2FlashLite = "gemini-2.0-flash-lite-001" public static let gemini2FlashPreviewImageGeneration = "gemini-2.0-flash-preview-image-generation" public static let gemini2_5_Flash = "gemini-2.5-flash" + public static let gemini2_5_FlashLite = "gemini-2.5-flash-lite" public static let gemini2_5_Pro = "gemini-2.5-pro" public static let gemma3_4B = "gemma-3-4b-it" } diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index 9dd8ff36cc2..2c6ced5cfc6 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -421,7 +421,7 @@ struct GenerateContentIntegrationTests { @Test(arguments: InstanceConfig.allConfigs) func generateContent_codeExecution_succeeds(_ config: InstanceConfig) async throws { let model = FirebaseAI.componentInstance(config).generativeModel( - modelName: ModelNames.gemini2Flash, + modelName: ModelNames.gemini2_5_FlashLite, generationConfig: generationConfig, tools: [.codeExecution()] ) @@ -429,9 +429,21 @@ struct GenerateContentIntegrationTests { What is the sum of the first 5 prime numbers? Generate and run code for the calculation. """ - _ = try await model.generateContent(prompt) + let response = try await model.generateContent(prompt) - // TODO: Add checks for the response contents + let candidate = try #require(response.candidates.first) + let executableCodeParts = candidate.content.parts.compactMap { $0 as? ExecutableCodePart } + #expect(executableCodeParts.count == 1) + let executableCodePart = try #require(executableCodeParts.first) + #expect(executableCodePart.language == "PYTHON") + #expect(executableCodePart.code.contains("sum")) + let codeExecutionResults = candidate.content.parts.compactMap { $0 as? CodeExecutionResultPart } + #expect(codeExecutionResults.count == 1) + let codeExecutionResultPart = try #require(codeExecutionResults.first) + #expect(codeExecutionResultPart.outcome == .ok) + #expect(codeExecutionResultPart.output.contains("28")) // 2 + 3 + 5 + 7 + 11 = 28 + let text = try #require(response.text) + #expect(text.contains("28")) } // MARK: Streaming Tests From d617c9352f95b3502b2b21e4be44e165dcb54c2f Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 3 Sep 2025 15:36:46 -0400 Subject: [PATCH 04/19] Add handling for unexpected null values --- FirebaseAI/Sources/AILog.swift | 1 + .../Sources/Types/Internal/InternalPart.swift | 21 ++++- FirebaseAI/Sources/Types/Public/Part.swift | 81 ++++++++++++------- 3 files changed, 72 insertions(+), 31 deletions(-) diff --git a/FirebaseAI/Sources/AILog.swift b/FirebaseAI/Sources/AILog.swift index bba677162c7..66f8202435a 100644 --- a/FirebaseAI/Sources/AILog.swift +++ b/FirebaseAI/Sources/AILog.swift @@ -63,6 +63,7 @@ enum AILog { case generateContentResponseUnrecognizedContentModality = 3012 case decodedUnsupportedImagenPredictionType = 3013 case codeExecutionResultUnrecognizedOutcome = 3014 + case executableCodeUnrecognizedLanguage = 3015 // SDK State Errors case generateContentResponseNoCandidates = 4000 diff --git a/FirebaseAI/Sources/Types/Internal/InternalPart.swift b/FirebaseAI/Sources/Types/Internal/InternalPart.swift index b314a7322a1..a8afe4439c3 100644 --- a/FirebaseAI/Sources/Types/Internal/InternalPart.swift +++ b/FirebaseAI/Sources/Types/Internal/InternalPart.swift @@ -65,10 +65,22 @@ struct FunctionResponse: Codable, Equatable, Sendable { @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) struct ExecutableCode: Codable, Equatable, Sendable { - let language: String - let code: String + struct Language: CodableProtoEnum, Sendable, Equatable { + enum Kind: String { + case unspecified = "LANGUAGE_UNSPECIFIED" + case python = "PYTHON" + } + + let rawValue: String + + static let unrecognizedValueMessageCode = + AILog.MessageCode.executableCodeUnrecognizedLanguage + } + + let language: Language? + let code: String? - init(language: String, code: String) { + init(language: Language, code: String) { self.language = language self.code = code } @@ -78,6 +90,7 @@ struct ExecutableCode: Codable, Equatable, Sendable { struct CodeExecutionResult: Codable, Equatable, Sendable { struct Outcome: CodableProtoEnum, Sendable, Equatable { enum Kind: String { + case unspecified = "OUTCOME_UNSPECIFIED" case ok = "OUTCOME_OK" case failed = "OUTCOME_FAILED" case deadlineExceeded = "OUTCOME_DEADLINE_EXCEEDED" @@ -89,7 +102,7 @@ struct CodeExecutionResult: Codable, Equatable, Sendable { AILog.MessageCode.codeExecutionResultUnrecognizedOutcome } - let outcome: Outcome + let outcome: Outcome? let output: String? init(outcome: Outcome, output: String) { diff --git a/FirebaseAI/Sources/Types/Public/Part.swift b/FirebaseAI/Sources/Types/Public/Part.swift index 6351d56c361..e731579eb18 100644 --- a/FirebaseAI/Sources/Types/Public/Part.swift +++ b/FirebaseAI/Sources/Types/Public/Part.swift @@ -205,18 +205,41 @@ public struct FunctionResponsePart: Part { @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct ExecutableCodePart: Part { + public struct Language: Sendable, Equatable { + let internalLanguage: ExecutableCode.Language + + public static let python = ExecutableCodePart.Language(ExecutableCode.Language(kind: .python)) + + init(_ language: ExecutableCode.Language) { + internalLanguage = language + } + } + let executableCode: ExecutableCode let _isThought: Bool? let thoughtSignature: String? - public var language: String { executableCode.language } + public var language: ExecutableCodePart.Language { + ExecutableCodePart.Language( + // Fallback to "LANGUAGE_UNSPECIFIED" if the value is ever omitted by the backend; this should + // never happen. + executableCode.language ?? ExecutableCode.Language(kind: .unspecified) + ) + } - public var code: String { executableCode.code } + public var code: String { + // Fallback to empty string if `code` is ever omitted by the backend; this should never happen. + executableCode.code ?? "" + } public var isThought: Bool { _isThought ?? false } - public init(language: String, code: String) { - self.init(ExecutableCode(language: language, code: code), isThought: nil, thoughtSignature: nil) + public init(language: ExecutableCodePart.Language, code: String) { + self.init( + ExecutableCode(language: language.internalLanguage, code: code), + isThought: nil, + thoughtSignature: nil + ) } init(_ executableCode: ExecutableCode, isThought: Bool?, thoughtSignature: String?) { @@ -228,15 +251,40 @@ public struct ExecutableCodePart: Part { @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct CodeExecutionResultPart: Part { + public struct Outcome: Sendable, Equatable { + let internalOutcome: CodeExecutionResult.Outcome + + public static let ok = CodeExecutionResultPart.Outcome(CodeExecutionResult.Outcome(kind: .ok)) + + public static let failed = + CodeExecutionResultPart.Outcome(CodeExecutionResult.Outcome(kind: .failed)) + + public static let deadlineExceeded = + CodeExecutionResultPart.Outcome(CodeExecutionResult.Outcome(kind: .deadlineExceeded)) + + public var rawValue: String { internalOutcome.rawValue } + + init(_ outcome: CodeExecutionResult.Outcome) { + internalOutcome = outcome + } + } + let codeExecutionResult: CodeExecutionResult let _isThought: Bool? let thoughtSignature: String? public var outcome: CodeExecutionResultPart.Outcome { - CodeExecutionResultPart.Outcome(codeExecutionResult.outcome) + CodeExecutionResultPart.Outcome( + // Fallback to "OUTCOME_UNSPECIFIED" if this value is ever omitted by the backend; this should + // never happen. + codeExecutionResult.outcome ?? CodeExecutionResult.Outcome(kind: .unspecified) + ) } - public var output: String { codeExecutionResult.output ?? "" } + public var output: String { + // Fallback to empty string if `output` is omitted by the backend; this should never happen. + codeExecutionResult.output ?? "" + } public var isThought: Bool { _isThought ?? false } @@ -254,24 +302,3 @@ public struct CodeExecutionResultPart: Part { self.thoughtSignature = thoughtSignature } } - -@available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) -public extension CodeExecutionResultPart { - struct Outcome: Sendable, Equatable { - let internalOutcome: CodeExecutionResult.Outcome - - public static let ok = CodeExecutionResultPart.Outcome(CodeExecutionResult.Outcome(kind: .ok)) - - public static let failed = - CodeExecutionResultPart.Outcome(CodeExecutionResult.Outcome(kind: .failed)) - - public static let deadlineExceeded = - CodeExecutionResultPart.Outcome(CodeExecutionResult.Outcome(kind: .deadlineExceeded)) - - public var rawValue: String { internalOutcome.rawValue } - - init(_ outcome: CodeExecutionResult.Outcome) { - internalOutcome = outcome - } - } -} From 69aef8eb696f801b2d52c101170c5ed42174fb6a Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 3 Sep 2025 15:52:25 -0400 Subject: [PATCH 05/19] Add documentation --- FirebaseAI/Sources/Tool.swift | 3 +++ FirebaseAI/Sources/Types/Public/Part.swift | 12 ++++++++++++ .../Sources/Types/Public/Tools/CodeExecution.swift | 4 ++++ 3 files changed, 19 insertions(+) diff --git a/FirebaseAI/Sources/Tool.swift b/FirebaseAI/Sources/Tool.swift index 7f782fa9208..78dc8ef9443 100644 --- a/FirebaseAI/Sources/Tool.swift +++ b/FirebaseAI/Sources/Tool.swift @@ -128,6 +128,9 @@ public struct Tool: Sendable { return self.init(googleSearch: googleSearch) } + /// Creates a tool that allows the model to execute code. + /// + /// For more details, see ``CodeExecution``. public static func codeExecution() -> Tool { return self.init(codeExecution: CodeExecution()) } diff --git a/FirebaseAI/Sources/Types/Public/Part.swift b/FirebaseAI/Sources/Types/Public/Part.swift index e731579eb18..4600acaaf12 100644 --- a/FirebaseAI/Sources/Types/Public/Part.swift +++ b/FirebaseAI/Sources/Types/Public/Part.swift @@ -203,11 +203,14 @@ public struct FunctionResponsePart: Part { } } +/// A part containing code that was executed by the model. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct ExecutableCodePart: Part { + /// The language of the code in an ``ExecutableCodePart``. public struct Language: Sendable, Equatable { let internalLanguage: ExecutableCode.Language + /// The Python programming language. public static let python = ExecutableCodePart.Language(ExecutableCode.Language(kind: .python)) init(_ language: ExecutableCode.Language) { @@ -219,6 +222,7 @@ public struct ExecutableCodePart: Part { let _isThought: Bool? let thoughtSignature: String? + /// The language of the code. public var language: ExecutableCodePart.Language { ExecutableCodePart.Language( // Fallback to "LANGUAGE_UNSPECIFIED" if the value is ever omitted by the backend; this should @@ -227,6 +231,7 @@ public struct ExecutableCodePart: Part { ) } + /// The code that was executed. public var code: String { // Fallback to empty string if `code` is ever omitted by the backend; this should never happen. executableCode.code ?? "" @@ -249,16 +254,21 @@ public struct ExecutableCodePart: Part { } } +/// The result of executing code. @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct CodeExecutionResultPart: Part { + /// The outcome of a code execution. public struct Outcome: Sendable, Equatable { let internalOutcome: CodeExecutionResult.Outcome + /// The code executed without errors. public static let ok = CodeExecutionResultPart.Outcome(CodeExecutionResult.Outcome(kind: .ok)) + /// The code failed to execute. public static let failed = CodeExecutionResultPart.Outcome(CodeExecutionResult.Outcome(kind: .failed)) + /// The code took too long to execute. public static let deadlineExceeded = CodeExecutionResultPart.Outcome(CodeExecutionResult.Outcome(kind: .deadlineExceeded)) @@ -273,6 +283,7 @@ public struct CodeExecutionResultPart: Part { let _isThought: Bool? let thoughtSignature: String? + /// The outcome of the code execution. public var outcome: CodeExecutionResultPart.Outcome { CodeExecutionResultPart.Outcome( // Fallback to "OUTCOME_UNSPECIFIED" if this value is ever omitted by the backend; this should @@ -281,6 +292,7 @@ public struct CodeExecutionResultPart: Part { ) } + /// The output of the code execution. public var output: String { // Fallback to empty string if `output` is omitted by the backend; this should never happen. codeExecutionResult.output ?? "" diff --git a/FirebaseAI/Sources/Types/Public/Tools/CodeExecution.swift b/FirebaseAI/Sources/Types/Public/Tools/CodeExecution.swift index 93b0fe9f75a..5701b7523ce 100644 --- a/FirebaseAI/Sources/Types/Public/Tools/CodeExecution.swift +++ b/FirebaseAI/Sources/Types/Public/Tools/CodeExecution.swift @@ -12,6 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +/// A tool that allows the model to execute code. +/// +/// This tool can be used to solve complex problems, for example, by generating and executing Python +/// code to solve a math problem. public struct CodeExecution: Sendable, Encodable { init() {} } From 130133801d19d2273fb0fa4dd9f74670886e84fa Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 3 Sep 2025 16:19:03 -0400 Subject: [PATCH 06/19] Add `CustomStringConvertible` conformance --- FirebaseAI/Sources/Types/Public/Part.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/FirebaseAI/Sources/Types/Public/Part.swift b/FirebaseAI/Sources/Types/Public/Part.swift index 4600acaaf12..3dcf5661957 100644 --- a/FirebaseAI/Sources/Types/Public/Part.swift +++ b/FirebaseAI/Sources/Types/Public/Part.swift @@ -207,12 +207,14 @@ public struct FunctionResponsePart: Part { @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct ExecutableCodePart: Part { /// The language of the code in an ``ExecutableCodePart``. - public struct Language: Sendable, Equatable { + public struct Language: Sendable, Equatable, CustomStringConvertible { let internalLanguage: ExecutableCode.Language /// The Python programming language. public static let python = ExecutableCodePart.Language(ExecutableCode.Language(kind: .python)) + public var description: String { internalLanguage.rawValue } + init(_ language: ExecutableCode.Language) { internalLanguage = language } @@ -258,7 +260,7 @@ public struct ExecutableCodePart: Part { @available(iOS 15.0, macOS 12.0, macCatalyst 15.0, tvOS 15.0, watchOS 8.0, *) public struct CodeExecutionResultPart: Part { /// The outcome of a code execution. - public struct Outcome: Sendable, Equatable { + public struct Outcome: Sendable, Equatable, CustomStringConvertible { let internalOutcome: CodeExecutionResult.Outcome /// The code executed without errors. @@ -272,7 +274,7 @@ public struct CodeExecutionResultPart: Part { public static let deadlineExceeded = CodeExecutionResultPart.Outcome(CodeExecutionResult.Outcome(kind: .deadlineExceeded)) - public var rawValue: String { internalOutcome.rawValue } + public var description: String { internalOutcome.rawValue } init(_ outcome: CodeExecutionResult.Outcome) { internalOutcome = outcome From 31d8dbdd8aa71f1eb5f4245d887c1695d343d2ef Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 3 Sep 2025 16:24:58 -0400 Subject: [PATCH 07/19] Add `CodeExecution` tool encoding test --- FirebaseAI/Tests/Unit/Types/ToolTests.swift | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/FirebaseAI/Tests/Unit/Types/ToolTests.swift b/FirebaseAI/Tests/Unit/Types/ToolTests.swift index b163894932d..9bfdf2313b7 100644 --- a/FirebaseAI/Tests/Unit/Types/ToolTests.swift +++ b/FirebaseAI/Tests/Unit/Types/ToolTests.swift @@ -27,7 +27,9 @@ final class ToolTests: XCTestCase { func testEncodeTool_googleSearch() throws { let tool = Tool.googleSearch() + let jsonData = try encoder.encode(tool) + let jsonString = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) XCTAssertEqual(jsonString, """ { @@ -38,6 +40,21 @@ final class ToolTests: XCTestCase { """) } + func testEncodeTool_codeExecution() throws { + let tool = Tool.codeExecution() + + let jsonData = try encoder.encode(tool) + + let jsonString = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(jsonString, """ + { + "codeExecution" : { + + } + } + """) + } + func testEncodeTool_functionDeclarations() throws { let functionDecl = FunctionDeclaration( name: "test_function", @@ -45,11 +62,9 @@ final class ToolTests: XCTestCase { parameters: ["param1": .string()] ) let tool = Tool.functionDeclarations([functionDecl]) - - encoder.outputFormatting.insert(.withoutEscapingSlashes) let jsonData = try encoder.encode(tool) - let jsonString = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + let jsonString = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) XCTAssertEqual(jsonString, """ { "functionDeclarations" : [ From b4b56761fe4ec7dc6f57bcd52633bd6f744d5400 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 3 Sep 2025 16:35:28 -0400 Subject: [PATCH 08/19] Add `InternalPartTests` for `ExecutedCode` and `CodeExecutionResult` --- .../Tests/Unit/Types/InternalPartTests.swift | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift b/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift index 2cd5c5fee2a..e4962ba2df3 100644 --- a/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift +++ b/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift @@ -283,4 +283,48 @@ final class InternalPartTests: XCTestCase { XCTAssertEqual(functionResponse.name, functionName) XCTAssertEqual(functionResponse.response, ["output": .string("someValue")]) } + + func testDecodeExecutableCodePart() throws { + let json = """ + { + "executableCode": { + "language": "PYTHON", + "code": "print('hello')" + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(InternalPart.self, from: jsonData) + + XCTAssertNil(part.isThought) + guard case let .executableCode(executableCode) = part.data else { + XCTFail("Decoded part is not an executableCode part.") + return + } + XCTAssertEqual(executableCode.language, .init(kind: .python)) + XCTAssertEqual(executableCode.code, "print('hello')") + } + + func testDecodeCodeExecutionResultPart() throws { + let json = """ + { + "codeExecutionResult": { + "outcome": "OUTCOME_OK", + "output": "hello" + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(InternalPart.self, from: jsonData) + + XCTAssertNil(part.isThought) + guard case let .codeExecutionResult(codeExecutionResult) = part.data else { + XCTFail("Decoded part is not a codeExecutionResult part.") + return + } + XCTAssertEqual(codeExecutionResult.outcome, .init(kind: .ok)) + XCTAssertEqual(codeExecutionResult.output, "hello") + } } From 3199d949784ef9f1468de0895840cd0495d619ab Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 3 Sep 2025 16:40:28 -0400 Subject: [PATCH 09/19] Add decoding and encoding tests in `PartTests` --- FirebaseAI/Tests/Unit/PartTests.swift | 66 +++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/FirebaseAI/Tests/Unit/PartTests.swift b/FirebaseAI/Tests/Unit/PartTests.swift index 544e229e08b..9a17ad7f881 100644 --- a/FirebaseAI/Tests/Unit/PartTests.swift +++ b/FirebaseAI/Tests/Unit/PartTests.swift @@ -86,6 +86,40 @@ final class PartTests: XCTestCase { XCTAssertEqual(functionResponse.response, [resultParameter: .string(resultValue)]) } + func testDecodeExecutableCodePart() throws { + let json = """ + { + "executableCode": { + "language": "PYTHON", + "code": "print('hello')" + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(ExecutableCodePart.self, from: jsonData) + + XCTAssertEqual(part.language, .python) + XCTAssertEqual(part.code, "print('hello')") + } + + func testDecodeCodeExecutionResultPart() throws { + let json = """ + { + "codeExecutionResult": { + "outcome": "OUTCOME_OK", + "output": "hello" + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(CodeExecutionResultPart.self, from: jsonData) + + XCTAssertEqual(part.outcome, .ok) + XCTAssertEqual(part.output, "hello") + } + // MARK: - Part Encoding func testEncodeTextPart() throws { @@ -139,6 +173,38 @@ final class PartTests: XCTestCase { """) } + func testEncodeExecutableCodePart() throws { + let executableCodePart = ExecutableCodePart(language: .python, code: "print('hello')") + + let jsonData = try encoder.encode(executableCodePart) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "executableCode" : { + "code" : "print('hello')", + "language" : "PYTHON" + } + } + """) + } + + func testEncodeCodeExecutionResultPart() throws { + let codeExecutionResultPart = CodeExecutionResultPart(outcome: .ok, output: "hello") + + let jsonData = try encoder.encode(codeExecutionResultPart) + + let json = try XCTUnwrap(String(data: jsonData, encoding: .utf8)) + XCTAssertEqual(json, """ + { + "codeExecutionResult" : { + "outcome" : "OUTCOME_OK", + "output" : "hello" + } + } + """) + } + // MARK: - Helpers private static func bundle() -> Bundle { From 79ba1ffe385989b75a7dfef8edef0214981bc236 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 3 Sep 2025 18:02:07 -0400 Subject: [PATCH 10/19] Add CHANGELOG entry --- FirebaseAI/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/FirebaseAI/CHANGELOG.md b/FirebaseAI/CHANGELOG.md index fcd35a32e43..a1fcb5f36ec 100644 --- a/FirebaseAI/CHANGELOG.md +++ b/FirebaseAI/CHANGELOG.md @@ -1,3 +1,8 @@ +# 12.3.0 +- [feature] Added support for the Code Execution tool, which enables the model + to generate and run code to perform complex tasks like solving mathematical + equations or visualizing data. (#15280) + # 12.2.0 - [feature] Added support for returning thought summaries, which are synthesized versions of a model's internal reasoning process. (#15096) From 14f07ad869f60dd90ab21c939eaf647b567b1758 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 3 Sep 2025 18:06:52 -0400 Subject: [PATCH 11/19] Fix integration test --- .../Tests/Integration/GenerateContentIntegrationTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index 2c6ced5cfc6..7bb0df580eb 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -435,7 +435,7 @@ struct GenerateContentIntegrationTests { let executableCodeParts = candidate.content.parts.compactMap { $0 as? ExecutableCodePart } #expect(executableCodeParts.count == 1) let executableCodePart = try #require(executableCodeParts.first) - #expect(executableCodePart.language == "PYTHON") + #expect(executableCodePart.language == .python) #expect(executableCodePart.code.contains("sum")) let codeExecutionResults = candidate.content.parts.compactMap { $0 as? CodeExecutionResultPart } #expect(codeExecutionResults.count == 1) From d56d040a489bee205b42d7694e49e8d2992626f9 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 3 Sep 2025 18:49:31 -0400 Subject: [PATCH 12/19] Rename `_isThought` parameter in `CodeExecutionResultPart` initializer --- FirebaseAI/Sources/ModelContent.swift | 2 +- FirebaseAI/Sources/Types/Public/Part.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/FirebaseAI/Sources/ModelContent.swift b/FirebaseAI/Sources/ModelContent.swift index 0203b5b6a7a..7e6762e6405 100644 --- a/FirebaseAI/Sources/ModelContent.swift +++ b/FirebaseAI/Sources/ModelContent.swift @@ -94,7 +94,7 @@ public struct ModelContent: Equatable, Sendable { case let .codeExecutionResult(codeExecutionResult): return CodeExecutionResultPart( codeExecutionResult: codeExecutionResult, - _isThought: part.isThought, + isThought: part.isThought, thoughtSignature: part.thoughtSignature ) } diff --git a/FirebaseAI/Sources/Types/Public/Part.swift b/FirebaseAI/Sources/Types/Public/Part.swift index 3dcf5661957..65ff555cf77 100644 --- a/FirebaseAI/Sources/Types/Public/Part.swift +++ b/FirebaseAI/Sources/Types/Public/Part.swift @@ -305,14 +305,14 @@ public struct CodeExecutionResultPart: Part { public init(outcome: CodeExecutionResultPart.Outcome, output: String) { self.init( codeExecutionResult: CodeExecutionResult(outcome: outcome.internalOutcome, output: output), - _isThought: nil, + isThought: nil, thoughtSignature: nil ) } - init(codeExecutionResult: CodeExecutionResult, _isThought: Bool?, thoughtSignature: String?) { + init(codeExecutionResult: CodeExecutionResult, isThought: Bool?, thoughtSignature: String?) { self.codeExecutionResult = codeExecutionResult - self._isThought = _isThought + self._isThought = isThought self.thoughtSignature = thoughtSignature } } From d94c0621622437d90a8f04808335d8cc8ed3ab23 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 3 Sep 2025 19:04:40 -0400 Subject: [PATCH 13/19] Add mock response tests in `GenerativeModelVertexAITests` --- .../Unit/GenerativeModelVertexAITests.swift | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift b/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift index 0e33ba557e6..b7e284f9707 100644 --- a/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift +++ b/FirebaseAI/Tests/Unit/GenerativeModelVertexAITests.swift @@ -457,6 +457,38 @@ final class GenerativeModelVertexAITests: XCTestCase { XCTAssertEqual(response.text, textPart.text) } + func testGenerateContent_success_codeExecution() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-code-execution", + withExtension: "json", + subdirectory: vertexSubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + XCTAssertEqual(response.candidates.count, 1) + let candidate = try XCTUnwrap(response.candidates.first) + let parts = candidate.content.parts + XCTAssertEqual(candidate.finishReason, .stop) + XCTAssertEqual(parts.count, 4) + let textPart1 = try XCTUnwrap(parts[0] as? TextPart) + XCTAssertFalse(textPart1.isThought) + XCTAssertTrue(textPart1.text.hasPrefix("To find the sum of the first 5 prime numbers")) + let executableCodePart = try XCTUnwrap(parts[1] as? ExecutableCodePart) + XCTAssertFalse(executableCodePart.isThought) + XCTAssertEqual(executableCodePart.language, .python) + XCTAssertTrue(executableCodePart.code.starts(with: "prime_numbers = [2, 3, 5, 7, 11]")) + let codeExecutionResultPart = try XCTUnwrap(parts[2] as? CodeExecutionResultPart) + XCTAssertFalse(codeExecutionResultPart.isThought) + XCTAssertEqual(codeExecutionResultPart.outcome, .ok) + XCTAssertEqual(codeExecutionResultPart.output, "The sum of the first 5 prime numbers is: 28\n") + let textPart2 = try XCTUnwrap(parts[3] as? TextPart) + XCTAssertFalse(textPart2.isThought) + XCTAssertEqual( + textPart2.text, "The sum of the first 5 prime numbers (2, 3, 5, 7, and 11) is 28." + ) + } + func testGenerateContent_success_image_invalidSafetyRatingsIgnored() async throws { MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-success-image-invalid-safety-ratings", @@ -1405,6 +1437,39 @@ final class GenerativeModelVertexAITests: XCTestCase { XCTAssertTrue(text.hasPrefix("The sky is blue due to a phenomenon")) } + func testGenerateContentStream_success_codeExecution() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "streaming-success-code-execution", + withExtension: "txt", + subdirectory: vertexSubdirectory + ) + + var parts = [any Part]() + let stream = try model.generateContentStream(testPrompt) + for try await response in stream { + if let responseParts = response.candidates.first?.content.parts { + parts.append(contentsOf: responseParts) + } + } + + let thoughtParts = parts.filter { $0.isThought } + XCTAssertEqual(thoughtParts.count, 0) + let textParts = parts.filter { $0 is TextPart } + XCTAssertGreaterThan(textParts.count, 0) + let executableCodeParts = parts.compactMap { $0 as? ExecutableCodePart } + XCTAssertEqual(executableCodeParts.count, 1) + let executableCodePart = try XCTUnwrap(executableCodeParts.first) + XCTAssertFalse(executableCodePart.isThought) + XCTAssertEqual(executableCodePart.language, .python) + XCTAssertTrue(executableCodePart.code.starts(with: "prime_numbers = [2, 3, 5, 7, 11]")) + let codeExecutionResultParts = parts.compactMap { $0 as? CodeExecutionResultPart } + XCTAssertEqual(codeExecutionResultParts.count, 1) + let codeExecutionResultPart = try XCTUnwrap(codeExecutionResultParts.first) + XCTAssertFalse(codeExecutionResultPart.isThought) + XCTAssertEqual(codeExecutionResultPart.outcome, .ok) + XCTAssertEqual(codeExecutionResultPart.output, "The sum of the first 5 prime numbers is: 28\n") + } + func testGenerateContentStream_successWithInvalidSafetyRatingsIgnored() async throws { MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "streaming-success-image-invalid-safety-ratings", From b52a408ed7dcdc11882df6124dc2f5a77f3128e0 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 3 Sep 2025 19:36:31 -0400 Subject: [PATCH 14/19] Formatting --- FirebaseAI/Sources/Types/Public/Part.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FirebaseAI/Sources/Types/Public/Part.swift b/FirebaseAI/Sources/Types/Public/Part.swift index 65ff555cf77..0e36edf5229 100644 --- a/FirebaseAI/Sources/Types/Public/Part.swift +++ b/FirebaseAI/Sources/Types/Public/Part.swift @@ -312,7 +312,7 @@ public struct CodeExecutionResultPart: Part { init(codeExecutionResult: CodeExecutionResult, isThought: Bool?, thoughtSignature: String?) { self.codeExecutionResult = codeExecutionResult - self._isThought = isThought + _isThought = isThought self.thoughtSignature = thoughtSignature } } From 735684f9a23ba59e849a80628a86d4437ba8ce71 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 5 Sep 2025 16:10:00 -0400 Subject: [PATCH 15/19] Add `AILog.safeUnwrap` static method to log on use of fallback values --- FirebaseAI/Sources/AILog.swift | 29 ++++++++++++++++++++++ FirebaseAI/Sources/Types/Public/Part.swift | 15 +++++------ 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/FirebaseAI/Sources/AILog.swift b/FirebaseAI/Sources/AILog.swift index c0c17ff661c..fe04716384a 100644 --- a/FirebaseAI/Sources/AILog.swift +++ b/FirebaseAI/Sources/AILog.swift @@ -65,6 +65,7 @@ enum AILog { case decodedUnsupportedPartData = 3014 case codeExecutionResultUnrecognizedOutcome = 3015 case executableCodeUnrecognizedLanguage = 3016 + case fallbackValueUsed = 3017 // SDK State Errors case generateContentResponseNoCandidates = 4000 @@ -126,4 +127,32 @@ enum AILog { static func additionalLoggingEnabled() -> Bool { return ProcessInfo.processInfo.arguments.contains(enableArgumentKey) } + + /// Returns the unwrapped optional value if non-nil or returns the fallback value and logs. + /// + /// This convenience method is intended for use in place of `optionalValue ?? fallbackValue` with + /// the addition of logging on use of the fallback value. + /// + /// - Parameters: + /// - optionalValue: The value to unwrap. + /// - fallbackValue: The fallback (default) value to return when `optionalValue` is `nil`. + /// - level: The logging level to use for fallback messages; defaults to + /// `FirebaseLoggerLevel.warning`. + /// - code: The message code to use for fallback messages; defaults to + /// `MessageCode.fallbackValueUsed`. + /// - caller: The name of the unwrapped value; defaults to the name of the computed property or + /// function name from which the unwrapping occurred. + static func safeUnwrap(_ optionalValue: T?, + fallback fallbackValue: T, + level: FirebaseLoggerLevel = .warning, + code: MessageCode = .fallbackValueUsed, + caller: String = #function) -> T { + guard let unwrappedValue = optionalValue else { + AILog.log(level: level, code: code, """ + No value specified for '\(caller)' (\(T.self)); using fallback value '\(fallbackValue)'. + """) + return fallbackValue + } + return unwrappedValue + } } diff --git a/FirebaseAI/Sources/Types/Public/Part.swift b/FirebaseAI/Sources/Types/Public/Part.swift index 0e36edf5229..e0015901d61 100644 --- a/FirebaseAI/Sources/Types/Public/Part.swift +++ b/FirebaseAI/Sources/Types/Public/Part.swift @@ -229,14 +229,16 @@ public struct ExecutableCodePart: Part { ExecutableCodePart.Language( // Fallback to "LANGUAGE_UNSPECIFIED" if the value is ever omitted by the backend; this should // never happen. - executableCode.language ?? ExecutableCode.Language(kind: .unspecified) + AILog.safeUnwrap( + executableCode.language, fallback: ExecutableCode.Language(kind: .unspecified) + ) ) } /// The code that was executed. public var code: String { // Fallback to empty string if `code` is ever omitted by the backend; this should never happen. - executableCode.code ?? "" + AILog.safeUnwrap(executableCode.code, fallback: "") } public var isThought: Bool { _isThought ?? false } @@ -290,15 +292,14 @@ public struct CodeExecutionResultPart: Part { CodeExecutionResultPart.Outcome( // Fallback to "OUTCOME_UNSPECIFIED" if this value is ever omitted by the backend; this should // never happen. - codeExecutionResult.outcome ?? CodeExecutionResult.Outcome(kind: .unspecified) + AILog.safeUnwrap( + codeExecutionResult.outcome, fallback: CodeExecutionResult.Outcome(kind: .unspecified) + ) ) } /// The output of the code execution. - public var output: String { - // Fallback to empty string if `output` is omitted by the backend; this should never happen. - codeExecutionResult.output ?? "" - } + public var output: String? { codeExecutionResult.output } public var isThought: Bool { _isThought ?? false } From 7cac4b5e6d30ce88c6ad8870f4db8c6e1253537b Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 5 Sep 2025 16:30:28 -0400 Subject: [PATCH 16/19] Add mock response tests in `GenerativeModelGoogleAITests` --- .../Unit/GenerativeModelGoogleAITests.swift | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift index b1ee49da6a1..c6335142959 100644 --- a/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift +++ b/FirebaseAI/Tests/Unit/GenerativeModelGoogleAITests.swift @@ -308,6 +308,33 @@ final class GenerativeModelGoogleAITests: XCTestCase { XCTAssertTrue(thoughtSignature.hasPrefix("CtQOAVSoXO74PmYr9AFu")) } + func testGenerateContent_success_codeExecution() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "unary-success-code-execution", + withExtension: "json", + subdirectory: googleAISubdirectory + ) + + let response = try await model.generateContent(testPrompt) + + XCTAssertEqual(response.candidates.count, 1) + let candidate = try XCTUnwrap(response.candidates.first) + let parts = candidate.content.parts + XCTAssertEqual(candidate.finishReason, .stop) + XCTAssertEqual(parts.count, 3) + let executableCodePart = try XCTUnwrap(parts[0] as? ExecutableCodePart) + XCTAssertFalse(executableCodePart.isThought) + XCTAssertEqual(executableCodePart.language, .python) + XCTAssertTrue(executableCodePart.code.starts(with: "prime_numbers = [2, 3, 5, 7, 11]")) + let codeExecutionResultPart = try XCTUnwrap(parts[1] as? CodeExecutionResultPart) + XCTAssertFalse(codeExecutionResultPart.isThought) + XCTAssertEqual(codeExecutionResultPart.outcome, .ok) + XCTAssertEqual(codeExecutionResultPart.output, "sum_of_primes=28\n") + let textPart = try XCTUnwrap(parts[2] as? TextPart) + XCTAssertFalse(textPart.isThought) + XCTAssertTrue(textPart.text.hasPrefix("The first 5 prime numbers are 2, 3, 5, 7, and 11.")) + } + func testGenerateContent_failure_invalidAPIKey() async throws { let expectedStatusCode = 400 MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( @@ -526,6 +553,39 @@ final class GenerativeModelGoogleAITests: XCTestCase { } } + func testGenerateContentStream_success_codeExecution() async throws { + MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( + forResource: "streaming-success-code-execution", + withExtension: "txt", + subdirectory: googleAISubdirectory + ) + + var parts = [any Part]() + let stream = try model.generateContentStream(testPrompt) + for try await response in stream { + if let responseParts = response.candidates.first?.content.parts { + parts.append(contentsOf: responseParts) + } + } + + let thoughtParts = parts.filter { $0.isThought } + XCTAssertEqual(thoughtParts.count, 0) + let textParts = parts.filter { $0 is TextPart } + XCTAssertGreaterThan(textParts.count, 0) + let executableCodeParts = parts.compactMap { $0 as? ExecutableCodePart } + XCTAssertEqual(executableCodeParts.count, 1) + let executableCodePart = try XCTUnwrap(executableCodeParts.first) + XCTAssertFalse(executableCodePart.isThought) + XCTAssertEqual(executableCodePart.language, .python) + XCTAssertTrue(executableCodePart.code.starts(with: "prime_numbers = [2, 3, 5, 7, 11]")) + let codeExecutionResultParts = parts.compactMap { $0 as? CodeExecutionResultPart } + XCTAssertEqual(codeExecutionResultParts.count, 1) + let codeExecutionResultPart = try XCTUnwrap(codeExecutionResultParts.first) + XCTAssertFalse(codeExecutionResultPart.isThought) + XCTAssertEqual(codeExecutionResultPart.outcome, .ok) + XCTAssertEqual(codeExecutionResultPart.output, "The sum of the first 5 prime numbers is: 28\n") + } + func testGenerateContentStream_failureInvalidAPIKey() async throws { MockURLProtocol.requestHandler = try GenerativeModelTestUtil.httpRequestHandler( forResource: "unary-failure-api-key", From b78c4d2393f57b1a53fa01553d72b335a6f1acfb Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 5 Sep 2025 16:41:55 -0400 Subject: [PATCH 17/19] Fix `output` nullability in integration test --- .../Tests/Integration/GenerateContentIntegrationTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift index 8fb28898efe..d2fb589a432 100644 --- a/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift +++ b/FirebaseAI/Tests/TestApp/Tests/Integration/GenerateContentIntegrationTests.swift @@ -447,7 +447,8 @@ struct GenerateContentIntegrationTests { #expect(codeExecutionResults.count == 1) let codeExecutionResultPart = try #require(codeExecutionResults.first) #expect(codeExecutionResultPart.outcome == .ok) - #expect(codeExecutionResultPart.output.contains("28")) // 2 + 3 + 5 + 7 + 11 = 28 + let output = try #require(codeExecutionResultPart.output) + #expect(output.contains("28")) // 2 + 3 + 5 + 7 + 11 = 28 let text = try #require(response.text) #expect(text.contains("28")) } From 1162598b2fc8d389dc02ad35c5aa02ad6e152b10 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 5 Sep 2025 20:34:52 -0400 Subject: [PATCH 18/19] Add decoding tests for missing fields in `ExecutableCode` and `CodeExecutionResult` --- FirebaseAI/Tests/Unit/PartTests.swift | 92 +++++++++++++ .../Tests/Unit/Types/InternalPartTests.swift | 122 ++++++++++++++++++ 2 files changed, 214 insertions(+) diff --git a/FirebaseAI/Tests/Unit/PartTests.swift b/FirebaseAI/Tests/Unit/PartTests.swift index 9a17ad7f881..f538586d439 100644 --- a/FirebaseAI/Tests/Unit/PartTests.swift +++ b/FirebaseAI/Tests/Unit/PartTests.swift @@ -103,6 +103,52 @@ final class PartTests: XCTestCase { XCTAssertEqual(part.code, "print('hello')") } + func testDecodeExecutableCodePart_missingLanguage() throws { + let json = """ + { + "executableCode": { + "code": "print('hello')" + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(ExecutableCodePart.self, from: jsonData) + + XCTAssertEqual(part.language.description, "LANGUAGE_UNSPECIFIED") + XCTAssertEqual(part.code, "print('hello')") + } + + func testDecodeExecutableCodePart_missingCode() throws { + let json = """ + { + "executableCode": { + "language": "PYTHON" + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(ExecutableCodePart.self, from: jsonData) + + XCTAssertEqual(part.language, .python) + XCTAssertEqual(part.code, "") + } + + func testDecodeExecutableCodePart_missingLanguageAndCode() throws { + let json = """ + { + "executableCode": {} + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(ExecutableCodePart.self, from: jsonData) + + XCTAssertEqual(part.language.description, "LANGUAGE_UNSPECIFIED") + XCTAssertEqual(part.code, "") + } + func testDecodeCodeExecutionResultPart() throws { let json = """ { @@ -120,6 +166,52 @@ final class PartTests: XCTestCase { XCTAssertEqual(part.output, "hello") } + func testDecodeCodeExecutionResultPart_missingOutcome() throws { + let json = """ + { + "codeExecutionResult": { + "output": "hello" + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(CodeExecutionResultPart.self, from: jsonData) + + XCTAssertEqual(part.outcome.description, "OUTCOME_UNSPECIFIED") + XCTAssertEqual(part.output, "hello") + } + + func testDecodeCodeExecutionResultPart_missingOutput() throws { + let json = """ + { + "codeExecutionResult": { + "outcome": "OUTCOME_OK" + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(CodeExecutionResultPart.self, from: jsonData) + + XCTAssertEqual(part.outcome, .ok) + XCTAssertNil(part.output) + } + + func testDecodeCodeExecutionResultPart_missingOutcomeAndOutput() throws { + let json = """ + { + "codeExecutionResult": {} + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(CodeExecutionResultPart.self, from: jsonData) + + XCTAssertEqual(part.outcome.description, "OUTCOME_UNSPECIFIED") + XCTAssertNil(part.output) + } + // MARK: - Part Encoding func testEncodeTextPart() throws { diff --git a/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift b/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift index e4962ba2df3..b4d2d310c9d 100644 --- a/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift +++ b/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift @@ -306,6 +306,67 @@ final class InternalPartTests: XCTestCase { XCTAssertEqual(executableCode.code, "print('hello')") } + func testDecodeExecutableCodePart_missingLanguage() throws { + let json = """ + { + "executableCode": { + "code": "print('hello')" + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(InternalPart.self, from: jsonData) + + XCTAssertNil(part.isThought) + guard case let .executableCode(executableCode) = part.data else { + XCTFail("Decoded part is not an executableCode part.") + return + } + XCTAssertNil(executableCode.language) + XCTAssertEqual(executableCode.code, "print('hello')") + } + + func testDecodeExecutableCodePart_missingCode() throws { + let json = """ + { + "executableCode": { + "language": "PYTHON" + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(InternalPart.self, from: jsonData) + + XCTAssertNil(part.isThought) + guard case let .executableCode(executableCode) = part.data else { + XCTFail("Decoded part is not an executableCode part.") + return + } + XCTAssertEqual(executableCode.language, .init(kind: .python)) + XCTAssertNil(executableCode.code) + } + + func testDecodeExecutableCodePart_missingLanguageAndCode() throws { + let json = """ + { + "executableCode": {} + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(InternalPart.self, from: jsonData) + + XCTAssertNil(part.isThought) + guard case let .executableCode(executableCode) = part.data else { + XCTFail("Decoded part is not an executableCode part.") + return + } + XCTAssertNil(executableCode.language) + XCTAssertNil(executableCode.code) + } + func testDecodeCodeExecutionResultPart() throws { let json = """ { @@ -327,4 +388,65 @@ final class InternalPartTests: XCTestCase { XCTAssertEqual(codeExecutionResult.outcome, .init(kind: .ok)) XCTAssertEqual(codeExecutionResult.output, "hello") } + + func testDecodeCodeExecutionResultPart_missingOutcome() throws { + let json = """ + { + "codeExecutionResult": { + "output": "hello" + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(InternalPart.self, from: jsonData) + + XCTAssertNil(part.isThought) + guard case let .codeExecutionResult(codeExecutionResult) = part.data else { + XCTFail("Decoded part is not a codeExecutionResult part.") + return + } + XCTAssertNil(codeExecutionResult.outcome) + XCTAssertEqual(codeExecutionResult.output, "hello") + } + + func testDecodeCodeExecutionResultPart_missingOutput() throws { + let json = """ + { + "codeExecutionResult": { + "outcome": "OUTCOME_OK" + } + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(InternalPart.self, from: jsonData) + + XCTAssertNil(part.isThought) + guard case let .codeExecutionResult(codeExecutionResult) = part.data else { + XCTFail("Decoded part is not a codeExecutionResult part.") + return + } + XCTAssertEqual(codeExecutionResult.outcome, .init(kind: .ok)) + XCTAssertNil(codeExecutionResult.output) + } + + func testDecodeCodeExecutionResultPart_missingOutcomeAndOutput() throws { + let json = """ + { + "codeExecutionResult": {} + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(InternalPart.self, from: jsonData) + + XCTAssertNil(part.isThought) + guard case let .codeExecutionResult(codeExecutionResult) = part.data else { + XCTFail("Decoded part is not a codeExecutionResult part.") + return + } + XCTAssertNil(codeExecutionResult.outcome) + XCTAssertNil(codeExecutionResult.output) + } } From 35866d5993b0b259b2571450898a43e411831f00 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Fri, 5 Sep 2025 20:37:23 -0400 Subject: [PATCH 19/19] Add decoding tests with and without `"thought": true` --- .../Tests/Unit/Types/InternalPartTests.swift | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift b/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift index b4d2d310c9d..65121e913c2 100644 --- a/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift +++ b/FirebaseAI/Tests/Unit/Types/InternalPartTests.swift @@ -284,7 +284,30 @@ final class InternalPartTests: XCTestCase { XCTAssertEqual(functionResponse.response, ["output": .string("someValue")]) } - func testDecodeExecutableCodePart() throws { + func testDecodeExecutableCodePartWithThought() throws { + let json = """ + { + "executableCode": { + "language": "PYTHON", + "code": "print('hello')" + }, + "thought": true + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(InternalPart.self, from: jsonData) + + XCTAssertEqual(part.isThought, true) + guard case let .executableCode(executableCode) = part.data else { + XCTFail("Decoded part is not an executableCode part.") + return + } + XCTAssertEqual(executableCode.language, .init(kind: .python)) + XCTAssertEqual(executableCode.code, "print('hello')") + } + + func testDecodeExecutableCodePartWithoutThought() throws { let json = """ { "executableCode": { @@ -367,7 +390,30 @@ final class InternalPartTests: XCTestCase { XCTAssertNil(executableCode.code) } - func testDecodeCodeExecutionResultPart() throws { + func testDecodeCodeExecutionResultPartWithThought() throws { + let json = """ + { + "codeExecutionResult": { + "outcome": "OUTCOME_OK", + "output": "hello" + }, + "thought": true + } + """ + let jsonData = try XCTUnwrap(json.data(using: .utf8)) + + let part = try decoder.decode(InternalPart.self, from: jsonData) + + XCTAssertEqual(part.isThought, true) + guard case let .codeExecutionResult(codeExecutionResult) = part.data else { + XCTFail("Decoded part is not a codeExecutionResult part.") + return + } + XCTAssertEqual(codeExecutionResult.outcome, .init(kind: .ok)) + XCTAssertEqual(codeExecutionResult.output, "hello") + } + + func testDecodeCodeExecutionResultPartWithoutThought() throws { let json = """ { "codeExecutionResult": {