main: 引入 AgentProvider 流式事件与 OpenAI 兼容适配
- 增加流式事件流支持,Provider 输出 `message.delta` 等事件 - 实现 OpenAI 兼容适配器,包括 RequestBuilder、ApiClient 等模块 - 更新 Agent Run 逻辑,支持流式增量写入与模型完成状态管理 - 扩展配置项 `agent.openai.*`,支持模型、密钥等配置 - 优化文档,完善流式事件与消息类型说明 - 增加单元测试,覆盖 Provider 和 OpenAI 适配相关逻辑 - 更新环境变量与配置示例,支持新功能
This commit is contained in:
109
app/Services/Agent/OpenAi/ChatCompletionsRequestBuilder.php
Normal file
109
app/Services/Agent/OpenAi/ChatCompletionsRequestBuilder.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
87
app/Services/Agent/OpenAi/OpenAiApiClient.php
Normal file
87
app/Services/Agent/OpenAi/OpenAiApiClient.php
Normal 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();
|
||||
}
|
||||
}
|
||||
102
app/Services/Agent/OpenAi/OpenAiChatCompletionsAdapter.php
Normal file
102
app/Services/Agent/OpenAi/OpenAiChatCompletionsAdapter.php
Normal 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';
|
||||
}
|
||||
}
|
||||
50
app/Services/Agent/OpenAi/OpenAiEventNormalizer.php
Normal file
50
app/Services/Agent/OpenAi/OpenAiEventNormalizer.php
Normal 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;
|
||||
}
|
||||
}
|
||||
75
app/Services/Agent/OpenAi/OpenAiStreamParser.php
Normal file
75
app/Services/Agent/OpenAi/OpenAiStreamParser.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user