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

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