streamEvents method

  1. @override
Stream<ProviderEvent> streamEvents(
  1. List<ChatMessage> conversation
)
override

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;
    }

    final candidates = json['candidates'] as List<Object?>?;
    if (candidates == null || candidates.isEmpty) continue;
    final content =
        (candidates.first as Map<String, Object?>)['content']
            as Map<String, Object?>?;
    final parts = content?['parts'] as List<Object?>?;
    if (parts == null) 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,
          ),
        );
      }
    }
  }
}