retryTimes = $this->retryTimes ?? (int) config('agent.provider.retry_times', 1); $this->retryBackoffMs = $this->retryBackoffMs ?? (int) config('agent.provider.retry_backoff_ms', 500); } /** * Streams OpenAI-compatible chat completions and yields normalized events. * * @param array $options * @return \Generator */ public function stream(AgentContext $context, array $options = []): \Generator { $payload = $this->requestBuilder->build($context, $options); $attempts = $this->retryTimes + 1; $attempt = 1; $backoffMs = $this->retryBackoffMs; $hasYielded = false; $shouldStop = $options['should_stop'] ?? null; while (true) { try { $response = $this->apiClient->openStream($payload); $stream = $response->getBody(); try { foreach ($this->streamParser->parse($stream, is_callable($shouldStop) ? $shouldStop : null) as $chunk) { $events = $this->eventNormalizer->normalize($chunk); foreach ($events as $event) { $hasYielded = true; yield $event; if ($event->type === ProviderEventType::Done || $event->type === ProviderEventType::Error) { return; } } } } finally { $stream->close(); } if (! $hasYielded) { if (is_callable($shouldStop) && $shouldStop()) { return; } yield ProviderEvent::error('EMPTY_STREAM', 'Agent provider returned empty stream'); } return; } catch (ProviderException $exception) { if (! $hasYielded && is_callable($shouldStop) && $shouldStop()) { return; } if (! $hasYielded && $exception->retryable && $attempt < $attempts) { usleep($backoffMs * 1000); $attempt++; $backoffMs *= 2; continue; } yield ProviderEvent::error($exception->errorCode, $exception->getMessage(), [ 'retryable' => $exception->retryable, 'http_status' => $exception->httpStatus, 'raw_message' => $exception->rawMessage, ]); return; } catch (\Throwable $exception) { if (! $hasYielded && is_callable($shouldStop) && $shouldStop()) { return; } yield ProviderEvent::error('UNKNOWN_ERROR', $exception->getMessage()); return; } } } public function name(): string { return 'openai.chat.completions'; } }