main: 引入 AgentProvider 流式事件与 OpenAI 兼容适配

- 增加流式事件流支持,Provider 输出 `message.delta` 等事件
- 实现 OpenAI 兼容适配器,包括 RequestBuilder、ApiClient 等模块
- 更新 Agent Run 逻辑,支持流式增量写入与模型完成状态管理
- 扩展配置项 `agent.openai.*`,支持模型、密钥等配置
- 优化文档,完善流式事件与消息类型说明
- 增加单元测试,覆盖 Provider 和 OpenAI 适配相关逻辑
- 更新环境变量与配置示例,支持新功能
This commit is contained in:
2025-12-19 02:35:37 +08:00
parent 56523c1f0a
commit 8c4ad80dab
27 changed files with 1006 additions and 166 deletions

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Services\Agent\OpenAi;
use App\Models\Message;
use App\Services\Agent\AgentContext;
class ChatCompletionsRequestBuilder
{
public function __construct(
private ?string $model = null,
private ?float $temperature = null,
private ?float $topP = null,
private ?bool $includeUsage = null,
) {
$this->model = $this->model ?? (string) config('agent.openai.model', 'gpt-4o-mini');
$this->temperature = $this->temperature ?? (float) config('agent.openai.temperature', 0.7);
$this->topP = $this->topP ?? (float) config('agent.openai.top_p', 1.0);
$this->includeUsage = $this->includeUsage ?? (bool) config('agent.openai.include_usage', false);
}
/**
* Builds an OpenAI-compatible Chat Completions payload from AgentContext.
*
* @param array<string, mixed> $options
* @return array<string, mixed>
*/
public function build(AgentContext $context, array $options = []): array
{
$payload = [
'model' => (string) ($options['model'] ?? $this->model),
'messages' => $this->buildMessages($context),
'stream' => true,
];
if (array_key_exists('temperature', $options)) {
$payload['temperature'] = (float) $options['temperature'];
} else {
$payload['temperature'] = (float) $this->temperature;
}
if (array_key_exists('top_p', $options)) {
$payload['top_p'] = (float) $options['top_p'];
} else {
$payload['top_p'] = (float) $this->topP;
}
if (array_key_exists('max_tokens', $options)) {
$payload['max_tokens'] = (int) $options['max_tokens'];
}
if (array_key_exists('stop', $options)) {
$payload['stop'] = $options['stop'];
}
if (array_key_exists('stream_options', $options)) {
$payload['stream_options'] = $options['stream_options'];
} elseif ($this->includeUsage) {
$payload['stream_options'] = ['include_usage' => true];
}
if (array_key_exists('response_format', $options)) {
$payload['response_format'] = $options['response_format'];
}
return $payload;
}
/**
* @return array<int, array{role: string, content: string}>
*/
private function buildMessages(AgentContext $context): array
{
$messages = [];
if ($context->systemPrompt !== '') {
$messages[] = [
'role' => 'system',
'content' => $context->systemPrompt,
];
}
foreach ($context->messages as $message) {
$role = $this->mapRole((string) ($message['role'] ?? ''));
$content = $message['content'] ?? null;
if (! $role || ! is_string($content) || $content === '') {
continue;
}
$messages[] = [
'role' => $role,
'content' => $content,
];
}
return $messages;
}
private function mapRole(string $role): ?string
{
return match ($role) {
Message::ROLE_USER => 'user',
Message::ROLE_AGENT => 'assistant',
Message::ROLE_SYSTEM => 'system',
default => null,
};
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Services\Agent\OpenAi;
use App\Services\Agent\ProviderException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Psr\Http\Message\ResponseInterface;
class OpenAiApiClient
{
public function __construct(
private ?string $baseUrl = null,
private ?string $apiKey = null,
private ?string $organization = null,
private ?string $project = null,
private ?int $timeoutSeconds = null,
private ?int $connectTimeoutSeconds = null,
) {
$this->baseUrl = $this->baseUrl ?? (string) config('agent.openai.base_url', '');
$this->apiKey = $this->apiKey ?? (string) config('agent.openai.api_key', '');
$this->organization = $this->organization ?? (string) config('agent.openai.organization', '');
$this->project = $this->project ?? (string) config('agent.openai.project', '');
$this->timeoutSeconds = $this->timeoutSeconds ?? (int) config('agent.provider.timeout_seconds', 30);
$this->connectTimeoutSeconds = $this->connectTimeoutSeconds ?? (int) config('agent.provider.connect_timeout_seconds', 5);
}
/**
* Opens a streaming response for the Chat Completions endpoint.
*
* @param array<string, mixed> $payload
*/
public function openStream(array $payload): ResponseInterface
{
$baseUrl = trim((string) $this->baseUrl);
$apiKey = trim((string) $this->apiKey);
if ($baseUrl === '' || $apiKey === '') {
throw new ProviderException('CONFIG_MISSING', 'Agent provider configuration missing', false);
}
$endpoint = rtrim($baseUrl, '/').'/chat/completions';
$headers = [
'Authorization' => 'Bearer '.$apiKey,
'Accept' => 'text/event-stream',
];
if (trim((string) $this->organization) !== '') {
$headers['OpenAI-Organization'] = (string) $this->organization;
}
if (trim((string) $this->project) !== '') {
$headers['OpenAI-Project'] = (string) $this->project;
}
try {
$response = Http::withHeaders($headers)
->connectTimeout($this->connectTimeoutSeconds)
->timeout($this->timeoutSeconds)
->withOptions(['stream' => true])
->post($endpoint, $payload);
} catch (ConnectionException $exception) {
throw new ProviderException(
'CONNECTION_FAILED',
'Agent provider connection failed',
true,
null,
$exception->getMessage()
);
}
$status = $response->status();
if ($status < 200 || $status >= 300) {
$retryable = $status === 429 || $status >= 500;
throw new ProviderException(
'HTTP_ERROR',
'Agent provider failed',
$retryable,
$status,
$response->body()
);
}
return $response->toPsrResponse();
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Services\Agent\OpenAi;
use App\Services\Agent\AgentContext;
use App\Services\Agent\AgentPlatformAdapterInterface;
use App\Services\Agent\ProviderEvent;
use App\Services\Agent\ProviderEventType;
use App\Services\Agent\ProviderException;
class OpenAiChatCompletionsAdapter implements AgentPlatformAdapterInterface
{
public function __construct(
private readonly ChatCompletionsRequestBuilder $requestBuilder,
private readonly OpenAiApiClient $apiClient,
private readonly OpenAiStreamParser $streamParser,
private readonly OpenAiEventNormalizer $eventNormalizer,
private ?int $retryTimes = null,
private ?int $retryBackoffMs = null,
) {
$this->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<string, mixed> $options
* @return \Generator<int, ProviderEvent>
*/
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';
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Services\Agent\OpenAi;
use App\Services\Agent\ProviderEvent;
class OpenAiEventNormalizer
{
/**
* Normalizes a single SSE payload into ProviderEvent values.
*
* @return array<int, ProviderEvent>
*/
public function normalize(string $payload): array
{
if (trim($payload) === '[DONE]') {
return [ProviderEvent::done('done')];
}
$decoded = json_decode($payload, true);
if (! is_array($decoded)) {
return [ProviderEvent::error('INVALID_JSON', 'Agent provider returned invalid JSON', [
'raw' => $payload,
])];
}
$events = [];
$choices = $decoded['choices'] ?? [];
$firstChoice = is_array($choices) ? ($choices[0] ?? null) : null;
$delta = is_array($firstChoice) ? ($firstChoice['delta'] ?? null) : null;
if (is_array($delta)) {
$content = $delta['content'] ?? null;
if (is_string($content) && $content !== '') {
$events[] = ProviderEvent::messageDelta($content);
}
}
if (is_array($firstChoice) && array_key_exists('finish_reason', $firstChoice) && $firstChoice['finish_reason'] !== null) {
$events[] = ProviderEvent::done((string) $firstChoice['finish_reason']);
}
if (isset($decoded['usage']) && is_array($decoded['usage'])) {
$events[] = ProviderEvent::usage($decoded['usage']);
}
return $events;
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Services\Agent\OpenAi;
use Psr\Http\Message\StreamInterface;
class OpenAiStreamParser
{
public function __construct(private readonly int $chunkSize = 1024)
{
}
/**
* Parses SSE data lines into payload strings.
*
* @return \Generator<int, string>
*/
public function parse(StreamInterface $stream, ?callable $shouldStop = null): \Generator
{
$buffer = '';
$eventData = '';
while (! $stream->eof()) {
if ($shouldStop && $shouldStop()) {
break;
}
$chunk = $stream->read($this->chunkSize);
if ($chunk === '') {
usleep(10000);
continue;
}
$buffer .= $chunk;
while (($pos = strpos($buffer, "\n")) !== false) {
$line = substr($buffer, 0, $pos);
$buffer = substr($buffer, $pos + 1);
$line = rtrim($line, "\r");
if ($line === '') {
if ($eventData !== '') {
yield $eventData;
$eventData = '';
}
continue;
}
if (str_starts_with($line, 'data:')) {
$data = ltrim(substr($line, 5));
if ($eventData !== '') {
$eventData .= "\n";
}
$eventData .= $data;
}
}
}
if ($buffer !== '') {
$line = rtrim($buffer, "\r");
if (str_starts_with($line, 'data:')) {
$data = ltrim(substr($line, 5));
if ($eventData !== '') {
$eventData .= "\n";
}
$eventData .= $data;
}
}
if ($eventData !== '') {
yield $eventData;
}
}
}