streamEvents method
Streams structured events for conversation's next assistant turn.
conversation is oldest-first; its last element is the new user turn.
Implementation
@override
Stream<ProviderEvent> streamEvents(List<ChatMessage> conversation) async* {
final systemText = conversation
.where((m) => m.role == ChatRole.system)
.map((m) => m.text)
.where((t) => t.isNotEmpty)
.join('\n\n');
final request = http.Request('POST', _endpoint)
..headers['Content-Type'] = 'application/json'
..headers['Accept'] = 'text/event-stream'
..body = jsonEncode({
if (systemText.isNotEmpty)
'systemInstruction': {
'parts': [
{'text': systemText},
],
},
'contents': _toGeminiContents(conversation),
if (functionDeclarations != null)
'tools': [
{'functionDeclarations': functionDeclarations},
],
...?extraParams,
});
final response = await _httpClient.send(request);
if (response.statusCode < 200 || response.statusCode >= 300) {
final body = await response.stream.bytesToString();
throw AgentProviderException(response.statusCode, body);
}
var callCounter = 0;
// No [DONE] sentinel here, unlike OpenRouter -- the SSE stream simply
// ends when the HTTP response completes, and sseDataEvents' async*
// terminates naturally at that point.
await for (final payload in sseDataEvents(response.stream)) {
if (payload.isEmpty) continue;
final Map<String, Object?> json;
try {
json = jsonDecode(payload) as Map<String, Object?>;
} on FormatException {
continue;
}
// A payload that parses as valid JSON but has an unexpected shape
// (e.g. a proxy sends `candidates` as something other than a List)
// throws a TypeError from the casts below. Treat it the same as a
// FormatException -- skip the one bad chunk rather than failing the
// whole turn and losing everything accumulated so far.
try {
// Checked with `is` rather than `as` so a malformed `error` field
// (e.g. not a Map) can't itself throw a TypeError that gets
// swallowed by the catch below -- a real error must always surface.
final error = json['error'];
if (error is Map<String, Object?>) {
final code = error['code'];
throw AgentProviderException(
code is int ? code : response.statusCode,
error['message']?.toString() ?? jsonEncode(error),
);
}
final candidates = json['candidates'] as List<Object?>?;
if (candidates == null || candidates.isEmpty) continue;
final candidate = candidates.first as Map<String, Object?>;
final content = candidate['content'] as Map<String, Object?>?;
final parts = content?['parts'] as List<Object?>?;
if (parts == null || parts.isEmpty) {
// No content parts and a non-STOP finish reason means the model
// didn't actually produce (or finish) a response -- most commonly
// a safety block. Surfacing this as a failure beats silently
// completing the turn with nothing in it. Gemini represents "no
// content" as either an absent `parts` field or an empty list, so
// both are treated the same way here.
final finishReason = candidate['finishReason'] as String?;
if (finishReason != null && finishReason != 'STOP') {
throw AgentProviderException(
response.statusCode,
'Gemini stopped generating: $finishReason',
);
}
continue;
}
for (final rawPart in parts) {
final part = rawPart as Map<String, Object?>;
final text = part['text'] as String?;
if (text != null && text.isNotEmpty) {
yield TextDelta(text);
}
final functionCall = part['functionCall'] as Map<String, Object?>?;
if (functionCall != null) {
final name = functionCall['name'] as String?;
if (name == null) continue;
final args = functionCall['args'];
final input = args == null
? '{}'
: const JsonEncoder.withIndent(' ').convert(args);
callCounter++;
yield ToolCallEvent(
ToolCall(
// Gemini's functionCall carries no id -- synthesize one, the
// same no-uuid-dependency approach ChatMessage's own id
// generator uses.
id: '${DateTime.now().microsecondsSinceEpoch}-fc-$callCounter',
name: name,
status: ToolCallStatus.pending,
input: input,
),
);
}
}
} on TypeError {
continue;
}
}
}