Skip to content

Commit a5172d2

Browse files
authored
Refactor GenerateContentResponse text accessors (#16087)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> This fixes an issue where `[FirebaseAI][I-VTX004001] Could not get a text part from the first candidate.` is logged during automatic function calling. - Extracts core text retrieval logic into an internal text(isThought:) helper. - Updates public text properties to perform logging only when accessed externally. - Migrates internal consumers in GenerativeModelSession to avoid redundant logs.
1 parent b6d6c00 commit a5172d2

5 files changed

Lines changed: 143 additions & 14 deletions

File tree

FirebaseAI/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- [fixed] Fixed a `no member 'autoFunctionDeclaration'` compilation error on
55
unofficially supported Xcode versions older than 26.2. (#16037)
66
- [fixed] Fixed missing thought summary output in `GenerativeModelSession.streamResponse`. (#16075)
7+
- [fixed] Removed unnecessary log statements related to retrieving text parts during automatic function calling.
78

89
# 12.12.0
910
- [feature] Added support for automatic function calling in

FirebaseAI/Sources/AILog.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,20 @@ enum AILog {
110110
/// The argument required to enable additional logging.
111111
static let enableArgumentKey = "-FIRDebugEnabled"
112112

113+
#if DEBUG
114+
/// A callback closure used to intercept log emissions during unit testing.
115+
///
116+
/// This property is only available in debug builds to facilitate testing without external
117+
/// dependencies.
118+
nonisolated(unsafe) static var logInterceptor: ((FirebaseLoggerLevel, MessageCode, String)
119+
-> Void)?
120+
#endif
121+
113122
static func log(level: FirebaseLoggerLevel, code: MessageCode, _ message: String) {
123+
#if DEBUG
124+
logInterceptor?(level, code, message)
125+
#endif
126+
114127
let messageCode = String(format: "I-VTX%06d", code.rawValue)
115128
FirebaseLogger.log(
116129
level: level,

FirebaseAI/Sources/GenerateContentResponse.swift

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,21 @@ public struct GenerateContentResponse: Sendable {
7575
///
7676
/// - Note: This does not include thought summaries; see ``thoughtSummary`` for more details.
7777
public var text: String? {
78-
return text(isThought: false)
78+
guard !candidates.isEmpty else {
79+
AILog.error(
80+
code: .generateContentResponseNoCandidates,
81+
"Could not get text from a response that had no candidates."
82+
)
83+
return nil
84+
}
85+
guard let value = text(isThought: false) else {
86+
AILog.error(
87+
code: .generateContentResponseNoText,
88+
"Could not get a text part from the first candidate."
89+
)
90+
return nil
91+
}
92+
return value
7993
}
8094

8195
/// A summary of the model's thinking process, if available.
@@ -84,7 +98,21 @@ public struct GenerateContentResponse: Sendable {
8498
/// ``ThinkingConfig``. For more information, see the
8599
/// [Thinking](https://firebase.google.com/docs/ai-logic/thinking) documentation.
86100
public var thoughtSummary: String? {
87-
return text(isThought: true)
101+
guard candidates.first != nil else {
102+
AILog.error(
103+
code: .generateContentResponseNoCandidates,
104+
"Could not get text from a response that had no candidates."
105+
)
106+
return nil
107+
}
108+
guard let value = text(isThought: true) else {
109+
AILog.error(
110+
code: .generateContentResponseNoText,
111+
"Could not get a text part from any candidates."
112+
)
113+
return nil
114+
}
115+
return value
88116
}
89117

90118
/// Returns function calls found in any `Part`s of the first candidate of the response, if any.
@@ -128,10 +156,6 @@ public struct GenerateContentResponse: Sendable {
128156

129157
func text(isThought: Bool) -> String? {
130158
guard let candidate = candidates.first else {
131-
AILog.error(
132-
code: .generateContentResponseNoCandidates,
133-
"Could not get text from a response that had no candidates."
134-
)
135159
return nil
136160
}
137161
let textValues: [String] = candidate.content.parts.compactMap { part in
@@ -141,10 +165,6 @@ public struct GenerateContentResponse: Sendable {
141165
return textPart.text
142166
}
143167
guard textValues.count > 0 else {
144-
AILog.error(
145-
code: .generateContentResponseNoText,
146-
"Could not get a text part from the first candidate."
147-
)
148168
return nil
149169
}
150170
return textValues.joined(separator: " ")

FirebaseAI/Sources/GenerativeModelSession.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@
280280
}
281281

282282
let text: String
283-
if let responseText = response.text {
283+
if let responseText = response.text(isThought: false) {
284284
text = responseText
285285
} else if let parts = response.candidates.first?.content.parts, !parts.isEmpty {
286286
text = ""
@@ -350,7 +350,7 @@
350350
functionCalls.append(contentsOf: chunk.functionCalls)
351351

352352
let text: String
353-
if let responseText = chunk.text {
353+
if let responseText = chunk.text(isThought: false) {
354354
text = responseText
355355
} else if let parts = chunk.candidates.first?.content.parts, !parts.isEmpty {
356356
text = ""
@@ -362,7 +362,7 @@
362362

363363
// 2. If we have pending data, we now know it wasn't the last chunk.
364364
if let pending = pendingChunkData,
365-
!pending.text.isEmpty || pending.response.thoughtSummary != nil {
365+
!pending.text.isEmpty || pending.response.text(isThought: true) != nil {
366366
let rawContent = try Self.makeRawContent(
367367
from: pending.text,
368368
generationID: pending.id,
@@ -415,7 +415,7 @@
415415
if !functionResponses.isEmpty {
416416
// Yield any pending text if it's not empty, but mark it as NOT complete yet.
417417
if let pending = pendingChunkData,
418-
!pending.text.isEmpty || pending.response.thoughtSummary != nil {
418+
!pending.text.isEmpty || pending.response.text(isThought: true) != nil {
419419
let rawContent = try Self.makeRawContent(
420420
from: pending.text,
421421
generationID: pending.id,

FirebaseAI/Tests/Unit/Types/GenerateContentResponseTests.swift

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,101 @@ final class GenerateContentResponseTests: XCTestCase {
108108
)
109109
}
110110

111+
func testGenerateContentResponse_noCandidates_logging() throws {
112+
var loggedCodes: [AILog.MessageCode] = []
113+
AILog.logInterceptor = { level, code, message in
114+
loggedCodes.append(code)
115+
}
116+
117+
let response = GenerateContentResponse(candidates: [])
118+
119+
_ = response.text
120+
XCTAssertTrue(loggedCodes.contains(.generateContentResponseNoCandidates))
121+
122+
loggedCodes.removeAll()
123+
_ = response.thoughtSummary
124+
XCTAssertTrue(loggedCodes.contains(.generateContentResponseNoCandidates))
125+
}
126+
127+
func testGenerateContentResponse_textIsThought_logging() throws {
128+
var loggedCodes: [AILog.MessageCode] = []
129+
AILog.logInterceptor = { level, code, message in
130+
loggedCodes.append(code)
131+
}
132+
133+
let imageData = Data("sample image data".utf8)
134+
let inlineDataPart = InlineDataPart(data: imageData, mimeType: "image/png")
135+
let candidate = Candidate(
136+
content: ModelContent(parts: [inlineDataPart]),
137+
safetyRatings: [],
138+
finishReason: nil,
139+
citationMetadata: nil
140+
)
141+
let response = GenerateContentResponse(candidates: [candidate])
142+
143+
_ = response.text
144+
XCTAssertTrue(loggedCodes.contains(.generateContentResponseNoText))
145+
146+
loggedCodes.removeAll()
147+
_ = response.thoughtSummary
148+
XCTAssertTrue(loggedCodes.contains(.generateContentResponseNoText))
149+
}
150+
151+
func testGenerateContentResponse_thoughtSummary_success() throws {
152+
let thoughtPart = TextPart("This is a thought.", isThought: true, thoughtSignature: nil)
153+
let modelContent = ModelContent(parts: [thoughtPart])
154+
let candidate = Candidate(
155+
content: modelContent,
156+
safetyRatings: [],
157+
finishReason: nil,
158+
citationMetadata: nil
159+
)
160+
let response = GenerateContentResponse(candidates: [candidate])
161+
162+
XCTAssertEqual(response.thoughtSummary, "This is a thought.")
163+
}
164+
165+
func testGenerateContentResponse_text_success() throws {
166+
let textPart = TextPart("This is text.", isThought: false, thoughtSignature: nil)
167+
let modelContent = ModelContent(parts: [textPart])
168+
let candidate = Candidate(
169+
content: modelContent,
170+
safetyRatings: [],
171+
finishReason: nil,
172+
citationMetadata: nil
173+
)
174+
let response = GenerateContentResponse(candidates: [candidate])
175+
176+
XCTAssertEqual(response.text, "This is text.")
177+
}
178+
179+
func testGenerateContentResponse_internalAccessor_doesNotLog() throws {
180+
var loggedCodes: [AILog.MessageCode] = []
181+
AILog.logInterceptor = { level, code, message in
182+
loggedCodes.append(code)
183+
}
184+
185+
// Case 1: No candidates
186+
let noCandidatesResponse = GenerateContentResponse(candidates: [])
187+
_ = noCandidatesResponse.text(isThought: false)
188+
_ = noCandidatesResponse.text(isThought: true)
189+
XCTAssertTrue(loggedCodes.isEmpty)
190+
191+
// Case 2: No text parts
192+
let imageData = Data("sample image data".utf8)
193+
let inlineDataPart = InlineDataPart(data: imageData, mimeType: "image/png")
194+
let candidate = Candidate(
195+
content: ModelContent(parts: [inlineDataPart]),
196+
safetyRatings: [],
197+
finishReason: nil,
198+
citationMetadata: nil
199+
)
200+
let noTextResponse = GenerateContentResponse(candidates: [candidate])
201+
_ = noTextResponse.text(isThought: false)
202+
_ = noTextResponse.text(isThought: true)
203+
XCTAssertTrue(loggedCodes.isEmpty)
204+
}
205+
111206
// MARK: - Decoding Tests
112207

113208
func testDecodeCandidate_emptyURLMetadata_urlContextMetadataIsNil() throws {

0 commit comments

Comments
 (0)