@tanstack/ai-gemini 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/adapters/audio.d.ts +92 -0
- package/dist/esm/adapters/audio.js +61 -0
- package/dist/esm/adapters/audio.js.map +1 -0
- package/dist/esm/adapters/image.js +70 -23
- package/dist/esm/adapters/image.js.map +1 -1
- package/dist/esm/adapters/summarize.js +98 -70
- package/dist/esm/adapters/summarize.js.map +1 -1
- package/dist/esm/adapters/text.js +21 -2
- package/dist/esm/adapters/text.js.map +1 -1
- package/dist/esm/adapters/tts.d.ts +39 -3
- package/dist/esm/adapters/tts.js +153 -44
- package/dist/esm/adapters/tts.js.map +1 -1
- package/dist/esm/image/image-provider-options.d.ts +4 -2
- package/dist/esm/image/image-provider-options.js +8 -1
- package/dist/esm/image/image-provider-options.js.map +1 -1
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.js +6 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/model-meta.d.ts +6 -1
- package/dist/esm/model-meta.js +15 -0
- package/dist/esm/model-meta.js.map +1 -1
- package/package.json +3 -3
- package/src/adapters/audio.ts +185 -0
- package/src/adapters/image.ts +101 -24
- package/src/adapters/summarize.ts +111 -80
- package/src/adapters/text.ts +22 -1
- package/src/adapters/tts.ts +264 -51
- package/src/image/image-provider-options.ts +24 -4
- package/src/index.ts +14 -0
- package/src/model-meta.ts +87 -3
package/src/adapters/image.ts
CHANGED
|
@@ -74,34 +74,50 @@ export class GeminiImageAdapter<
|
|
|
74
74
|
private client: GoogleGenAI
|
|
75
75
|
|
|
76
76
|
constructor(config: GeminiImageConfig, model: TModel) {
|
|
77
|
-
super(
|
|
77
|
+
super(model, config)
|
|
78
78
|
this.client = createGeminiClient(config)
|
|
79
79
|
}
|
|
80
80
|
|
|
81
81
|
async generateImages(
|
|
82
82
|
options: ImageGenerationOptions<GeminiImageProviderOptions>,
|
|
83
83
|
): Promise<ImageGenerationResult> {
|
|
84
|
-
const { model, prompt } = options
|
|
84
|
+
const { model, prompt, logger } = options
|
|
85
85
|
|
|
86
|
-
|
|
86
|
+
logger.request(
|
|
87
|
+
`activity=generateImage provider=gemini model=${this.model}`,
|
|
88
|
+
{
|
|
89
|
+
provider: 'gemini',
|
|
90
|
+
model: this.model,
|
|
91
|
+
},
|
|
92
|
+
)
|
|
87
93
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
}
|
|
94
|
+
try {
|
|
95
|
+
validatePrompt({ prompt, model })
|
|
91
96
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
97
|
+
if (this.isGeminiImageModel(model)) {
|
|
98
|
+
return await this.generateWithGeminiApi(options)
|
|
99
|
+
}
|
|
95
100
|
|
|
96
|
-
|
|
101
|
+
// Imagen models path (generateImages API)
|
|
102
|
+
validateImageSize(model, options.size)
|
|
103
|
+
validateNumberOfImages(model, options.numberOfImages)
|
|
97
104
|
|
|
98
|
-
|
|
99
|
-
model,
|
|
100
|
-
prompt,
|
|
101
|
-
config,
|
|
102
|
-
})
|
|
105
|
+
const config = this.buildImagenConfig(options)
|
|
103
106
|
|
|
104
|
-
|
|
107
|
+
const response = await this.client.models.generateImages({
|
|
108
|
+
model,
|
|
109
|
+
prompt,
|
|
110
|
+
config,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
return this.transformImagenResponse(model, response)
|
|
114
|
+
} catch (error) {
|
|
115
|
+
logger.errors('gemini.generateImage fatal', {
|
|
116
|
+
error,
|
|
117
|
+
source: 'gemini.generateImage',
|
|
118
|
+
})
|
|
119
|
+
throw error
|
|
120
|
+
}
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
private isGeminiImageModel(model: string): boolean {
|
|
@@ -122,8 +138,24 @@ export class GeminiImageAdapter<
|
|
|
122
138
|
? `${prompt} Generate ${numberOfImages} distinct images.`
|
|
123
139
|
: prompt
|
|
124
140
|
|
|
141
|
+
// GeminiImageProviderOptions is Imagen-shaped — most fields
|
|
142
|
+
// (personGeneration, safetyFilterLevel, addWatermark, outputMimeType,
|
|
143
|
+
// outputCompressionQuality, guidanceScale, enhancePrompt,
|
|
144
|
+
// includeSafetyAttributes, includeRaiReason, outputGcsUri, labels,
|
|
145
|
+
// negativePrompt, language) are only valid on GenerateImagesConfig and
|
|
146
|
+
// would be rejected by the Gemini-native generateContent path. Pick only
|
|
147
|
+
// the fields that are valid on GenerateContentConfig instead of spreading
|
|
148
|
+
// the whole options object.
|
|
149
|
+
const nativeConfig: GenerateContentConfig = {}
|
|
150
|
+
if (modelOptions?.seed !== undefined) {
|
|
151
|
+
nativeConfig.seed = modelOptions.seed
|
|
152
|
+
}
|
|
153
|
+
|
|
125
154
|
const config: GenerateContentConfig = {
|
|
126
|
-
|
|
155
|
+
...nativeConfig,
|
|
156
|
+
// Include TEXT so the model can interleave descriptions between images.
|
|
157
|
+
// IMPORTANT: responseModalities is a protected default — set it AFTER
|
|
158
|
+
// nativeConfig so nothing can silently disable image output.
|
|
127
159
|
responseModalities: ['TEXT', 'IMAGE'],
|
|
128
160
|
...(parsedSize && {
|
|
129
161
|
imageConfig: {
|
|
@@ -135,7 +167,6 @@ export class GeminiImageAdapter<
|
|
|
135
167
|
}),
|
|
136
168
|
},
|
|
137
169
|
}),
|
|
138
|
-
...modelOptions,
|
|
139
170
|
}
|
|
140
171
|
|
|
141
172
|
const response = await this.client.models.generateContent({
|
|
@@ -152,6 +183,7 @@ export class GeminiImageAdapter<
|
|
|
152
183
|
response: GenerateContentResponse,
|
|
153
184
|
): ImageGenerationResult {
|
|
154
185
|
const images: Array<GeneratedImage> = []
|
|
186
|
+
const textParts: Array<string> = []
|
|
155
187
|
const parts = response.candidates?.[0]?.content?.parts ?? []
|
|
156
188
|
|
|
157
189
|
for (const part of parts) {
|
|
@@ -161,9 +193,23 @@ export class GeminiImageAdapter<
|
|
|
161
193
|
part.inlineData.data.length > 0
|
|
162
194
|
) {
|
|
163
195
|
images.push({ b64Json: part.inlineData.data })
|
|
196
|
+
} else if (typeof part.text === 'string' && part.text.length > 0) {
|
|
197
|
+
textParts.push(part.text)
|
|
164
198
|
}
|
|
165
199
|
}
|
|
166
200
|
|
|
201
|
+
// If the model returned only text parts (for example a safety refusal
|
|
202
|
+
// or a "can't do that" message), surface the text instead of silently
|
|
203
|
+
// resolving to an empty images array — otherwise callers can't tell a
|
|
204
|
+
// generation failure apart from a genuine empty response.
|
|
205
|
+
if (images.length === 0) {
|
|
206
|
+
const reason =
|
|
207
|
+
textParts.length > 0
|
|
208
|
+
? `: ${textParts.join(' ').trim()}`
|
|
209
|
+
: ' (no inline image or text parts were returned).'
|
|
210
|
+
throw new Error(`Gemini ${model} returned no images${reason}`)
|
|
211
|
+
}
|
|
212
|
+
|
|
167
213
|
return {
|
|
168
214
|
id: generateId(this.name),
|
|
169
215
|
model,
|
|
@@ -189,12 +235,43 @@ export class GeminiImageAdapter<
|
|
|
189
235
|
model: string,
|
|
190
236
|
response: GenerateImagesResponse,
|
|
191
237
|
): ImageGenerationResult {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
238
|
+
const entries = response.generatedImages ?? []
|
|
239
|
+
const images: Array<GeneratedImage> = []
|
|
240
|
+
const filterReasons: Array<string> = []
|
|
241
|
+
|
|
242
|
+
for (const item of entries) {
|
|
243
|
+
const b64Json = item.image?.imageBytes
|
|
244
|
+
if (b64Json) {
|
|
245
|
+
images.push({ b64Json, revisedPrompt: item.enhancedPrompt })
|
|
246
|
+
continue
|
|
247
|
+
}
|
|
248
|
+
// Imagen can drop individual entries with a raiFilteredReason when
|
|
249
|
+
// Responsible-AI filters fire. Preserve the reason so callers can
|
|
250
|
+
// surface it instead of silently getting back fewer images.
|
|
251
|
+
const reason = (item as { raiFilteredReason?: string }).raiFilteredReason
|
|
252
|
+
if (reason) {
|
|
253
|
+
filterReasons.push(reason)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Every entry was filtered — no usable images to return. Throw rather
|
|
258
|
+
// than resolve to an empty array so the caller is forced to handle the
|
|
259
|
+
// failure mode explicitly.
|
|
260
|
+
if (entries.length > 0 && images.length === 0) {
|
|
261
|
+
const joined = filterReasons.length > 0 ? filterReasons.join('; ') : ''
|
|
262
|
+
throw new Error(
|
|
263
|
+
`Imagen ${model} returned no images: all ${entries.length} generated image(s) were filtered by Responsible-AI${joined ? ` (${joined})` : ''}.`,
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Partial filter: surface via console.warn since ImageGenerationResult
|
|
268
|
+
// has no warnings field. Callers that care can still inspect the count
|
|
269
|
+
// mismatch between requested and returned images.
|
|
270
|
+
if (filterReasons.length > 0 && typeof console !== 'undefined') {
|
|
271
|
+
console.warn(
|
|
272
|
+
`[gemini-image] ${filterReasons.length} of ${entries.length} images from ${model} were filtered by Responsible-AI: ${filterReasons.join('; ')}`,
|
|
273
|
+
)
|
|
274
|
+
}
|
|
198
275
|
|
|
199
276
|
return {
|
|
200
277
|
id: generateId(this.name),
|
|
@@ -81,8 +81,14 @@ export class GeminiSummarizeAdapter<
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
async summarize(options: SummarizationOptions): Promise<SummarizationResult> {
|
|
84
|
+
const { logger } = options
|
|
84
85
|
const model = options.model
|
|
85
86
|
|
|
87
|
+
logger.request(`activity=summarize provider=gemini`, {
|
|
88
|
+
provider: 'gemini',
|
|
89
|
+
model,
|
|
90
|
+
})
|
|
91
|
+
|
|
86
92
|
// Build the system prompt based on format
|
|
87
93
|
const formatInstructions = this.getFormatInstructions(options.style)
|
|
88
94
|
const lengthInstructions = options.maxLength
|
|
@@ -91,40 +97,49 @@ export class GeminiSummarizeAdapter<
|
|
|
91
97
|
|
|
92
98
|
const systemPrompt = `You are a helpful assistant that summarizes text. ${formatInstructions}${lengthInstructions}`
|
|
93
99
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
try {
|
|
101
|
+
const response = await this.client.models.generateContent({
|
|
102
|
+
model,
|
|
103
|
+
contents: [
|
|
104
|
+
{
|
|
105
|
+
role: 'user',
|
|
106
|
+
parts: [
|
|
107
|
+
{ text: `Please summarize the following:\n\n${options.text}` },
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
config: {
|
|
112
|
+
systemInstruction: systemPrompt,
|
|
102
113
|
},
|
|
103
|
-
|
|
104
|
-
config: {
|
|
105
|
-
systemInstruction: systemPrompt,
|
|
106
|
-
},
|
|
107
|
-
})
|
|
114
|
+
})
|
|
108
115
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
116
|
+
const summary = response.text ?? ''
|
|
117
|
+
const inputTokens = response.usageMetadata?.promptTokenCount ?? 0
|
|
118
|
+
const outputTokens = response.usageMetadata?.candidatesTokenCount ?? 0
|
|
112
119
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
return {
|
|
121
|
+
id: generateId('sum'),
|
|
122
|
+
model,
|
|
123
|
+
summary,
|
|
124
|
+
usage: {
|
|
125
|
+
promptTokens: inputTokens,
|
|
126
|
+
completionTokens: outputTokens,
|
|
127
|
+
totalTokens: inputTokens + outputTokens,
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
} catch (error) {
|
|
131
|
+
logger.errors('gemini.summarize fatal', {
|
|
132
|
+
error,
|
|
133
|
+
source: 'gemini.summarize',
|
|
134
|
+
})
|
|
135
|
+
throw error
|
|
122
136
|
}
|
|
123
137
|
}
|
|
124
138
|
|
|
125
139
|
async *summarizeStream(
|
|
126
140
|
options: SummarizationOptions,
|
|
127
141
|
): AsyncIterable<StreamChunk> {
|
|
142
|
+
const { logger } = options
|
|
128
143
|
const model = options.model
|
|
129
144
|
const id = generateId('sum')
|
|
130
145
|
let accumulatedContent = ''
|
|
@@ -139,69 +154,85 @@ export class GeminiSummarizeAdapter<
|
|
|
139
154
|
|
|
140
155
|
const systemPrompt = `You are a helpful assistant that summarizes text. ${formatInstructions}${lengthInstructions}`
|
|
141
156
|
|
|
142
|
-
|
|
157
|
+
logger.request(`activity=summarize provider=gemini`, {
|
|
158
|
+
provider: 'gemini',
|
|
143
159
|
model,
|
|
144
|
-
|
|
145
|
-
{
|
|
146
|
-
role: 'user',
|
|
147
|
-
parts: [
|
|
148
|
-
{ text: `Please summarize the following:\n\n${options.text}` },
|
|
149
|
-
],
|
|
150
|
-
},
|
|
151
|
-
],
|
|
152
|
-
config: {
|
|
153
|
-
systemInstruction: systemPrompt,
|
|
154
|
-
},
|
|
160
|
+
stream: true,
|
|
155
161
|
})
|
|
156
162
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
+
try {
|
|
164
|
+
const result = await this.client.models.generateContentStream({
|
|
165
|
+
model,
|
|
166
|
+
contents: [
|
|
167
|
+
{
|
|
168
|
+
role: 'user',
|
|
169
|
+
parts: [
|
|
170
|
+
{ text: `Please summarize the following:\n\n${options.text}` },
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
config: {
|
|
175
|
+
systemInstruction: systemPrompt,
|
|
176
|
+
},
|
|
177
|
+
})
|
|
163
178
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
179
|
+
for await (const chunk of result) {
|
|
180
|
+
logger.provider(`provider=gemini`, { chunk })
|
|
181
|
+
// Track usage metadata
|
|
182
|
+
if (chunk.usageMetadata) {
|
|
183
|
+
inputTokens = chunk.usageMetadata.promptTokenCount ?? inputTokens
|
|
184
|
+
outputTokens =
|
|
185
|
+
chunk.usageMetadata.candidatesTokenCount ?? outputTokens
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (chunk.candidates?.[0]?.content?.parts) {
|
|
189
|
+
for (const part of chunk.candidates[0].content.parts) {
|
|
190
|
+
if (part.text) {
|
|
191
|
+
accumulatedContent += part.text
|
|
192
|
+
yield asChunk({
|
|
193
|
+
type: 'TEXT_MESSAGE_CONTENT',
|
|
194
|
+
messageId: id,
|
|
195
|
+
model,
|
|
196
|
+
timestamp: Date.now(),
|
|
197
|
+
delta: part.text,
|
|
198
|
+
content: accumulatedContent,
|
|
199
|
+
})
|
|
200
|
+
}
|
|
176
201
|
}
|
|
177
202
|
}
|
|
178
|
-
}
|
|
179
203
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
+
// Check for finish reason
|
|
205
|
+
const finishReason = chunk.candidates?.[0]?.finishReason
|
|
206
|
+
if (
|
|
207
|
+
finishReason === FinishReason.STOP ||
|
|
208
|
+
finishReason === FinishReason.MAX_TOKENS ||
|
|
209
|
+
finishReason === FinishReason.SAFETY
|
|
210
|
+
) {
|
|
211
|
+
yield asChunk({
|
|
212
|
+
type: 'RUN_FINISHED',
|
|
213
|
+
runId: id,
|
|
214
|
+
model,
|
|
215
|
+
timestamp: Date.now(),
|
|
216
|
+
finishReason:
|
|
217
|
+
finishReason === FinishReason.STOP
|
|
218
|
+
? 'stop'
|
|
219
|
+
: finishReason === FinishReason.MAX_TOKENS
|
|
220
|
+
? 'length'
|
|
221
|
+
: 'content_filter',
|
|
222
|
+
usage: {
|
|
223
|
+
promptTokens: inputTokens,
|
|
224
|
+
completionTokens: outputTokens,
|
|
225
|
+
totalTokens: inputTokens + outputTokens,
|
|
226
|
+
},
|
|
227
|
+
})
|
|
228
|
+
}
|
|
204
229
|
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
logger.errors('gemini.summarize fatal', {
|
|
232
|
+
error,
|
|
233
|
+
source: 'gemini.summarize',
|
|
234
|
+
})
|
|
235
|
+
throw error
|
|
205
236
|
}
|
|
206
237
|
}
|
|
207
238
|
|
package/src/adapters/text.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
StructuredOutputOptions,
|
|
17
17
|
StructuredOutputResult,
|
|
18
18
|
} from '@tanstack/ai/adapters'
|
|
19
|
+
import type { InternalLogger } from '@tanstack/ai/adapter-internals'
|
|
19
20
|
import type {
|
|
20
21
|
Content,
|
|
21
22
|
GenerateContentParameters,
|
|
@@ -119,14 +120,23 @@ export class GeminiTextAdapter<
|
|
|
119
120
|
options: TextOptions<GeminiTextProviderOptions>,
|
|
120
121
|
): AsyncIterable<StreamChunk> {
|
|
121
122
|
const mappedOptions = this.mapCommonOptionsToGemini(options)
|
|
123
|
+
const { logger } = options
|
|
122
124
|
|
|
123
125
|
try {
|
|
126
|
+
logger.request(
|
|
127
|
+
`activity=chat provider=gemini model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`,
|
|
128
|
+
{ provider: 'gemini', model: this.model },
|
|
129
|
+
)
|
|
124
130
|
const result =
|
|
125
131
|
await this.client.models.generateContentStream(mappedOptions)
|
|
126
132
|
|
|
127
|
-
yield* this.processStreamChunks(result, options)
|
|
133
|
+
yield* this.processStreamChunks(result, options, logger)
|
|
128
134
|
} catch (error) {
|
|
129
135
|
const timestamp = Date.now()
|
|
136
|
+
logger.errors('gemini.chatStream fatal', {
|
|
137
|
+
error,
|
|
138
|
+
source: 'gemini.chatStream',
|
|
139
|
+
})
|
|
130
140
|
yield asChunk({
|
|
131
141
|
type: 'RUN_ERROR',
|
|
132
142
|
model: options.model,
|
|
@@ -154,10 +164,15 @@ export class GeminiTextAdapter<
|
|
|
154
164
|
options: StructuredOutputOptions<GeminiTextProviderOptions>,
|
|
155
165
|
): Promise<StructuredOutputResult<unknown>> {
|
|
156
166
|
const { chatOptions, outputSchema } = options
|
|
167
|
+
const { logger } = chatOptions
|
|
157
168
|
|
|
158
169
|
const mappedOptions = this.mapCommonOptionsToGemini(chatOptions)
|
|
159
170
|
|
|
160
171
|
try {
|
|
172
|
+
logger.request(
|
|
173
|
+
`activity=chat provider=gemini model=${this.model} messages=${chatOptions.messages.length} tools=${chatOptions.tools?.length ?? 0} stream=false`,
|
|
174
|
+
{ provider: 'gemini', model: this.model },
|
|
175
|
+
)
|
|
161
176
|
// Add structured output configuration
|
|
162
177
|
const result = await this.client.models.generateContent({
|
|
163
178
|
...mappedOptions,
|
|
@@ -186,6 +201,10 @@ export class GeminiTextAdapter<
|
|
|
186
201
|
rawText,
|
|
187
202
|
}
|
|
188
203
|
} catch (error) {
|
|
204
|
+
logger.errors('gemini.structuredOutput fatal', {
|
|
205
|
+
error,
|
|
206
|
+
source: 'gemini.structuredOutput',
|
|
207
|
+
})
|
|
189
208
|
throw new Error(
|
|
190
209
|
error instanceof Error
|
|
191
210
|
? error.message
|
|
@@ -214,6 +233,7 @@ export class GeminiTextAdapter<
|
|
|
214
233
|
private async *processStreamChunks(
|
|
215
234
|
result: AsyncGenerator<GenerateContentResponse, unknown, unknown>,
|
|
216
235
|
options: TextOptions<GeminiTextProviderOptions>,
|
|
236
|
+
logger: InternalLogger,
|
|
217
237
|
): AsyncIterable<StreamChunk> {
|
|
218
238
|
const model = options.model
|
|
219
239
|
const timestamp = Date.now()
|
|
@@ -243,6 +263,7 @@ export class GeminiTextAdapter<
|
|
|
243
263
|
let hasEmittedStepStarted = false
|
|
244
264
|
|
|
245
265
|
for await (const chunk of result) {
|
|
266
|
+
logger.provider(`provider=gemini`, { chunk })
|
|
246
267
|
// Emit RUN_STARTED on first chunk
|
|
247
268
|
if (!hasEmittedRunStarted) {
|
|
248
269
|
hasEmittedRunStarted = true
|