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,114 @@
<?php
namespace Tests\Unit;
use App\Models\Message;
use App\Services\Agent\AgentContext;
use App\Services\Agent\OpenAi\ChatCompletionsRequestBuilder;
use App\Services\Agent\OpenAi\OpenAiEventNormalizer;
use App\Services\Agent\OpenAi\OpenAiStreamParser;
use App\Services\Agent\ProviderEventType;
use GuzzleHttp\Psr7\Utils;
use Tests\TestCase;
class OpenAiAdapterTest extends TestCase
{
public function test_request_builder_maps_context_to_openai_payload(): void
{
config()->set('agent.openai.model', 'test-model');
config()->set('agent.openai.temperature', 0.2);
config()->set('agent.openai.top_p', 0.9);
config()->set('agent.openai.include_usage', true);
$context = new AgentContext('run-1', 'session-1', 'system prompt', [
[
'message_id' => 'm1',
'role' => Message::ROLE_USER,
'type' => 'user.prompt',
'content' => 'hello',
'seq' => 1,
],
[
'message_id' => 'm2',
'role' => Message::ROLE_AGENT,
'type' => 'agent.message',
'content' => 'hi',
'seq' => 2,
],
]);
$payload = (new ChatCompletionsRequestBuilder())->build($context);
$this->assertSame('test-model', $payload['model']);
$this->assertTrue($payload['stream']);
$this->assertSame(0.2, $payload['temperature']);
$this->assertSame(0.9, $payload['top_p']);
$this->assertSame(['include_usage' => true], $payload['stream_options']);
$this->assertSame([
['role' => 'system', 'content' => 'system prompt'],
['role' => 'user', 'content' => 'hello'],
['role' => 'assistant', 'content' => 'hi'],
], $payload['messages']);
}
public function test_event_normalizer_maps_delta_and_done(): void
{
$normalizer = new OpenAiEventNormalizer();
$delta = json_encode([
'choices' => [
[
'delta' => ['content' => 'Hi'],
'finish_reason' => null,
],
],
]);
$events = $normalizer->normalize($delta);
$this->assertCount(1, $events);
$this->assertSame(ProviderEventType::MessageDelta, $events[0]->type);
$this->assertSame('Hi', $events[0]->payload['text']);
$done = json_encode([
'choices' => [
[
'delta' => [],
'finish_reason' => 'stop',
],
],
]);
$events = $normalizer->normalize($done);
$this->assertCount(1, $events);
$this->assertSame(ProviderEventType::Done, $events[0]->type);
$this->assertSame('stop', $events[0]->payload['reason']);
}
public function test_event_normalizer_handles_invalid_json(): void
{
$normalizer = new OpenAiEventNormalizer();
$events = $normalizer->normalize('{invalid');
$this->assertCount(1, $events);
$this->assertSame(ProviderEventType::Error, $events[0]->type);
$this->assertSame('INVALID_JSON', $events[0]->payload['code']);
}
public function test_event_normalizer_handles_done_marker(): void
{
$normalizer = new OpenAiEventNormalizer();
$events = $normalizer->normalize('[DONE]');
$this->assertCount(1, $events);
$this->assertSame(ProviderEventType::Done, $events[0]->type);
}
public function test_stream_parser_splits_sse_events(): void
{
$stream = Utils::streamFor("data: {\"id\":1}\n\ndata: [DONE]\n\n");
$parser = new OpenAiStreamParser(5);
$chunks = iterator_to_array($parser->parse($stream));
$this->assertSame(['{"id":1}', '[DONE]'], $chunks);
}
}