main: 引入 AgentProvider 流式事件与 OpenAI 兼容适配
- 增加流式事件流支持,Provider 输出 `message.delta` 等事件 - 实现 OpenAI 兼容适配器,包括 RequestBuilder、ApiClient 等模块 - 更新 Agent Run 逻辑,支持流式增量写入与模型完成状态管理 - 扩展配置项 `agent.openai.*`,支持模型、密钥等配置 - 优化文档,完善流式事件与消息类型说明 - 增加单元测试,覆盖 Provider 和 OpenAI 适配相关逻辑 - 更新环境变量与配置示例,支持新功能
This commit is contained in:
@@ -4,8 +4,9 @@ namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\AgentRunJob;
|
||||
use App\Models\Message;
|
||||
use App\Services\Agent\AgentContext;
|
||||
use App\Services\Agent\AgentProviderInterface;
|
||||
use App\Services\Agent\ProviderException;
|
||||
use App\Services\Agent\ProviderEvent;
|
||||
use App\Services\CancelChecker;
|
||||
use App\Services\ChatService;
|
||||
use App\Services\RunDispatcher;
|
||||
@@ -204,14 +205,17 @@ class AgentRunTest extends TestCase
|
||||
$this->assertTrue($messages->contains(fn ($m) => $m->type === 'run.status' && ($m->payload['status'] ?? null) === 'CANCELED'));
|
||||
}
|
||||
|
||||
public function test_provider_exception_writes_error_and_failed_status(): void
|
||||
public function test_provider_error_event_writes_error_and_failed_status(): void
|
||||
{
|
||||
Queue::fake();
|
||||
$this->app->bind(AgentProviderInterface::class, function () {
|
||||
return new class implements AgentProviderInterface {
|
||||
public function generate(array $context, array $options = []): string
|
||||
public function stream(AgentContext $context, array $options = []): \Generator
|
||||
{
|
||||
throw new ProviderException('HTTP_ERROR', 'provider failed', true, 500, 'boom');
|
||||
yield ProviderEvent::error('HTTP_ERROR', 'provider failed', [
|
||||
'retryable' => true,
|
||||
'http_status' => 500,
|
||||
]);
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -229,16 +233,11 @@ class AgentRunTest extends TestCase
|
||||
|
||||
$runId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id);
|
||||
|
||||
try {
|
||||
(new AgentRunJob($session->session_id, $runId))->handle(
|
||||
app(RunLoop::class),
|
||||
app(OutputSink::class),
|
||||
app(CancelChecker::class)
|
||||
);
|
||||
$this->fail('Expected provider exception');
|
||||
} catch (ProviderException $exception) {
|
||||
$this->assertSame('HTTP_ERROR', $exception->errorCode);
|
||||
}
|
||||
(new AgentRunJob($session->session_id, $runId))->handle(
|
||||
app(RunLoop::class),
|
||||
app(OutputSink::class),
|
||||
app(CancelChecker::class)
|
||||
);
|
||||
|
||||
$messages = Message::query()
|
||||
->where('session_id', $session->session_id)
|
||||
|
||||
114
tests/Unit/OpenAiAdapterTest.php
Normal file
114
tests/Unit/OpenAiAdapterTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user