streamChat method

  1. @override
Stream<Object> streamChat(
  1. List<AiChatMessage> messages, {
  2. required String model,
  3. double? temperature,
  4. int? maxTokens,
  5. Map<String, Object?> extraBody = const {},
})
override

Streams a chat completion as a sequence of typed events.

Emits AiTextDelta for each content chunk, AiToolCall when the model invokes tools, and terminates with AiDone. Errors surface as AiError (the stream does not throw — UI code can just switch on event types).

Implementation

@override
Stream<Object> streamChat(
  List<AiChatMessage> messages, {
  required String model,
  double? temperature,
  int? maxTokens,
  Map<String, Object?> extraBody = const {},
}) async* {
  final uri = Uri.parse('$baseUrl/chat/completions');
  final body = jsonEncode({
    'model': model,
    'messages': [for (final m in messages) m.toJson()],
    'stream': true,
    ?temperature: temperature,
    ?maxTokens: maxTokens,
    ...extraBody,
  });

  final request = http.Request('POST', uri)
    ..headers['Content-Type'] = 'application/json'
    ..headers['Accept'] = 'text/event-stream';
  if (apiKey != null && apiKey!.isNotEmpty) {
    request.headers['Authorization'] = 'Bearer $apiKey';
  }
  _extraHeaders.forEach((name, value) {
    request.headers[name] = value;
  });
  request.body = body;

  final response = await _client.send(request);
  if (response.statusCode != 200) {
    final errorBody = await response.stream.bytesToString();
    yield AiError(
      'Request failed with HTTP ${response.statusCode}: $errorBody',
      statusCode: response.statusCode,
    );
    return;
  }

  final parser = SseParser();
  final usageCollector = _UsageCollector();
  final toolAssembler = _ToolCallAssembler();

  await for (final chunk in response.stream.transform(
    const Utf8Decoder(allowMalformed: true),
  )) {
    for (final event in parser.addChunk(chunk)) {
      for (final parsed in _parseEvent(
        event,
        usageCollector,
        toolAssembler,
      )) {
        if (parsed is _DoneSignal) {
          // Tool-call fragments are complete once the stream signals a
          // finish reason — emit the assembled calls before terminating.
          for (final call in toolAssembler.flush()) {
            yield call;
          }
          yield AiDone(
            finishReason: parsed.finishReason ?? 'stop',
            usage: usageCollector.snapshot(),
          );
          return;
        }
        yield parsed;
      }
    }
  }

  // Stream ended without [DONE] — still terminate cleanly.
  for (final call in toolAssembler.flush()) {
    yield call;
  }
  yield AiDone(finishReason: 'stop', usage: usageCollector.snapshot());
}