119 lines
2.8 KiB
PHP
119 lines
2.8 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace tobimori\Seo\Ai\Drivers;
|
||
|
|
|
||
|
|
use Generator;
|
||
|
|
use tobimori\Seo\Ai\Chunk;
|
||
|
|
use tobimori\Seo\Ai\Content;
|
||
|
|
use tobimori\Seo\Ai\Driver;
|
||
|
|
use tobimori\Seo\Ai\SseStream;
|
||
|
|
|
||
|
|
class OpenAi extends Driver
|
||
|
|
{
|
||
|
|
protected const string DEFAULT_ENDPOINT = 'https://api.openai.com/v1/responses';
|
||
|
|
protected const string DEFAULT_MODEL = 'gpt-5-mini-2025-08-07';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @inheritDoc
|
||
|
|
*/
|
||
|
|
public function stream(
|
||
|
|
array $content,
|
||
|
|
string|null $model = null,
|
||
|
|
): Generator {
|
||
|
|
$apiKey = $this->config('apiKey', required: true);
|
||
|
|
$headers = [
|
||
|
|
'Content-Type: application/json',
|
||
|
|
'Accept: text/event-stream',
|
||
|
|
"Authorization: Bearer {$apiKey}",
|
||
|
|
];
|
||
|
|
if ($organization = $this->config('organization')) {
|
||
|
|
$headers[] = "OpenAI-Organization: {$organization}";
|
||
|
|
}
|
||
|
|
|
||
|
|
$payload = [
|
||
|
|
'model' => $model ?? $this->config('model', static::DEFAULT_MODEL),
|
||
|
|
'input' => $this->buildInput($content),
|
||
|
|
// instructions does not work for e.g. openrouter so let's just put everything in user prompt
|
||
|
|
'stream' => true,
|
||
|
|
];
|
||
|
|
|
||
|
|
$stream = new SseStream($this->config('endpoint', static::DEFAULT_ENDPOINT), $headers, $payload, (int)$this->config('timeout', 120));
|
||
|
|
yield from $stream->stream(function (array $event): Generator {
|
||
|
|
$type = $event['type'] ?? null;
|
||
|
|
|
||
|
|
if ($type === 'response.created') {
|
||
|
|
yield Chunk::streamStart($event);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($type === 'response.in_progress') {
|
||
|
|
yield Chunk::textStart($event);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($type === 'response.output_text.delta') {
|
||
|
|
$delta = $event['delta'] ?? '';
|
||
|
|
if ($delta !== '') {
|
||
|
|
yield Chunk::textDelta($delta, $event);
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($type === 'response.output_text.done') {
|
||
|
|
yield Chunk::textComplete($event);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($type === 'response.completed') {
|
||
|
|
yield Chunk::streamEnd($event);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($type === 'response.output_item.added' && ($event['item']['type'] ?? null) === 'reasoning') {
|
||
|
|
yield Chunk::thinkingStart($event);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($type === 'response.error') {
|
||
|
|
$message = $event['error']['message'] ?? 'Unknown OpenAI streaming error.';
|
||
|
|
yield Chunk::error($message, $event);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Translates an array of Content messages into the OpenAI Responses API input format.
|
||
|
|
*
|
||
|
|
* @param array<Content> $content
|
||
|
|
*/
|
||
|
|
private function buildInput(array $content): array
|
||
|
|
{
|
||
|
|
$input = [];
|
||
|
|
|
||
|
|
foreach ($content as $message) {
|
||
|
|
$blocks = [];
|
||
|
|
|
||
|
|
foreach ($message->blocks() as $block) {
|
||
|
|
if ($block['type'] === 'image') {
|
||
|
|
$blocks[] = [
|
||
|
|
'type' => 'input_image',
|
||
|
|
'image_url' => "data:{$block['mediaType']};base64,{$block['data']}",
|
||
|
|
];
|
||
|
|
} elseif ($block['type'] === 'text') {
|
||
|
|
$blocks[] = [
|
||
|
|
'type' => 'input_text',
|
||
|
|
'text' => $block['text'],
|
||
|
|
];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$input[] = [
|
||
|
|
'role' => $message->role(),
|
||
|
|
'content' => $blocks,
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
return $input;
|
||
|
|
}
|
||
|
|
}
|