diff --git a/config/autoload/odin.php b/config/autoload/odin.php index df4e480..5d3ca2c 100644 --- a/config/autoload/odin.php +++ b/config/autoload/odin.php @@ -15,7 +15,7 @@ use Hyperf\Odin\Model\ChatglmModel; use Hyperf\Odin\Model\OllamaModel; use Hyperf\Odin\Model\OpenAIModel; -use Hyperf\Odin\Model\SkylarkModel; +use Hyperf\Odin\Model\DoubaoModel; use function Hyperf\Support\env; use function Hyperf\Support\value; @@ -137,7 +137,7 @@ ], ], 'skylark:character-4k' => [ - 'implementation' => SkylarkModel::class, + 'implementation' => DoubaoModel::class, 'config' => [ 'host' => env('SKYLARK_PRO_CHARACTER_4K_HOST', env('SKYLARK_PRO_HOST')), 'ak' => env('SKYLARK_PRO_CHARACTER_4K_AK', env('SKYLARK_PRO_AK')), @@ -148,7 +148,7 @@ ], ], 'skylark:turbo-8k' => [ - 'implementation' => SkylarkModel::class, + 'implementation' => DoubaoModel::class, 'config' => [ 'host' => env('SKYLARK_PRO_TURBO_8K_HOST', env('SKYLARK_PRO_HOST')), 'ak' => env('SKYLARK_PRO_TURBO_8K_AK', env('SKYLARK_PRO_AK')), @@ -159,7 +159,7 @@ ], ], 'skylark:32k' => [ - 'implementation' => SkylarkModel::class, + 'implementation' => DoubaoModel::class, 'config' => [ 'host' => env('SKYLARK_PRO_32K_HOST', env('SKYLARK_PRO_HOST')), 'ak' => env('SKYLARK_PRO_32K_AK', env('SKYLARK_PRO_AK')), @@ -170,7 +170,7 @@ ], ], 'skylark:4k' => [ - 'implementation' => SkylarkModel::class, + 'implementation' => DoubaoModel::class, 'config' => [ 'host' => env('SKYLARK_PRO_4K_HOST', env('SKYLARK_PRO_HOST')), 'ak' => env('SKYLARK_PRO_4K_AK', env('SKYLARK_PRO_AK')), @@ -181,7 +181,7 @@ ], ], 'skylark:lite-8k' => [ - 'implementation' => SkylarkModel::class, + 'implementation' => DoubaoModel::class, 'config' => [ 'host' => env('SKYLARK_PRO_LITE_8K_HOST', env('SKYLARK_PRO_HOST')), 'ak' => env('SKYLARK_PRO_LITE_8K_AK', env('SKYLARK_PRO_AK')), diff --git a/publish/odin.php b/publish/odin.php index 8e46f1c..fcf56d1 100644 --- a/publish/odin.php +++ b/publish/odin.php @@ -15,7 +15,7 @@ use Hyperf\Odin\Model\ChatglmModel; use Hyperf\Odin\Model\OllamaModel; use Hyperf\Odin\Model\OpenAIModel; -use Hyperf\Odin\Model\SkylarkModel; +use Hyperf\Odin\Model\DoubaoModel; use function Hyperf\Support\env; use function Hyperf\Support\value; @@ -138,7 +138,7 @@ ], ], 'skylark:character-4k' => [ - 'implementation' => SkylarkModel::class, + 'implementation' => DoubaoModel::class, 'config' => [ 'host' => env('SKYLARK_PRO_CHARACTER_4K_HOST', env('SKYLARK_PRO_HOST')), 'ak' => env('SKYLARK_PRO_CHARACTER_4K_AK', env('SKYLARK_PRO_AK')), @@ -149,7 +149,7 @@ ], ], 'skylark:turbo-8k' => [ - 'implementation' => SkylarkModel::class, + 'implementation' => DoubaoModel::class, 'config' => [ 'host' => env('SKYLARK_PRO_TURBO_8K_HOST', env('SKYLARK_PRO_HOST')), 'ak' => env('SKYLARK_PRO_TURBO_8K_AK', env('SKYLARK_PRO_AK')), @@ -160,7 +160,7 @@ ], ], 'skylark:32k' => [ - 'implementation' => SkylarkModel::class, + 'implementation' => DoubaoModel::class, 'config' => [ 'host' => env('SKYLARK_PRO_32K_HOST', env('SKYLARK_PRO_HOST')), 'ak' => env('SKYLARK_PRO_32K_AK', env('SKYLARK_PRO_AK')), @@ -171,7 +171,7 @@ ], ], 'skylark:4k' => [ - 'implementation' => SkylarkModel::class, + 'implementation' => DoubaoModel::class, 'config' => [ 'host' => env('SKYLARK_PRO_4K_HOST', env('SKYLARK_PRO_HOST')), 'ak' => env('SKYLARK_PRO_4K_AK', env('SKYLARK_PRO_AK')), @@ -182,7 +182,7 @@ ], ], 'skylark:lite-8k' => [ - 'implementation' => SkylarkModel::class, + 'implementation' => DoubaoModel::class, 'config' => [ 'host' => env('SKYLARK_PRO_LITE_8K_HOST', env('SKYLARK_PRO_HOST')), 'ak' => env('SKYLARK_PRO_LITE_8K_AK', env('SKYLARK_PRO_AK')), diff --git a/src/Agent/ToolsAgent.php b/src/Agent/ToolsAgent.php index a5e60b5..40cfff0 100644 --- a/src/Agent/ToolsAgent.php +++ b/src/Agent/ToolsAgent.php @@ -22,7 +22,7 @@ use Hyperf\Odin\Message\FunctionMessage; use Hyperf\Odin\Message\ToolMessage; use Hyperf\Odin\Model\ModelInterface; -use Hyperf\Odin\Model\SkylarkModel; +use Hyperf\Odin\Model\DoubaoModel; use Hyperf\Odin\Observer; use Hyperf\Odin\Prompt\PromptInterface; use Hyperf\Odin\Tool\ToolInterface; @@ -254,7 +254,7 @@ protected function chat( protected function response(ChatCompletionResponse $response): ChatCompletionResponse { - if ($this->model instanceof SkylarkModel) { + if ($this->model instanceof DoubaoModel) { $choices = $response->getChoices(); // 取 <|Answer|>: 后面的内容作为回答 foreach ($choices as $key => $choice) { @@ -279,7 +279,7 @@ protected function response(ChatCompletionResponse $response): ChatCompletionRes protected function transferMessages(array $messages): array { - if ($this->model instanceof SkylarkModel) { + if ($this->model instanceof DoubaoModel) { // 把里面的 ToolMessage 转为 FunctionMessage foreach ($messages as $key => $message) { if ($message instanceof ToolMessage) { diff --git a/src/Api/AzureOpenAI/AzureOpenAIConfig.php b/src/Api/AzureOpenAI/AzureOpenAIConfig.php index 4dcd7c4..c55c2ba 100644 --- a/src/Api/AzureOpenAI/AzureOpenAIConfig.php +++ b/src/Api/AzureOpenAI/AzureOpenAIConfig.php @@ -16,9 +16,7 @@ class AzureOpenAIConfig { public function __construct( protected array $config = [], - ) { - - } + ) {} public function getApiKey(): ?string { diff --git a/src/Api/AzureOpenAI/Client.php b/src/Api/AzureOpenAI/Client.php index b0f4d0d..374b289 100644 --- a/src/Api/AzureOpenAI/Client.php +++ b/src/Api/AzureOpenAI/Client.php @@ -21,7 +21,6 @@ use Hyperf\Odin\Exception\NotImplementedException; use Hyperf\Odin\Message\MessageInterface; use Hyperf\Odin\Tool\ToolInterface; -use InvalidArgumentException; use Psr\Log\LoggerInterface; class Client implements ClientInterface @@ -33,12 +32,13 @@ class Client implements ClientInterface */ protected array $clients = []; - protected ?LoggerInterface $logger; + protected ?LoggerInterface $logger = null; protected bool $debug = false; + protected string $model; - public function __construct(AzureOpenAIConfig $config, LoggerInterface $logger, string $model) + public function __construct(AzureOpenAIConfig $config, ?LoggerInterface $logger, string $model) { $this->logger = $logger; $this->model = $model; @@ -54,7 +54,7 @@ public function chat( array $tools = [], bool $stream = false, ): ChatCompletionResponse { - $deploymentPath = $this->buildDeploymentPath($model); + $deploymentPath = $this->buildDeploymentPath(); $messagesArr = []; foreach ($messages as $message) { if ($message instanceof MessageInterface) { @@ -65,6 +65,7 @@ public function chat( 'messages' => $messagesArr, 'model' => $model, 'temperature' => $temperature, + 'stream' => $stream, ]; if ($maxTokens) { $json['max_tokens'] = $maxTokens; @@ -91,10 +92,9 @@ public function chat( $this->debug && $this->logger?->debug(sprintf("Send Messages: %s\nTools: %s", json_encode($messagesArr, JSON_UNESCAPED_UNICODE), json_encode($tools, JSON_UNESCAPED_UNICODE))); $response = $this->getClient($model)->post($deploymentPath . '/chat/completions', [ 'query' => [ - 'api-version' => $this->config->getApiVersion($model), + 'api-version' => $this->config->getApiVersion(), ], 'json' => $json, - 'verify' => false, ]); $chatCompletionResponse = new ChatCompletionResponse($response); $this->debug && $this->logger?->debug('Receive: ' . $chatCompletionResponse); @@ -107,10 +107,10 @@ public function completions( float $temperature = 0.9, int $maxTokens = 200 ): TextCompletionResponse { - $deploymentPath = $this->buildDeploymentPath($model); + $deploymentPath = $this->buildDeploymentPath(); $response = $this->getClient($model)->post($deploymentPath . '/completions', [ 'query' => [ - 'api-version' => $this->config->getApiVersion($model), + 'api-version' => $this->config->getApiVersion(), ], 'json' => [ 'prompt' => $prompt, @@ -133,14 +133,14 @@ public function embedding( string $model = 'text-embedding-ada-002', ?string $user = null ): ListResponse { - $deploymentPath = $this->buildDeploymentPath($model); + $deploymentPath = $this->buildDeploymentPath(); $json = [ 'input' => $input, ]; $user && $json['user'] = $user; $response = $this->getClient($model)->post($deploymentPath . '/embeddings', [ 'query' => [ - 'api-version' => $this->config->getApiVersion($model), + 'api-version' => $this->config->getApiVersion(), ], 'json' => $json, 'verify' => false, @@ -161,9 +161,6 @@ public function setDebug(bool $debug): static protected function initConfig(AzureOpenAIConfig $config): static { - if (! $config instanceof AzureOpenAIConfig) { - throw new InvalidArgumentException('AzureOpenAIConfig is required'); - } $this->config = $config; $headers = [ 'api-key' => $config->getApiKey(), @@ -182,8 +179,8 @@ protected function getClient(string $model): ?GuzzleClient return $this->clients[$model]; } - protected function buildDeploymentPath(string $model = 'gpt-3.5-turbo'): string + protected function buildDeploymentPath(): string { - return 'openai/deployments/' . $this->config->getDeploymentName($model); + return 'openai/deployments/' . $this->config->getDeploymentName(); } } diff --git a/src/Api/Doubao/Client.php b/src/Api/Doubao/Client.php new file mode 100644 index 0000000..bba9161 --- /dev/null +++ b/src/Api/Doubao/Client.php @@ -0,0 +1,117 @@ +logger = $logger; + $this->initConfig($config); + } + + public function chat( + array $messages, + string $model, + float $temperature = 0.9, + int $maxTokens = 4096, + array $stop = [], + array $tools = [], + bool $stream = false, + ): ChatCompletionResponse { + $messagesArr = []; + foreach ($messages as $message) { + if ($message instanceof MessageInterface) { + $messagesArr[] = $message->toArray(); + } + } + $json = [ + 'stream' => $stream, + 'model' => $model, + 'messages' => $messagesArr, + 'temperature' => $temperature, + ]; + if ($maxTokens) { + $json['max_tokens'] = $maxTokens; + } + if (! empty($tools)) { + $toolsArray = []; + foreach ($tools as $tool) { + if ($tool instanceof ToolInterface) { + $toolsArray[] = $tool->toToolDefinition()->toArray(); + } elseif ($tool instanceof ToolDefinition) { + $toolsArray[] = $tool->toArray(); + } else { + $toolsArray[] = $tool; + } + } + if (! empty($toolsArray)) { + $json['tools'] = $toolsArray; + } + } + if ($stop) { + $json['stop'] = $stop; + } + $this->debug && $this->logger?->debug(sprintf("Send Messages: %s\nTools: %s", json_encode($messagesArr, JSON_UNESCAPED_UNICODE), json_encode($tools, JSON_UNESCAPED_UNICODE))); + $response = $this->client->post('/api/v3/chat/completions', [ + 'json' => $json, + ]); + $chatCompletionResponse = new ChatCompletionResponse($response); + $this->debug && $this->logger?->debug('Receive: ' . $chatCompletionResponse); + return $chatCompletionResponse; + } + + public function isDebug(): bool + { + return $this->debug; + } + + public function setDebug(bool $debug): static + { + $this->debug = $debug; + return $this; + } + + protected function initConfig(DoubaoConfig $config): static + { + $headers = [ + 'Content-Type' => 'application/json', + 'User-Agent' => 'Hyperf-Odin/1.0', + ]; + if ($config->getApiKey()) { + $headers['Authorization'] = 'Bearer ' . $config->getApiKey(); + } + $this->client = new GuzzleClient([ + 'base_uri' => $config->getBaseUrl(), + 'headers' => $headers, + ]); + $this->config = $config; + return $this; + } +} diff --git a/src/Api/Skylark/Skylark.php b/src/Api/Doubao/Doubao.php similarity index 59% rename from src/Api/Skylark/Skylark.php rename to src/Api/Doubao/Doubao.php index cf0b25d..4419d5a 100644 --- a/src/Api/Skylark/Skylark.php +++ b/src/Api/Doubao/Doubao.php @@ -10,12 +10,12 @@ * @license https://github.com/hyperf/hyperf/blob/master/LICENSE */ -namespace Hyperf\Odin\Api\Skylark; +namespace Hyperf\Odin\Api\Doubao; use Hyperf\Odin\Api\AbstractApi; use Hyperf\Odin\Logger; -class Skylark extends AbstractApi +class Doubao extends AbstractApi { /** * @var Client[] @@ -23,13 +23,13 @@ class Skylark extends AbstractApi protected array $clients = []; - public function getClient(SkylarkConfig $config): Client + public function getClient(DoubaoConfig $config): Client { - if ($config->getEndpoint() && isset($this->clients[$config->getEndpoint()])) { - return $this->clients[$config->getEndpoint()]; + if ($config->getApiKey() && isset($this->clients[$config->getApiKey()])) { + return $this->clients[$config->getApiKey()]; } $client = new Client($config, new Logger()); - $this->clients[$config->getEndpoint()] = $client; + $this->clients[$config->getApiKey()] = $client; return $client; } } diff --git a/src/Api/Doubao/DoubaoConfig.php b/src/Api/Doubao/DoubaoConfig.php new file mode 100644 index 0000000..67de85b --- /dev/null +++ b/src/Api/Doubao/DoubaoConfig.php @@ -0,0 +1,37 @@ +apiKey; + } + + public function getBaseUrl(): string + { + return $this->baseUrl; + } + + public function getModel(): string + { + return $this->model; + } +} diff --git a/src/Api/OpenAI/Client.php b/src/Api/OpenAI/Client.php index c9d7b0b..8d59353 100644 --- a/src/Api/OpenAI/Client.php +++ b/src/Api/OpenAI/Client.php @@ -32,7 +32,7 @@ class Client implements ClientInterface protected bool $debug = false; - public function __construct(OpenAIConfig $config, LoggerInterface $logger = null) + public function __construct(OpenAIConfig $config, ?LoggerInterface $logger = null) { $this->logger = $logger; $this->initConfig($config); @@ -57,6 +57,7 @@ public function chat( 'messages' => $messagesArr, 'model' => $model, 'temperature' => $temperature, + 'stream' => $stream, ]; if ($maxTokens) { $json['max_tokens'] = $maxTokens; diff --git a/src/Api/OpenAI/OpenAIConfig.php b/src/Api/OpenAI/OpenAIConfig.php index bd81b5c..47dffb6 100644 --- a/src/Api/OpenAI/OpenAIConfig.php +++ b/src/Api/OpenAI/OpenAIConfig.php @@ -1,18 +1,28 @@ apiKey = $apiKey; @@ -34,4 +44,4 @@ public function getBaseUrl(): string { return $this->baseUrl; } -} \ No newline at end of file +} diff --git a/src/Api/OpenAI/Response/ChatCompletionChoice.php b/src/Api/OpenAI/Response/ChatCompletionChoice.php index 5fadc6e..9e61fe4 100644 --- a/src/Api/OpenAI/Response/ChatCompletionChoice.php +++ b/src/Api/OpenAI/Response/ChatCompletionChoice.php @@ -22,12 +22,20 @@ public function __construct( public ?int $index = null, public ?string $logprobs = null, public ?string $finishReason = null - ) { - } + ) {} public static function fromArray(array $choice): static { - return new static(Message::fromArray($choice['message']), $choice['index'] ?? null, $choice['logprobs'] ?? null, $choice['finish_reason'] ?? null); + $message = $choice['message'] ?? []; + if (isset($choice['delta'])) { + $message = [ + 'role' => $choice['delta']['role'] ?? 'assistant', + 'content' => $choice['delta']['content'] ?? '', + 'tool_calls' => $choice['delta']['tool_calls'] ?? [], + ]; + } + + return new static(Message::fromArray($message), $choice['index'] ?? null, $choice['logprobs'] ?? null, $choice['finish_reason'] ?? null); } public function getMessage(): MessageInterface diff --git a/src/Api/OpenAI/Response/ChatCompletionResponse.php b/src/Api/OpenAI/Response/ChatCompletionResponse.php index 4ae3fe7..079c491 100644 --- a/src/Api/OpenAI/Response/ChatCompletionResponse.php +++ b/src/Api/OpenAI/Response/ChatCompletionResponse.php @@ -12,6 +12,10 @@ namespace Hyperf\Odin\Api\OpenAI\Response; +use Generator; +use Hyperf\Odin\Exception\RuntimeException; +use Psr\Http\Message\ResponseInterface as PsrResponseInterface; +use Psr\Http\Message\StreamInterface; use Stringable; class ChatCompletionResponse extends AbstractResponse implements Stringable @@ -24,13 +28,15 @@ class ChatCompletionResponse extends AbstractResponse implements Stringable protected ?string $model = null; - protected array|null $choices = []; + protected ?array $choices = []; protected ?Usage $usage = null; + protected bool $isChunked = false; + public function __toString(): string { - return trim($this->getChoices()[0]?->getMessage()?->getContent() ? : ''); + return trim($this->getChoices()[0]?->getMessage()?->getContent() ?: ''); } public function getId(): ?string @@ -82,6 +88,9 @@ public function getFirstChoice(): ?ChatCompletionChoice return $this->choices[0] ?? null; } + /** + * @return null|ChatCompletionChoice[] + */ public function getChoices(): ?array { return $this->choices; @@ -104,8 +113,59 @@ public function setUsage(?Usage $usage): static return $this; } + public function isChunked(): bool + { + return $this->isChunked; + } + + public function getStreamIterator(): Generator + { + while (! $this->originResponse->getBody()->eof()) { + $line = $this->readLine($this->originResponse->getBody()); + + if (! str_starts_with($line, 'data:')) { + continue; + } + $data = trim(substr($line, strlen('data:'))); + if (str_starts_with('[DONE]', $data)) { + break; + } + $content = json_decode($data, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException('Invalid JSON response | ' . $line); + } + if (isset($content['error'])) { + throw new RuntimeException('Steam Error | ' . $content['error']); + } + $this->setId($content['id'] ?? null); + $this->setObject($content['object'] ?? null); + $this->setCreated($content['created'] ?? null); + $this->setModel($content['model'] ?? null); + if (empty($content['choices'])) { + continue; + } + foreach ($content['choices'] as $choice) { + yield ChatCompletionChoice::fromArray($choice); + } + } + } + + public function setOriginResponse(PsrResponseInterface $originResponse): static + { + $this->originResponse = $originResponse; + $this->success = $originResponse->getStatusCode() === 200; + $this->parseContent(); + return $this; + } + protected function parseContent(): static { + if ($this->originResponse->hasHeader('Transfer-Encoding') + && $this->originResponse->getHeaderLine('Transfer-Encoding') === 'chunked') { + $this->isChunked = true; + return $this; + } + $this->content = $this->originResponse->getBody()->getContents(); $content = json_decode($this->content, true); if (isset($content['id'])) { $this->setId($content['id']); @@ -137,4 +197,18 @@ protected function buildChoices(mixed $choices): array return $result; } + private function readLine(StreamInterface $stream): string + { + $buffer = ''; + while (! $stream->eof()) { + if ('' === ($byte = $stream->read(1))) { + return $buffer; + } + $buffer .= $byte; + if ($byte === "\n") { + break; + } + } + return $buffer; + } } diff --git a/src/Api/OpenAI/Response/ToolCall.php b/src/Api/OpenAI/Response/ToolCall.php index c539a49..0ac1dca 100644 --- a/src/Api/OpenAI/Response/ToolCall.php +++ b/src/Api/OpenAI/Response/ToolCall.php @@ -16,20 +16,13 @@ class ToolCall implements Arrayable { - - /** - * @param string $name - * @param array $arguments - * @param bool $shouldFix Sometimes the API will return a wrong function call. If this flag is true will attempt to fix that. - */ public function __construct( protected string $name, protected array $arguments, protected string $id, - protected string $type = 'function' - ) - { - } + protected string $type = 'function', + protected string $streamArguments = '', + ) {} public static function fromArray(array $toolCalls): array { @@ -50,7 +43,7 @@ public static function fromArray(array $toolCalls): array $name = $function['name'] ?? ''; $id = $toolCall['id'] ?? ''; $type = $toolCall['type'] ?? 'function'; - $static = new static($name, $arguments, $id, $type); + $static = new static($name, $arguments, $id, $type, $function['arguments']); $toolCallsResult[] = $static; } return $toolCallsResult; @@ -81,12 +74,16 @@ public function setName(string $name): static public function getArguments(): array { + if (! empty($this->streamArguments)) { + $arguments = json_decode($this->streamArguments, true); + return is_array($arguments) ? $arguments : []; + } return $this->arguments; } public function getSerializedArguments(): string { - return json_encode($this->arguments); + return json_encode($this->getArguments(), JSON_UNESCAPED_UNICODE); } public function setArguments(array $arguments): static @@ -116,4 +113,14 @@ public function setType(string $type): static $this->type = $type; return $this; } + + public function getStreamArguments(): string + { + return $this->streamArguments; + } + + public function appendStreamArguments(string $arguments): void + { + $this->streamArguments .= $arguments; + } } diff --git a/src/Api/Skylark/Client.php b/src/Api/Skylark/Client.php deleted file mode 100644 index 50d99fa..0000000 --- a/src/Api/Skylark/Client.php +++ /dev/null @@ -1,213 +0,0 @@ -logger = $logger; - $this->initConfig($config); - } - - public function chat( - array $messages, - string $model, - float $temperature = 0.9, - int $maxTokens = 1000, - array $stop = [], - array $tools = [], - bool $stream = false, - ): ChatCompletionResponse { - $messagesArr = []; - foreach ($messages as $message) { - if ($message instanceof MessageInterface) { - $messagesArr[] = $message->toArray(); - } - } - $json = [ - 'stream' => $stream, - 'messages' => $messagesArr, - 'parameters' => [ - 'temperature' => $temperature, - ], - ]; - if ($maxTokens) { - $json['parameters']['max_tokens'] = $maxTokens; - } - if (! empty($tools)) { - $toolsArray = []; - foreach ($tools as $tool) { - if ($tool instanceof ToolInterface) { - $toolsArray[] = $tool->toToolDefinition()->toArray(); - } elseif ($tool instanceof ToolDefinition) { - $toolsArray[] = $tool->toArray(); - } else { - $toolsArray[] = $tool; - } - } - if (! empty($toolsArray)) { - $json['tools'] = $toolsArray; - } - } - if ($stop) { - $json['stop'] = $stop; - } - $this->debug && $this->logger?->debug(sprintf("Send Messages: %s\nTools: %s", json_encode($messagesArr, JSON_UNESCAPED_UNICODE), json_encode($tools, JSON_UNESCAPED_UNICODE))); - $response = $this->request(method: 'POST', path: sprintf('/api/v2/endpoint/%s/chat', $this->config->getEndpoint()), body: json_encode($json, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_IGNORE)); - $chatCompletionResponse = new ChatCompletionResponse($response); - $this->debug && $this->logger?->debug('Receive: ' . $chatCompletionResponse); - return $chatCompletionResponse; - } - - public function isDebug(): bool - { - return $this->debug; - } - - public function setDebug(bool $debug): static - { - $this->debug = $debug; - return $this; - } - - protected function initConfig(SkylarkConfig $config): static - { - $this->validConfig($config); - $this->client = new GuzzleClient([ - 'base_uri' => $config->getHost(), - 'timeout' => 180.0, - ]); - $this->config = $config; - return $this; - } - - protected function request( - string $method, - string $path, - array $headers = [], - array $query = [], - string $action = '', - string $body = '' - ): ResponseInterface { - $retryTimes = 0; - retry: - $credential = [ - 'accessKeyId' => $this->config->getAk(), - 'secretKeyId' => $this->config->getSk(), - 'service' => $this->config->getService(), - 'region' => $this->config->getRegion(), - ]; - // 初始化签名结构体 - $query = array_merge($query, [ - 'Action' => $action, - 'Version' => '', - ]); - ksort($query); - $requestParam = [ - // body是http请求需要的原生body - 'body' => $body, - 'host' => $this->config->getHost(false), - 'path' => $path, - 'method' => $method, - 'contentType' => 'application/json', - 'date' => gmdate('Ymd\THis\Z'), - 'query' => $query, - ]; - // 第三步:接下来开始计算签名。在计算签名前,先准备好用于接收签算结果的 signResult 变量,并设置一些参数。 - // 初始化签名结果的结构体 - $xDate = $requestParam['date']; - $shortXDate = substr($xDate, 0, 8); - $xContentSha256 = hash('sha256', $requestParam['body']); - $signResult = [ - 'Host' => $requestParam['host'], - 'X-Content-Sha256' => $xContentSha256, - 'X-Date' => $xDate, - 'Content-Type' => $requestParam['contentType'], - ]; - // 第四步:计算 Signature 签名。 - $signedHeaderStr = join(';', ['content-type', 'host', 'x-content-sha256', 'x-date']); - $canonicalRequestStr = join("\n", [ - $requestParam['method'], - $requestParam['path'], - http_build_query($requestParam['query']), - join("\n", [ - 'content-type:' . $requestParam['contentType'], - 'host:' . $requestParam['host'], - 'x-content-sha256:' . $xContentSha256, - 'x-date:' . $xDate, - ]), - '', - $signedHeaderStr, - $xContentSha256, - ]); - $hashedCanonicalRequest = hash('sha256', $canonicalRequestStr); - $credentialScope = join('/', [$shortXDate, $credential['region'], $credential['service'], 'request']); - $stringToSign = join("\n", ['HMAC-SHA256', $xDate, $credentialScope, $hashedCanonicalRequest]); - $kDate = hash_hmac('sha256', $shortXDate, $credential['secretKeyId'], true); - $kRegion = hash_hmac('sha256', $credential['region'], $kDate, true); - $kService = hash_hmac('sha256', $credential['service'], $kRegion, true); - $kSigning = hash_hmac('sha256', 'request', $kService, true); - $signature = hash_hmac('sha256', $stringToSign, $kSigning); - $signResult['Authorization'] = sprintf('HMAC-SHA256 Credential=%s, SignedHeaders=%s, Signature=%s', $credential['accessKeyId'] . '/' . $credentialScope, $signedHeaderStr, $signature); - $headers = array_merge($headers, $signResult); - $response = $this->client->request($method, $path, [ - 'headers' => $headers, - 'query' => $query, - 'body' => $body, - 'verify' => false, - 'http_errors' => false, - ]); - if ($response->getStatusCode() === 500 && $retryTimes < 3) { - goto retry; - } - return $response; - } - - private function validConfig(SkylarkConfig $config): void - { - if (! $config->getHost()) { - throw new InvalidArgumentException('The host of Skylark is required.'); - } - if (! $config->getAk()) { - throw new InvalidArgumentException('The ak of Skylark is required.'); - } - if (! $config->getSk()) { - throw new InvalidArgumentException('The sk of Skylark is required.'); - } - if (! $config->getRegion()) { - throw new InvalidArgumentException('The region of Skylark is required.'); - } - if (! $config->getEndpoint()) { - throw new InvalidArgumentException('The endpoint of Skylark is required.'); - } - } -} diff --git a/src/Api/Skylark/SkylarkConfig.php b/src/Api/Skylark/SkylarkConfig.php deleted file mode 100644 index 3b7f27a..0000000 --- a/src/Api/Skylark/SkylarkConfig.php +++ /dev/null @@ -1,52 +0,0 @@ -ak; - } - - public function getSk(): string - { - return $this->sk; - } - - public function getEndpoint(): string - { - return $this->endpoint; - } - - public function getRegion(): string - { - return $this->region; - } - - public function getHost(bool $withSchema = true): string - { - if ($withSchema) { - return $this->host; - } else { - return str_replace(['http://', 'https://'], '', $this->host); - } - } - - public function getService(): string - { - return $this->service; - } -} \ No newline at end of file diff --git a/src/Message/UserMessage.php b/src/Message/UserMessage.php index d65c7d5..94f6ea0 100644 --- a/src/Message/UserMessage.php +++ b/src/Message/UserMessage.php @@ -1,10 +1,70 @@ contents[] = $content; + return $this; + } + + public function toArray(): array + { + if (! is_null($this->contents)) { + $contents = []; + foreach ($this->contents as $content) { + $contents[] = $content->toArray(); + } + return [ + 'role' => $this->role->value, + 'content' => $contents, + ]; + } + return parent::toArray(); + } + + public static function fromArray(array $message): static + { + $content = $message['content'] ?? ''; + if (is_string($content)) { + return new static($content); + } + if (is_array($content)) { + $userMessage = new static(''); + foreach ($content as $item) { + $userMessageContent = (new UserMessageContent($item['type'] ?? '')) + ->setText($item['text'] ?? '') + ->setImageUrl($item['image_url']['url'] ?? ''); + if ($userMessageContent->isValid()) { + $userMessage->addContent($userMessageContent); + } + } + return $userMessage; + } + return new static(''); + } +} diff --git a/src/Message/UserMessageContent.php b/src/Message/UserMessageContent.php new file mode 100644 index 0000000..f3229b5 --- /dev/null +++ b/src/Message/UserMessageContent.php @@ -0,0 +1,90 @@ +type = $type; + } + + public static function text(string $text): self + { + return (new self('text'))->setText($text); + } + + public static function imageUrl(string $url): self + { + return (new self('image_url'))->setImageUrl($url); + } + + public function getType(): string + { + return $this->type; + } + + public function getText(): string + { + return $this->text; + } + + public function setText(string $text): self + { + $this->text = $text; + return $this; + } + + public function getImageUrl(): string + { + return $this->imageUrl; + } + + public function setImageUrl(string $imageUrl): self + { + $this->imageUrl = $imageUrl; + return $this; + } + + public function isValid(): bool + { + return match ($this->type) { + 'text' => $this->text !== '', + 'image_url' => $this->imageUrl !== '', + default => false, + }; + } + + public function toArray(): array + { + return match ($this->type) { + 'text' => [ + 'type' => 'text', + 'text' => $this->text, + ], + 'image_url' => [ + 'type' => 'image_url', + 'image_url' => [ + 'url' => $this->imageUrl, + ], + ], + default => [], + }; + } +} diff --git a/src/Model/AzureOpenAIModel.php b/src/Model/AzureOpenAIModel.php index bb08080..e6d7e09 100644 --- a/src/Model/AzureOpenAIModel.php +++ b/src/Model/AzureOpenAIModel.php @@ -16,13 +16,10 @@ use Hyperf\Odin\Api\AzureOpenAI\AzureOpenAIConfig; use Hyperf\Odin\Api\AzureOpenAI\Client as AzureOpenAIClient; use Hyperf\Odin\Api\OpenAI\Response\ChatCompletionResponse; -use Hyperf\Odin\Api\OpenAI\Response\ListResponse; class AzureOpenAIModel implements ModelInterface, EmbeddingInterface { - public function __construct(public string $model, public array $config) - { - } + public function __construct(public string $model, public array $config) {} public function chat( array $messages, @@ -30,15 +27,15 @@ public function chat( int $maxTokens = 0, array $stop = [], array $tools = [], + bool $stream = false, ): ChatCompletionResponse { $client = $this->getAzureOpenAIClient(); - return $client->chat($messages, $this->model, $temperature, $maxTokens, $stop, $tools); + return $client->chat($messages, $this->model, $temperature, $maxTokens, $stop, $tools, $stream); } public function embedding(string $input): Embedding { $client = $this->getAzureOpenAIClient(); - /** @var ListResponse $response */ $response = $client->embedding($input, $this->model); $embeddings = []; $data = $response->getData(); @@ -67,5 +64,4 @@ public function getVectorSize(): int { return 1536; } - } diff --git a/src/Model/ChatglmModel.php b/src/Model/ChatglmModel.php index 19f003c..a6cb36e 100644 --- a/src/Model/ChatglmModel.php +++ b/src/Model/ChatglmModel.php @@ -17,6 +17,8 @@ use Hyperf\Odin\Api\Chatglm\Client as ChatglmClient; use Hyperf\Odin\Api\OpenAI\Response\ChatCompletionResponse; use Hyperf\Odin\Api\OpenAI\Response\ListResponse; +use Hyperf\Odin\Exception\OdinException; +use Hyperf\Odin\Exception\RuntimeException; class ChatglmModel implements ModelInterface, EmbeddingInterface { @@ -30,8 +32,12 @@ public function chat( int $maxTokens = 0, array $stop = [], array $tools = [], + bool $stream = false, ): ChatCompletionResponse { $client = $this->getChatglmClient(); + if ($stream) { + throw new RuntimeException('Stream is temporarily not supported'); + } return $client->chat($messages, $this->model, $temperature, $maxTokens, $stop, $tools); } diff --git a/src/Model/DoubaoModel.php b/src/Model/DoubaoModel.php new file mode 100644 index 0000000..06b0939 --- /dev/null +++ b/src/Model/DoubaoModel.php @@ -0,0 +1,46 @@ +getSkylarkClient(); + return $client->chat($messages, $this->model, $temperature, $maxTokens, $stop, $tools, $stream); + } + + public function getSkylarkClient(): Client + { + $skylark = new Doubao(); + $config = new DoubaoConfig( + apiKey: $this->config['api_key'] ?? null, + baseUrl: $this->config['base_url'] ?? '', + model: $this->config['model'] ?? '', + ); + return $skylark->getClient($config); + } +} diff --git a/src/Model/ModelInterface.php b/src/Model/ModelInterface.php index 1fc6623..8579e95 100644 --- a/src/Model/ModelInterface.php +++ b/src/Model/ModelInterface.php @@ -22,5 +22,6 @@ public function chat( int $maxTokens = 0, array $stop = [], array $tools = [], + bool $stream = false, ): ChatCompletionResponse; } diff --git a/src/Model/OllamaModel.php b/src/Model/OllamaModel.php index 13e2bf9..7c13a1e 100644 --- a/src/Model/OllamaModel.php +++ b/src/Model/OllamaModel.php @@ -16,6 +16,7 @@ use Hyperf\Odin\Api\Ollama\Ollama; use Hyperf\Odin\Api\Ollama\OllamaConfig; use Hyperf\Odin\Api\OpenAI\Response\ChatCompletionResponse; +use Hyperf\Odin\Exception\RuntimeException; class OllamaModel implements ModelInterface, EmbeddingInterface { @@ -29,9 +30,13 @@ public function chat( int $maxTokens = 0, array $stop = [], array $tools = [], + bool $stream = false, ): ChatCompletionResponse { $client = $this->getOllamaClient(); + if ($stream) { + throw new RuntimeException('Stream is temporarily not supported'); + } return $client->chat($messages, $this->model, $temperature, $maxTokens, $stop, $tools); } diff --git a/src/Model/OpenAIModel.php b/src/Model/OpenAIModel.php index 05872c1..a76410f 100644 --- a/src/Model/OpenAIModel.php +++ b/src/Model/OpenAIModel.php @@ -16,12 +16,11 @@ use Hyperf\Odin\Api\OpenAI\OpenAI; use Hyperf\Odin\Api\OpenAI\OpenAIConfig; use Hyperf\Odin\Api\OpenAI\Response\ChatCompletionResponse; +use Hyperf\Odin\Exception\RuntimeException; class OpenAIModel implements ModelInterface { - public function __construct(public string $model, public array $config) - { - } + public function __construct(public string $model, public array $config) {} public function chat( array $messages, @@ -29,16 +28,16 @@ public function chat( int $maxTokens = 0, array $stop = [], array $tools = [], - ): ChatCompletionResponse - { + bool $stream = false, + ): ChatCompletionResponse { $client = $this->getOpenAIClient(); - return $client->chat($messages, $this->model, $temperature, $maxTokens, $stop, $tools); + return $client->chat($messages, $this->model, $temperature, $maxTokens, $stop, $tools, $stream); } protected function getOpenAIClient(): Client { $openAI = new OpenAI(); - $config = new OpenAIConfig($this->config['api_key'] ?? null, $this->config['organization'] ?? null, $this->config['base_url'] ?? 'https://api.openai.com/',); - return $openAI->getClient($config, $this->model); + $config = new OpenAIConfig($this->config['api_key'] ?? null, $this->config['organization'] ?? null, $this->config['base_url'] ?? 'https://api.openai.com/'); + return $openAI->getClient($config); } } diff --git a/src/Model/SkylarkModel.php b/src/Model/SkylarkModel.php deleted file mode 100644 index 48ce9b4..0000000 --- a/src/Model/SkylarkModel.php +++ /dev/null @@ -1,56 +0,0 @@ -getSkylarkClient(); - return $client->chat($messages, $this->model, $temperature, $maxTokens, $stop, $tools); - } - - public function getSkylarkClient(): Client - { - $skylark = new Skylark(); - $config = new SkylarkConfig(ak: $this->config['ak'], sk: $this->config['sk'], endpoint: $this->config['endpoint'], host: $this->config['host'], region: $this->config['region'], service: $this->config['service']); - return $skylark->getClient($config); - } - - public function parseAnswer(AssistantMessage $message): AssistantMessage - { - $content = $message->getContent(); - // 如果存在 <|Answer|> 标记,则取出 <|Answer|>: 后面的内容作为回答 - if (str_contains($content, '<|Answer|>:')) { - $answer = substr($content, strpos($content, '<|Answer|>:') + 11); - $message->setContent($answer); - } - return $message; - } -} diff --git a/tests/Cases/AbstractTestCase.php b/tests/Cases/AbstractTestCase.php index b7d898b..985cedf 100644 --- a/tests/Cases/AbstractTestCase.php +++ b/tests/Cases/AbstractTestCase.php @@ -13,27 +13,31 @@ namespace HyperfTest\Odin\Cases; use PHPUnit\Framework\TestCase; +use ReflectionClass; /** * Class AbstractTestCase. */ abstract class AbstractTestCase extends TestCase { - protected function callNonpublicMethod(object $object, string $method) { - $reflection = new \ReflectionClass($object); + $reflection = new ReflectionClass($object); $reflectionMethod = $reflection->getMethod($method); - $reflectionMethod->setAccessible(true); return $reflectionMethod->invoke($object); } protected function getNonpublicProperty(object $object, string $property) { - $reflection = new \ReflectionClass($object); + $reflection = new ReflectionClass($object); $reflectionProperty = $reflection->getProperty($property); - $reflectionProperty->setAccessible(true); return $reflectionProperty->getValue($object); } + protected function setNonpublicPropertyValue(object $object, string $property, mixed $value): void + { + $reflection = new ReflectionClass($object); + $reflectionProperty = $reflection->getProperty($property); + $reflectionProperty->setValue($object, $value); + } } diff --git a/tests/Cases/Api/AzureOpenAI/AzureOpenAIConfigTest.php b/tests/Cases/Api/AzureOpenAI/AzureOpenAIConfigTest.php new file mode 100644 index 0000000..aa2b11d --- /dev/null +++ b/tests/Cases/Api/AzureOpenAI/AzureOpenAIConfigTest.php @@ -0,0 +1,59 @@ + 'test_api_key']); + $this->assertSame('test_api_key', $config->getApiKey()); + } + + public function testGetBaseUrl() + { + $config = new AzureOpenAIConfig(['api_base' => 'https://api.example.com']); + $this->assertSame('https://api.example.com', $config->getBaseUrl()); + } + + public function testGetApiVersion() + { + $config = new AzureOpenAIConfig(['api_version' => 'v1']); + $this->assertSame('v1', $config->getApiVersion()); + } + + public function testGetDeploymentName() + { + $config = new AzureOpenAIConfig(['deployment_name' => 'test_deployment']); + $this->assertSame('test_deployment', $config->getDeploymentName()); + } + + public function testGetConfig() + { + $configArray = [ + 'api_key' => 'test_api_key', + 'api_base' => 'https://api.example.com', + 'api_version' => 'v1', + 'deployment_name' => 'test_deployment', + ]; + $config = new AzureOpenAIConfig($configArray); + $this->assertSame($configArray, $config->getConfig()); + } +} diff --git a/tests/Cases/Api/AzureOpenAI/AzureOpenAITest.php b/tests/Cases/Api/AzureOpenAI/AzureOpenAITest.php new file mode 100644 index 0000000..7e7f223 --- /dev/null +++ b/tests/Cases/Api/AzureOpenAI/AzureOpenAITest.php @@ -0,0 +1,40 @@ + 'test_api_key', + 'api_base' => 'https://api.example.com', + 'api_version' => 'v1', + 'deployment_name' => 'test_deployment', + ]); + + $azureOpenAI = new AzureOpenAI(); + $client = $azureOpenAI->getClient($config, 'gpt-3.5-turbo'); + + $this->assertInstanceOf(Client::class, $client); + } +} diff --git a/tests/Cases/Api/AzureOpenAI/ClientTest.php b/tests/Cases/Api/AzureOpenAI/ClientTest.php new file mode 100644 index 0000000..22a0a2c --- /dev/null +++ b/tests/Cases/Api/AzureOpenAI/ClientTest.php @@ -0,0 +1,437 @@ +config = new AzureOpenAIConfig([ + 'api_key' => env('AZURE_OPENAI_4O_API_KEY'), + 'api_base' => env('AZURE_OPENAI_4O_API_BASE'), + 'api_version' => env('AZURE_OPENAI_4O_API_VERSION'), + 'deployment_name' => env('AZURE_OPENAI_4O_DEPLOYMENT_NAME'), + ]); + $this->model = 'gpt-4o-global'; + } + + public function testChat() + { + $client = new Client($this->config, new Logger(), $this->model); + + $guzzleClientMock = $this->createMock(GuzzleClient::class); + $response = new Response( + 200, + [], + <<<'JSON' +{ + "choices": [ + { + "content_filter_results": { + "hate": { + "filtered": false, + "severity": "safe" + }, + "self_harm": { + "filtered": false, + "severity": "safe" + }, + "sexual": { + "filtered": false, + "severity": "safe" + }, + "violence": { + "filtered": false, + "severity": "safe" + } + }, + "finish_reason": "stop", + "index": 0, + "logprobs": null, + "message": { + "content": "Hello! How can I assist you today?", + "refusal": null, + "role": "assistant" + } + } + ], + "created": 1736846202, + "id": "chatcmpl-ApXKMLCwroGSJnFICgA6nhYZ7OQfO", + "model": "gpt-4o-2024-08-06", + "object": "chat.completion", + "prompt_filter_results": [ + { + "prompt_index": 0, + "content_filter_results": {} + } + ], + "system_fingerprint": "fp_f3927aa00d", + "usage": { + "completion_tokens": 92, + "completion_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 0, + "rejected_prediction_tokens": 0 + }, + "prompt_tokens": 61, + "prompt_tokens_details": { + "audio_tokens": 0, + "cached_tokens": 0 + }, + "total_tokens": 153 + } +} +JSON + ); + $guzzleClientMock->method('post')->willReturn($response); + $this->setNonpublicPropertyValue($client, 'clients', [$this->model => $guzzleClientMock]); + + $messages = [ + new SystemMessage(''), + new UserMessage('hello'), + ]; + $result = $client->chat(messages: $messages, model: $this->model); + + $this->assertInstanceOf(ChatCompletionResponse::class, $result); + var_dump((string) $result); + $this->assertNotEmpty($result->getChoices()[0]->getMessage()->getContent()); + + $this->assertNotEmpty($result->getId()); + $this->assertNotEmpty($result->getModel()); + $this->assertNotEmpty($result->getObject()); + } + + public function testChatWithTool() + { + $client = new Client($this->config, new Logger(), $this->model); + + $guzzleClientMock = $this->createMock(GuzzleClient::class); + $response = new Response( + 200, + [], + <<<'JSON' +{ + "choices": [ + { + "content_filter_results": {}, + "finish_reason": "tool_calls", + "index": 0, + "logprobs": null, + "message": { + "content": null, + "refusal": null, + "role": "assistant", + "tool_calls": [ + { + "function": { + "arguments": "{\"slat\":\"hello\"}", + "name": "get_rand_string" + }, + "id": "call_cQL3yg4IQrXpB15naaHp6nY5", + "type": "function" + } + ] + } + } + ], + "created": 1736911720, + "id": "chatcmpl-ApoN65HowyikDJvdYM8dGNBou1EVK", + "model": "gpt-4o-2024-08-06", + "object": "chat.completion", + "prompt_filter_results": [ + { + "prompt_index": 0, + "content_filter_results": { + "hate": { + "filtered": false, + "severity": "safe" + }, + "jailbreak": { + "filtered": false, + "detected": false + }, + "self_harm": { + "filtered": false, + "severity": "safe" + }, + "sexual": { + "filtered": false, + "severity": "safe" + }, + "violence": { + "filtered": false, + "severity": "safe" + } + } + } + ], + "system_fingerprint": "fp_f3927aa00d", + "usage": { + "completion_tokens": 16, + "completion_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 0, + "rejected_prediction_tokens": 0 + }, + "prompt_tokens": 78, + "prompt_tokens_details": { + "audio_tokens": 0, + "cached_tokens": 0 + }, + "total_tokens": 94 + } +} +JSON + ); + $guzzleClientMock->method('post')->willReturn($response); + $this->setNonpublicPropertyValue($client, 'clients', [$this->model => $guzzleClientMock]); + + $messages = [ + new SystemMessage('你可以为用户生成随机字符串,调用 get_rand_string 工具来完成'), + new UserMessage('帮我生成 1 个随机字符串,其中 slat 为 hello'), + ]; + $tool = [ + new class extends AbstractTool { + public string $name = 'get_rand_string'; + + public string $description = '生成随机字符串'; + + public array $parameters = [ + 'slat' => [ + 'type' => 'string', + 'description' => '盐值', + ], + ]; + + public function invoke($args): ?array + { + var_dump($args); + return [ + uniqid(), + ]; + } + }, + ]; + $result = $client->chat(messages: $messages, model: $this->model, tools: $tool); + + $this->assertInstanceOf(ChatCompletionResponse::class, $result); + $this->assertTrue($result->getFirstChoice()->isFinishedByToolCall()); + $this->assertInstanceOf(AssistantMessage::class, $result->getFirstChoice()->getMessage()); + /** @var AssistantMessage $message */ + $message = $result->getFirstChoice()->getMessage(); + $this->assertSame('get_rand_string', $message->getToolCalls()[0]->getName()); + $this->assertSame(['slat' => 'hello'], $message->getToolCalls()[0]->getArguments()); + } + + public function testChatStream() + { + $client = new Client($this->config, new Logger(), $this->model); + + $guzzleClientMock = $this->createMock(GuzzleClient::class); + $list = [ + <<<'JSON' +{"choices":[],"created":0,"id":"","model":"","object":"","prompt_filter_results":[{"prompt_index":0,"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"jailbreak":{"filtered":false,"detected":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}}}]} +JSON, + <<<'JSON' +{"choices":[{"content_filter_results":{},"delta":{"content":"","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1736906666,"id":"chatcmpl-Apn3aJ0BPybkQFIi3YO2XHweNLS8W","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_04751d0b65"} +JSON, + <<<'JSON' +{"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"protected_material_code":{"filtered":false,"detected":false},"protected_material_text":{"filtered":false,"detected":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"Hello! "},"finish_reason":null,"index":0,"logprobs":null}],"created":1736906666,"id":"chatcmpl-Apn3aJ0BPybkQFIi3YO2XHweNLS8W","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_04751d0b65"} +JSON, + <<<'JSON' +{"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"protected_material_code":{"filtered":false,"detected":false},"protected_material_text":{"filtered":false,"detected":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"How can I assist you today?"},"finish_reason":null,"index":0,"logprobs":null}],"created":1736906666,"id":"chatcmpl-Apn3aJ0BPybkQFIi3YO2XHweNLS8W","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_04751d0b65"} +JSON, + + <<<'JSON' +{"choices":[{"content_filter_results":{},"delta":{},"finish_reason":"stop","index":0,"logprobs":null}],"created":1736906666,"id":"chatcmpl-Apn3aJ0BPybkQFIi3YO2XHweNLS8W","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_04751d0b65"} +JSON, + ]; + $chunkedBody = []; + foreach ($list as $item) { + $chunkedBody[] = 'data:' . $item; + } + $chunkedBody[] = 'data:[DONE]'; + $stream = Utils::streamFor(implode("\r\n", $chunkedBody)); + $response = new Response(200, ['Transfer-Encoding' => 'chunked'], $stream); + $guzzleClientMock->method('post')->willReturn($response); + $this->setNonpublicPropertyValue($client, 'clients', [$this->model => $guzzleClientMock]); + + $messages = [ + new SystemMessage(''), + new UserMessage('hello'), + ]; + $result = $client->chat($messages, $this->model, stream: true); + $this->assertInstanceOf(ChatCompletionResponse::class, $result); + + $content = ''; + foreach ($result->getStreamIterator() as $choice) { + $content .= $choice->getMessage()?->getContent() ?: ''; + } + $content = trim($content); + var_dump($content); + $this->assertNotEmpty($content); + $this->assertNotEmpty($result->getId()); + $this->assertNotEmpty($result->getModel()); + $this->assertNotEmpty($result->getObject()); + } + + public function testChatStreamWithTool() + { + $client = new Client($this->config, new Logger(), $this->model); + + $guzzleClientMock = $this->createMock(GuzzleClient::class); + $list = [ + <<<'JSON' +{"choices":[{"content_filter_results":{},"delta":{"tool_calls":[{"function":{"arguments":"","name":"get_rand_string"},"id":"call_sJFuv3SdQtxoaCccGBPiPY53","index":0,"type":"function"}]},"finish_reason":null,"index":0,"logprobs":null}],"created":1736912972,"id":"chatcmpl-ApohIw9hJ9OVHrr7P75dYmwSvFiS6","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_f3927aa00d"} +JSON, + <<<'JSON' +{"choices":[{"content_filter_results":{},"delta":{"tool_calls":[{"function":{"arguments":"{\"slat\": \"hello\"}"},"index":0}]},"finish_reason":null,"index":0,"logprobs":null}],"created":1736912972,"id":"chatcmpl-ApohIw9hJ9OVHrr7P75dYmwSvFiS6","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_f3927aa00d"} +JSON, + <<<'JSON' +{"choices":[{"content_filter_results":{},"delta":{"tool_calls":[{"function":{"arguments":"","name":"get_rand_string"},"id":"call_RleVQfBwjkh4jez8JowsMSE1","index":1,"type":"function"}]},"finish_reason":null,"index":0,"logprobs":null}],"created":1736912972,"id":"chatcmpl-ApohIw9hJ9OVHrr7P75dYmwSvFiS6","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_f3927aa00d"} +JSON, + <<<'JSON' +{"choices":[{"content_filter_results":{},"delta":{"tool_calls":[{"function":{"arguments":"{\"slat\": \"hi\"}"},"index":1}]},"finish_reason":null,"index":0,"logprobs":null}],"created":1736912972,"id":"chatcmpl-ApohIw9hJ9OVHrr7P75dYmwSvFiS6","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_f3927aa00d"} +JSON, + <<<'JSON' +{"choices":[{"content_filter_results":{},"delta":{},"finish_reason":"tool_calls","index":0,"logprobs":null}],"created":1736912972,"id":"chatcmpl-ApohIw9hJ9OVHrr7P75dYmwSvFiS6","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_f3927aa00d"} +JSON, + ]; + $chunkedBody = []; + foreach ($list as $item) { + $chunkedBody[] = 'data:' . $item; + } + $chunkedBody[] = 'data:[DONE]'; + $stream = Utils::streamFor(implode("\r\n", $chunkedBody)); + $response = new Response(200, ['Transfer-Encoding' => 'chunked'], $stream); + $guzzleClientMock->method('post')->willReturn($response); + $this->setNonpublicPropertyValue($client, 'clients', [$this->model => $guzzleClientMock]); + + $messages = [ + new SystemMessage('你可以为用户生成随机字符串,调用 get_rand_string 工具来完成'), + new UserMessage('帮我生成 2 个随机字符串,第一个 slat 为 hello,第二个 slat 为 hi'), + ]; + $tool = [ + new class extends AbstractTool { + public string $name = 'get_rand_string'; + + public string $description = '生成随机字符串'; + + public array $parameters = [ + 'slat' => [ + 'type' => 'string', + 'description' => '盐值', + ], + ]; + + public function invoke($args): ?array + { + var_dump($args); + return [ + uniqid(), + ]; + } + }, + ]; + $result = $client->chat(messages: $messages, model: $this->model, tools: $tool, stream: true); + + $this->assertInstanceOf(ChatCompletionResponse::class, $result); + + $toolCalls = []; + $content = ''; + // 在 stream 模式下,需要自行维护 ToolCall 的组装 + foreach ($result->getStreamIterator() as $choice) { + /** @var AssistantMessage $message */ + $message = $choice->getMessage(); + foreach ($message->getToolCalls() as $toolCall) { + if ($toolCall->getId()) { + $toolCalls[] = new ToolCall($toolCall->getName(), [], $toolCall->getId(), $toolCall->getType(), $toolCall->getStreamArguments()); + } else { + /** @var ToolCall $lastToolCall */ + $lastToolCall = end($toolCalls); + $lastToolCall->appendStreamArguments($toolCall->getStreamArguments()); + } + } + $content .= $choice->getMessage()?->getContent() ?: ''; + } + $content = trim($content); + $this->assertSame('', $content); + $this->assertSame('get_rand_string', $toolCalls[0]->getName()); + $this->assertSame(['slat' => 'hello'], $toolCalls[0]->getArguments()); + $this->assertSame('get_rand_string', $toolCalls[1]->getName()); + $this->assertSame(['slat' => 'hi'], $toolCalls[1]->getArguments()); + } + + public function testChatVision() + { + $client = new Client($this->config, new Logger(), $this->model); + + $guzzleClientMock = $this->createMock(GuzzleClient::class); + $response = new Response( + 200, + [], + <<<'JSON' +{"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"finish_reason":"stop","index":0,"logprobs":null,"message":{"content":"图片中展示了一家人围坐在餐桌前,共同享用丰盛的中国菜肴。桌上摆满了各式各样的美食,包括整条烤鱼、炒虾、蔬菜拼盘、炒饭、汤等。每个人面前都有一副筷子,他们正享用着这顿美味的晚餐。桌上还有饮料,如橙汁,整个场景呈现出一种温馨祥和的家庭聚餐氛围。背景中可以看到传统的中式装修风格。","refusal":null,"role":"assistant"}}],"created":1736939844,"id":"chatcmpl-ApvgijBkXxrnuOdJmkcIkHJp10eSD","model":"gpt-4o-2024-08-06","object":"chat.completion","prompt_filter_results":[{"prompt_index":0,"content_filter_result":{"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"},"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"jailbreak":{"filtered":false,"detected":false}}},{"prompt_index":2,"content_filter_result":{"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"},"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"}}}],"system_fingerprint":"fp_f3927aa00d","usage":{"completion_tokens":114,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens":1301,"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0},"total_tokens":1415}} +JSON + ); + $guzzleClientMock->method('post')->willReturn($response); + $this->setNonpublicPropertyValue($client, 'clients', [$this->model => $guzzleClientMock]); + + $userMessage = new UserMessage(); + $userMessage->addContent(UserMessageContent::text('这个图片里面有什么')); + $userMessage->addContent(UserMessageContent::imageUrl(base64_decode('aHR0cHM6Ly92Y2cwMi5jZnAuY24vY3JlYXRpdmUvdmNnLzgwMC9uZXcvVkNHMjExMjU4OTAwOTQwLmpwZw=='))); + + $messages = [ + new SystemMessage(''), + $userMessage, + ]; + $result = $client->chat(messages: $messages, model: $this->model); + + $this->assertInstanceOf(ChatCompletionResponse::class, $result); + var_dump((string) $result); + $this->assertNotEmpty($result->getChoices()[0]->getMessage()->getContent()); + + $this->assertNotEmpty($result->getId()); + $this->assertNotEmpty($result->getModel()); + $this->assertNotEmpty($result->getObject()); + } +} diff --git a/tests/Cases/Api/Doubao/ClientTest.php b/tests/Cases/Api/Doubao/ClientTest.php new file mode 100644 index 0000000..7d53360 --- /dev/null +++ b/tests/Cases/Api/Doubao/ClientTest.php @@ -0,0 +1,362 @@ +model = env('SKYLARK_PRO_32K_ENDPOINT'); + $this->config = new DoubaoConfig( + apiKey: env('SKYLARK_API_KEY'), + baseUrl: env('SKYLARK_HOST'), + model: $this->model + ); + } + + public function testChat() + { + $client = new Client($this->config, new Logger()); + + $guzzleClientMock = $this->createMock(GuzzleClient::class); + $response = new Response( + 200, + [], + <<<'JSON' +{ + "choices": [ + { + "content_filter_results": { + "hate": { + "filtered": false, + "severity": "safe" + }, + "self_harm": { + "filtered": false, + "severity": "safe" + }, + "sexual": { + "filtered": false, + "severity": "safe" + }, + "violence": { + "filtered": false, + "severity": "safe" + } + }, + "finish_reason": "stop", + "index": 0, + "logprobs": null, + "message": { + "content": "Hello! How can I assist you today?", + "refusal": null, + "role": "assistant" + } + } + ], + "created": 1736846202, + "id": "chatcmpl-ApXKMLCwroGSJnFICgA6nhYZ7OQfO", + "model": "gpt-4o-2024-08-06", + "object": "chat.completion", + "prompt_filter_results": [ + { + "prompt_index": 0, + "content_filter_results": {} + } + ], + "system_fingerprint": "fp_f3927aa00d", + "usage": { + "completion_tokens": 92, + "completion_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 0, + "rejected_prediction_tokens": 0 + }, + "prompt_tokens": 61, + "prompt_tokens_details": { + "audio_tokens": 0, + "cached_tokens": 0 + }, + "total_tokens": 153 + } +} +JSON + ); + $guzzleClientMock->method('post')->willReturn($response); + $this->setNonpublicPropertyValue($client, 'client', $guzzleClientMock); + + $messages = [ + new SystemMessage(''), + new UserMessage('hello'), + ]; + $result = $client->chat(messages: $messages, model: $this->model); + + $this->assertInstanceOf(ChatCompletionResponse::class, $result); + var_dump((string) $result); + $this->assertNotEmpty($result->getChoices()[0]->getMessage()->getContent()); + + $this->assertNotEmpty($result->getId()); + $this->assertNotEmpty($result->getModel()); + $this->assertNotEmpty($result->getObject()); + } + + public function testChatWithTool() + { + $client = new Client($this->config, new Logger()); + + $guzzleClientMock = $this->createMock(GuzzleClient::class); + $response = new Response( + 200, + [], + <<<'JSON' +{"choices":[{"finish_reason":"tool_calls","index":0,"logprobs":null,"message":{"content":"\n当前提供了 1 个工具,分别是[\"get_rand_string\"],需要生成 slat 为 hello 的随机字符串,调用 get_rand_string。","role":"assistant","tool_calls":[{"function":{"arguments":"{\"slat\": \"hello\"}","name":"get_rand_string"},"id":"call_xgax2gfar3s7tuvsw7kib0wk","type":"function"}]}}],"created":1736934023,"id":"021736934021876a67222095253345647102ee3d39e5e9b67365c","model":"doubao-pro-32k-240515","object":"chat.completion","usage":{"completion_tokens":73,"prompt_tokens":95,"total_tokens":168,"prompt_tokens_details":{"cached_tokens":0}}} +JSON + ); + $guzzleClientMock->method('post')->willReturn($response); + $this->setNonpublicPropertyValue($client, 'client', $guzzleClientMock); + + $messages = [ + new SystemMessage('你可以为用户生成随机字符串,调用 get_rand_string 工具来完成'), + new UserMessage('帮我生成 1 个随机字符串,其中 slat 为 hello'), + ]; + $tool = [ + new class extends AbstractTool { + public string $name = 'get_rand_string'; + + public string $description = '生成随机字符串'; + + public array $parameters = [ + 'slat' => [ + 'type' => 'string', + 'description' => '盐值', + ], + ]; + + public function invoke($args): ?array + { + var_dump($args); + return [ + uniqid(), + ]; + } + }, + ]; + $result = $client->chat(messages: $messages, model: $this->model, tools: $tool); + + $this->assertInstanceOf(ChatCompletionResponse::class, $result); + $this->assertTrue($result->getFirstChoice()->isFinishedByToolCall()); + $this->assertInstanceOf(AssistantMessage::class, $result->getFirstChoice()->getMessage()); + /** @var AssistantMessage $message */ + $message = $result->getFirstChoice()->getMessage(); + $this->assertSame('get_rand_string', $message->getToolCalls()[0]->getName()); + $this->assertSame(['slat' => 'hello'], $message->getToolCalls()[0]->getArguments()); + } + + public function testChatStream() + { + $client = new Client($this->config, new Logger()); + + $guzzleClientMock = $this->createMock(GuzzleClient::class); + $list = [ + <<<'JSON' +{"choices":[],"created":0,"id":"","model":"","object":"","prompt_filter_results":[{"prompt_index":0,"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"jailbreak":{"filtered":false,"detected":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}}}]} +JSON, + <<<'JSON' +{"choices":[{"content_filter_results":{},"delta":{"content":"","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1736906666,"id":"chatcmpl-Apn3aJ0BPybkQFIi3YO2XHweNLS8W","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_04751d0b65"} +JSON, + <<<'JSON' +{"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"protected_material_code":{"filtered":false,"detected":false},"protected_material_text":{"filtered":false,"detected":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"Hello! "},"finish_reason":null,"index":0,"logprobs":null}],"created":1736906666,"id":"chatcmpl-Apn3aJ0BPybkQFIi3YO2XHweNLS8W","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_04751d0b65"} +JSON, + <<<'JSON' +{"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"protected_material_code":{"filtered":false,"detected":false},"protected_material_text":{"filtered":false,"detected":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"How can I assist you today?"},"finish_reason":null,"index":0,"logprobs":null}],"created":1736906666,"id":"chatcmpl-Apn3aJ0BPybkQFIi3YO2XHweNLS8W","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_04751d0b65"} +JSON, + + <<<'JSON' +{"choices":[{"content_filter_results":{},"delta":{},"finish_reason":"stop","index":0,"logprobs":null}],"created":1736906666,"id":"chatcmpl-Apn3aJ0BPybkQFIi3YO2XHweNLS8W","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_04751d0b65"} +JSON, + ]; + $chunkedBody = []; + foreach ($list as $item) { + $chunkedBody[] = 'data:' . $item; + } + $chunkedBody[] = 'data:[DONE]'; + $stream = Utils::streamFor(implode("\r\n", $chunkedBody)); + $response = new Response(200, ['Transfer-Encoding' => 'chunked'], $stream); + $guzzleClientMock->method('post')->willReturn($response); + $this->setNonpublicPropertyValue($client, 'client', $guzzleClientMock); + + $messages = [ + new SystemMessage(''), + new UserMessage('hello'), + ]; + $result = $client->chat($messages, $this->model, stream: true); + $this->assertInstanceOf(ChatCompletionResponse::class, $result); + + $content = ''; + foreach ($result->getStreamIterator() as $choice) { + $content .= $choice->getMessage()?->getContent() ?: ''; + } + $content = trim($content); + var_dump($content); + $this->assertNotEmpty($content); + $this->assertNotEmpty($result->getId()); + $this->assertNotEmpty($result->getModel()); + $this->assertNotEmpty($result->getObject()); + } + + public function testChatStreamWithTool() + { + $client = new Client($this->config, new Logger()); + + $guzzleClientMock = $this->createMock(GuzzleClient::class); + $list = [ + <<<'JSON' +{"choices":[{"content_filter_results":{},"delta":{"tool_calls":[{"function":{"arguments":"","name":"get_rand_string"},"id":"call_sJFuv3SdQtxoaCccGBPiPY53","index":0,"type":"function"}]},"finish_reason":null,"index":0,"logprobs":null}],"created":1736912972,"id":"chatcmpl-ApohIw9hJ9OVHrr7P75dYmwSvFiS6","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_f3927aa00d"} +JSON, + <<<'JSON' +{"choices":[{"content_filter_results":{},"delta":{"tool_calls":[{"function":{"arguments":"{\"slat\": \"hello\"}"},"index":0}]},"finish_reason":null,"index":0,"logprobs":null}],"created":1736912972,"id":"chatcmpl-ApohIw9hJ9OVHrr7P75dYmwSvFiS6","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_f3927aa00d"} +JSON, + <<<'JSON' +{"choices":[{"content_filter_results":{},"delta":{"tool_calls":[{"function":{"arguments":"","name":"get_rand_string"},"id":"call_RleVQfBwjkh4jez8JowsMSE1","index":1,"type":"function"}]},"finish_reason":null,"index":0,"logprobs":null}],"created":1736912972,"id":"chatcmpl-ApohIw9hJ9OVHrr7P75dYmwSvFiS6","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_f3927aa00d"} +JSON, + <<<'JSON' +{"choices":[{"content_filter_results":{},"delta":{"tool_calls":[{"function":{"arguments":"{\"slat\": \"hi\"}"},"index":1}]},"finish_reason":null,"index":0,"logprobs":null}],"created":1736912972,"id":"chatcmpl-ApohIw9hJ9OVHrr7P75dYmwSvFiS6","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_f3927aa00d"} +JSON, + <<<'JSON' +{"choices":[{"content_filter_results":{},"delta":{},"finish_reason":"tool_calls","index":0,"logprobs":null}],"created":1736912972,"id":"chatcmpl-ApohIw9hJ9OVHrr7P75dYmwSvFiS6","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_f3927aa00d"} +JSON, + ]; + $chunkedBody = []; + foreach ($list as $item) { + $chunkedBody[] = 'data:' . $item; + } + $chunkedBody[] = 'data:[DONE]'; + $stream = Utils::streamFor(implode("\r\n", $chunkedBody)); + $response = new Response(200, ['Transfer-Encoding' => 'chunked'], $stream); + $guzzleClientMock->method('post')->willReturn($response); + $this->setNonpublicPropertyValue($client, 'client', $guzzleClientMock); + + $messages = [ + new SystemMessage('你可以为用户生成随机字符串,调用 get_rand_string 工具来完成'), + new UserMessage('帮我生成 1 个随机字符串, slat 为 hello'), + ]; + $tool = [ + new class extends AbstractTool { + public string $name = 'get_rand_string'; + + public string $description = '生成随机字符串'; + + public array $parameters = [ + 'slat' => [ + 'type' => 'string', + 'description' => '盐值', + ], + ]; + + public function invoke($args): ?array + { + var_dump($args); + return [ + uniqid(), + ]; + } + }, + ]; + $result = $client->chat(messages: $messages, model: $this->model, tools: $tool, stream: true); + + $this->assertInstanceOf(ChatCompletionResponse::class, $result); + + $toolCalls = []; + $content = ''; + // 在 stream 模式下,需要自行维护 ToolCall 的组装 + foreach ($result->getStreamIterator() as $choice) { + /** @var AssistantMessage $message */ + $message = $choice->getMessage(); + foreach ($message->getToolCalls() as $toolCall) { + if ($toolCall->getId()) { + $toolCalls[] = new ToolCall($toolCall->getName(), [], $toolCall->getId(), $toolCall->getType(), $toolCall->getStreamArguments()); + } else { + /** @var ToolCall $lastToolCall */ + $lastToolCall = end($toolCalls); + $lastToolCall->appendStreamArguments($toolCall->getStreamArguments()); + } + } + $content .= $choice->getMessage()?->getContent() ?: ''; + } + $content = trim($content); + $this->assertIsString($content); + $this->assertSame('get_rand_string', $toolCalls[0]->getName()); + $this->assertSame(['slat' => 'hello'], $toolCalls[0]->getArguments()); + } + + public function testChatVision() + { + $this->markTestSkipped('豆包需要视觉模型'); + $client = new Client($this->config, new Logger()); + + $guzzleClientMock = $this->createMock(GuzzleClient::class); + $response = new Response( + 200, + [], + <<<'JSON' +{"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"finish_reason":"stop","index":0,"logprobs":null,"message":{"content":"图片中展示了一家人围坐在餐桌前,共同享用丰盛的中国菜肴。桌上摆满了各式各样的美食,包括整条烤鱼、炒虾、蔬菜拼盘、炒饭、汤等。每个人面前都有一副筷子,他们正享用着这顿美味的晚餐。桌上还有饮料,如橙汁,整个场景呈现出一种温馨祥和的家庭聚餐氛围。背景中可以看到传统的中式装修风格。","refusal":null,"role":"assistant"}}],"created":1736939844,"id":"chatcmpl-ApvgijBkXxrnuOdJmkcIkHJp10eSD","model":"gpt-4o-2024-08-06","object":"chat.completion","prompt_filter_results":[{"prompt_index":0,"content_filter_result":{"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"},"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"},"jailbreak":{"filtered":false,"detected":false}}},{"prompt_index":2,"content_filter_result":{"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"},"hate":{"filtered":false,"severity":"safe"},"self_harm":{"filtered":false,"severity":"safe"}}}],"system_fingerprint":"fp_f3927aa00d","usage":{"completion_tokens":114,"completion_tokens_details":{"accepted_prediction_tokens":0,"audio_tokens":0,"reasoning_tokens":0,"rejected_prediction_tokens":0},"prompt_tokens":1301,"prompt_tokens_details":{"audio_tokens":0,"cached_tokens":0},"total_tokens":1415}} +JSON + ); + $guzzleClientMock->method('post')->willReturn($response); + $this->setNonpublicPropertyValue($client, 'client', $guzzleClientMock); + $userMessage = new UserMessage(); + $userMessage->addContent(UserMessageContent::text('这个图片里面有什么')); + $userMessage->addContent(UserMessageContent::imageUrl(base64_decode('aHR0cHM6Ly92Y2cwMi5jZnAuY24vY3JlYXRpdmUvdmNnLzgwMC9uZXcvVkNHMjExMjU4OTAwOTQwLmpwZw=='))); + + $messages = [ + new SystemMessage(''), + $userMessage, + ]; + $result = $client->chat(messages: $messages, model: $this->model); + + $this->assertInstanceOf(ChatCompletionResponse::class, $result); + var_dump((string) $result); + $this->assertNotEmpty($result->getChoices()[0]->getMessage()->getContent()); + + $this->assertNotEmpty($result->getId()); + $this->assertNotEmpty($result->getModel()); + $this->assertNotEmpty($result->getObject()); + } +} diff --git a/tests/Cases/Api/Doubao/DoubaoConfigTest.php b/tests/Cases/Api/Doubao/DoubaoConfigTest.php new file mode 100644 index 0000000..a75e053 --- /dev/null +++ b/tests/Cases/Api/Doubao/DoubaoConfigTest.php @@ -0,0 +1,32 @@ +assertSame('test_api_key', $config->getApiKey()); + $this->assertSame('https://custom.url/', $config->getBaseUrl()); + $this->assertSame('test_model', $config->getModel()); + } +} diff --git a/tests/Cases/Api/Doubao/DoubaoTest.php b/tests/Cases/Api/Doubao/DoubaoTest.php new file mode 100644 index 0000000..2e4594c --- /dev/null +++ b/tests/Cases/Api/Doubao/DoubaoTest.php @@ -0,0 +1,46 @@ +getClient($config); + + $this->assertInstanceOf(Client::class, $client); + } + + public function testGetClientWithExistingConfig() + { + $config = new DoubaoConfig('test_api_key', 'https://custom.url/', 'test_model'); + $skylark = new Doubao(); + + $client1 = $skylark->getClient($config); + $client2 = $skylark->getClient($config); + + $this->assertSame($client1, $client2); + } +} diff --git a/tests/Cases/Api/OpenAI/ClientTest.php b/tests/Cases/Api/OpenAI/ClientTest.php new file mode 100644 index 0000000..b94a548 --- /dev/null +++ b/tests/Cases/Api/OpenAI/ClientTest.php @@ -0,0 +1,186 @@ +model = env('OPENAI_MODEL'); + $this->config = new OpenAIConfig( + apiKey: env('OPENAI_API_KEY'), + baseUrl: env('OPENAI_HOST'), + ); + } + + public function testChat() + { + $client = new Client($this->config, new Logger()); + + $guzzleClientMock = $this->createMock(GuzzleClient::class); + $response = new Response( + 200, + [], + <<<'JSON' +{ + "choices": [ + { + "content_filter_results": { + "hate": { + "filtered": false, + "severity": "safe" + }, + "self_harm": { + "filtered": false, + "severity": "safe" + }, + "sexual": { + "filtered": false, + "severity": "safe" + }, + "violence": { + "filtered": false, + "severity": "safe" + } + }, + "finish_reason": "stop", + "index": 0, + "logprobs": null, + "message": { + "content": "Hello! How can I assist you today?", + "refusal": null, + "role": "assistant" + } + } + ], + "created": 1736846202, + "id": "chatcmpl-ApXKMLCwroGSJnFICgA6nhYZ7OQfO", + "model": "gpt-4o-2024-08-06", + "object": "chat.completion", + "prompt_filter_results": [ + { + "prompt_index": 0, + "content_filter_results": {} + } + ], + "system_fingerprint": "fp_f3927aa00d", + "usage": { + "completion_tokens": 92, + "completion_tokens_details": { + "accepted_prediction_tokens": 0, + "audio_tokens": 0, + "reasoning_tokens": 0, + "rejected_prediction_tokens": 0 + }, + "prompt_tokens": 61, + "prompt_tokens_details": { + "audio_tokens": 0, + "cached_tokens": 0 + }, + "total_tokens": 153 + } +} +JSON + ); + $guzzleClientMock->method('post')->willReturn($response); + $this->setNonpublicPropertyValue($client, 'client', $guzzleClientMock); + + $messages = [ + new SystemMessage(''), + new UserMessage('hello'), + ]; + $result = $client->chat(messages: $messages, model: $this->model); + + $this->assertInstanceOf(ChatCompletionResponse::class, $result); + var_dump((string) $result); + $this->assertNotEmpty($result->getChoices()[0]->getMessage()->getContent()); + + $this->assertNotEmpty($result->getId()); + $this->assertNotEmpty($result->getModel()); + $this->assertNotEmpty($result->getObject()); + } + + public function testChatStream() + { + $client = new Client($this->config, new Logger()); + + $guzzleClientMock = $this->createMock(GuzzleClient::class); + $list = [ + <<<'JSON' +{"choices":[],"created":0,"id":"","model":"","object":"","prompt_filter_results":[{"prompt_index":0,"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"jailbreak":{"filtered":false,"detected":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}}}]} +JSON, + <<<'JSON' +{"choices":[{"content_filter_results":{},"delta":{"content":"","role":"assistant"},"finish_reason":null,"index":0,"logprobs":null}],"created":1736906666,"id":"chatcmpl-Apn3aJ0BPybkQFIi3YO2XHweNLS8W","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_04751d0b65"} +JSON, + <<<'JSON' +{"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"protected_material_code":{"filtered":false,"detected":false},"protected_material_text":{"filtered":false,"detected":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"Hello! "},"finish_reason":null,"index":0,"logprobs":null}],"created":1736906666,"id":"chatcmpl-Apn3aJ0BPybkQFIi3YO2XHweNLS8W","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_04751d0b65"} +JSON, + <<<'JSON' +{"choices":[{"content_filter_results":{"hate":{"filtered":false,"severity":"safe"},"protected_material_code":{"filtered":false,"detected":false},"protected_material_text":{"filtered":false,"detected":false},"self_harm":{"filtered":false,"severity":"safe"},"sexual":{"filtered":false,"severity":"safe"},"violence":{"filtered":false,"severity":"safe"}},"delta":{"content":"How can I assist you today?"},"finish_reason":null,"index":0,"logprobs":null}],"created":1736906666,"id":"chatcmpl-Apn3aJ0BPybkQFIi3YO2XHweNLS8W","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_04751d0b65"} +JSON, + + <<<'JSON' +{"choices":[{"content_filter_results":{},"delta":{},"finish_reason":"stop","index":0,"logprobs":null}],"created":1736906666,"id":"chatcmpl-Apn3aJ0BPybkQFIi3YO2XHweNLS8W","model":"gpt-4o-2024-08-06","object":"chat.completion.chunk","system_fingerprint":"fp_04751d0b65"} +JSON, + ]; + $chunkedBody = []; + foreach ($list as $item) { + $chunkedBody[] = 'data:' . $item; + } + $chunkedBody[] = 'data:[DONE]'; + $stream = Utils::streamFor(implode("\r\n", $chunkedBody)); + $response = new Response(200, ['Transfer-Encoding' => 'chunked'], $stream); + $guzzleClientMock->method('post')->willReturn($response); + $this->setNonpublicPropertyValue($client, 'client', $guzzleClientMock); + + $messages = [ + new SystemMessage(''), + new UserMessage('hello'), + ]; + $result = $client->chat($messages, $this->model, stream: true); + $this->assertInstanceOf(ChatCompletionResponse::class, $result); + + $content = ''; + foreach ($result->getStreamIterator() as $choice) { + $content .= $choice->getMessage()?->getContent() ?: ''; + } + $content = trim($content); + var_dump($content); + $this->assertNotEmpty($content); + $this->assertNotEmpty($result->getId()); + $this->assertNotEmpty($result->getModel()); + $this->assertNotEmpty($result->getObject()); + } +} diff --git a/tests/Cases/Api/OpenAI/OpenAIConfigTest.php b/tests/Cases/Api/OpenAI/OpenAIConfigTest.php new file mode 100644 index 0000000..067ec62 --- /dev/null +++ b/tests/Cases/Api/OpenAI/OpenAIConfigTest.php @@ -0,0 +1,50 @@ +assertSame('test_api_key', $config->getApiKey()); + $this->assertSame('test_organization', $config->getOrganization()); + $this->assertSame('https://custom.url/', $config->getBaseUrl()); + } + + public function testConstructorWithDefaultBaseUrl() + { + $config = new OpenAIConfig('test_api_key', 'test_organization'); + + $this->assertSame('test_api_key', $config->getApiKey()); + $this->assertSame('test_organization', $config->getOrganization()); + $this->assertSame('https://api.openai.com/', $config->getBaseUrl()); + } + + public function testConstructorWithNullValues() + { + $config = new OpenAIConfig(); + + $this->assertNull($config->getApiKey()); + $this->assertNull($config->getOrganization()); + $this->assertSame('https://api.openai.com/', $config->getBaseUrl()); + } +} diff --git a/tests/Cases/Apis/AzureOpenAITest.php b/tests/Cases/Apis/AzureOpenAITest.php deleted file mode 100644 index 1d3c09a..0000000 --- a/tests/Cases/Apis/AzureOpenAITest.php +++ /dev/null @@ -1,89 +0,0 @@ -getClient($config); - $this->assertInstanceOf(Client::class, $client); - /** @var \GuzzleHttp\Client $guzzleClient */ - $guzzleClient = $this->getNonpublicProperty($client, 'client'); - $headers = $guzzleClient->getConfig('headers'); - $this->assertSame($apiKey, $headers['api-key']); - $this->assertSame('application/json', $headers['Content-Type']); - } - - public function testApiKey() - { - [, $config] = $this->buildClient(); - $this->assertNotEmpty($config->getApiKey()); - } - - public function testChat() - { - [, , $client] = $this->buildClient(); - $response = $client->chat([ - new SystemMessage('You are a Robot created by Hyperf, your purpose is to make people happy.'), - new UserMessage('Who are you ?') - ], 'gpt-35-turbo', temperature: 0.4); - $this->assertTrue($response->isSuccess()); - $this->assertCount(1, $response->getChoices()); - $this->assertTrue(str_contains($response->getChoices()[0]->getMessage()->getContent(), 'Hyperf')); - // Assert Usage - $usage = $response->getUsage(); - $this->assertGreaterThan(0, $usage->getCompletionTokens()); - $this->assertGreaterThan(0, $usage->getPromptTokens()); - $this->assertGreaterThan(0, $usage->getTotalTokens()); - } - - /** - * @skip - */ - public function testCompletions() - { - $this->markTestSkipped('Azure OpenAI still does not created a model that support completions'); - [, , $client] = $this->buildClient(); - $response = $client->completions('1+1=?', 'text-davinci-003', temperature: 0.4); - $this->assertTrue($response->isSuccess()); - $this->assertCount(1, $response->getChoices()); - $this->assertTrue(str_contains($response->getChoices()[0]->getText(), '2')); - // Assert Usage - $usage = $response->getUsage(); - $this->assertGreaterThan(0, $usage->getCompletionTokens()); - $this->assertGreaterThan(0, $usage->getPromptTokens()); - $this->assertGreaterThan(0, $usage->getTotalTokens()); - } - - public function testModels() - { - $this->expectException(NotImplementedException::class); - [, , $client] = $this->buildClient(); - $client->models(); - } - - /** - * @return array{0: AzureOpenAI, 1: Client, 2: AzureOpenAIConfig} - */ - protected function buildClient(): array - { - $openAI = new AzureOpenAI(); - $config = new AzureOpenAIConfig(apiKey: env('AZURE_OPENAI_API_KEY'), baseUrl: ('AZURE_OPENAI_API_BASE'), apiVersion: env('AZURE_OPENAI_API_VERSION'), deploymentName: env('AZURE_OPENAI_DEPLOYMENT_NAME'),); - $client = $openAI->getClient($config); - return [$openAI, $config, $client]; - } - -} \ No newline at end of file diff --git a/tests/Cases/Apis/OpenAITest.php b/tests/Cases/Apis/OpenAITest.php deleted file mode 100644 index f2e9eda..0000000 --- a/tests/Cases/Apis/OpenAITest.php +++ /dev/null @@ -1,88 +0,0 @@ -getClient($config); - $this->assertInstanceOf(Client::class, $client); - /** @var \GuzzleHttp\Client $guzzleClient */ - $guzzleClient = $this->getNonpublicProperty($client, 'client'); - $headers = $guzzleClient->getConfig('headers'); - $this->assertSame('Bearer ' . $apiKey, $headers['Authorization']); - $this->assertSame('application/json', $headers['Content-Type']); - $this->assertSame($organization, $headers['OpenAI-Organization']); - } - - public function testApiKey() - { - [, $config] = $this->buildClient(); - $this->assertTrue(str_starts_with($config->getApiKey(), 'sk-')); - } - - public function testChat() - { - [, , $client] = $this->buildClient(); - $response = $client->chat([ - new SystemMessage('You are a Robot created by Hyperf, your purpose is to make people happy.'), - new UserMessage('Who are you ?') - ], 'gpt-3.5-turbo', temperature: 0.4); - $this->assertTrue($response->isSuccess()); - $this->assertCount(1, $response->getChoices()); - $this->assertTrue(str_contains($response->getChoices()[0]->getMessage()->getContent(), 'Hyperf')); - // Assert Usage - $usage = $response->getUsage(); - $this->assertGreaterThan(0, $usage->getCompletionTokens()); - $this->assertGreaterThan(0, $usage->getPromptTokens()); - $this->assertGreaterThan(0, $usage->getTotalTokens()); - } - - public function testCompletions() - { - [, , $client] = $this->buildClient(); - $response = $client->completions('1+1=?', 'text-davinci-003', temperature: 0.4); - $this->assertTrue($response->isSuccess()); - $this->assertCount(1, $response->getChoices()); - $this->assertTrue(str_contains($response->getChoices()[0]->getText(), '2')); - // Assert Usage - $usage = $response->getUsage(); - $this->assertGreaterThan(0, $usage->getCompletionTokens()); - $this->assertGreaterThan(0, $usage->getPromptTokens()); - $this->assertGreaterThan(0, $usage->getTotalTokens()); - } - - public function testModels() - { - [, , $client] = $this->buildClient(); - $response = $client->models(); - $this->assertTrue($response->isSuccess()); - $this->assertGreaterThan(0, count($response->getData())); - $this->assertInstanceOf(Model::class, current($response->getData())); - $this->assertSame('whisper-1', $response->getData()[0]->getId()); - } - - /** - * @return array{0: OpenAI, 1: OpenAIConfig, 2: Client} - */ - protected function buildClient(): array - { - $openAI = new OpenAI(); - $config = new OpenAIConfig(\Hyperf\Support\env('OPENAI_API_KEY'),); - $client = $openAI->getClient($config); - return [$openAI, $config, $client]; - } - -} \ No newline at end of file diff --git a/tests/Cases/Message/UserMessageContentTest.php b/tests/Cases/Message/UserMessageContentTest.php new file mode 100644 index 0000000..633d812 --- /dev/null +++ b/tests/Cases/Message/UserMessageContentTest.php @@ -0,0 +1,84 @@ +assertInstanceOf(UserMessageContent::class, $content); + $this->assertSame('text', $content->getType()); + } + + public function testImageUrl() + { + $content = UserMessageContent::imageUrl('image_url'); + $this->assertInstanceOf(UserMessageContent::class, $content); + $this->assertSame('image_url', $content->getType()); + } + + public function testSetText() + { + $content = new UserMessageContent('text'); + $content->setText('Hello, World!'); + $this->assertSame('Hello, World!', $content->getText()); + } + + public function testSetImageUrl() + { + $content = new UserMessageContent('image_url'); + $content->setImageUrl('https://example.com/image.jpg'); + $this->assertSame('https://example.com/image.jpg', $content->getImageUrl()); + } + + public function testIsValid() + { + $textContent = new UserMessageContent('text'); + $textContent->setText('Hello, World!'); + $this->assertTrue($textContent->isValid()); + + $imageContent = new UserMessageContent('image_url'); + $imageContent->setImageUrl('https://example.com/image.jpg'); + $this->assertTrue($imageContent->isValid()); + + $invalidContent = new UserMessageContent('text'); + $this->assertFalse($invalidContent->isValid()); + } + + public function testToArray() + { + $textContent = new UserMessageContent('text'); + $textContent->setText('Hello, World!'); + $this->assertSame([ + 'type' => 'text', + 'text' => 'Hello, World!', + ], $textContent->toArray()); + + $imageContent = new UserMessageContent('image_url'); + $imageContent->setImageUrl('https://example.com/image.jpg'); + $this->assertSame([ + 'type' => 'image_url', + 'image_url' => [ + 'url' => 'https://example.com/image.jpg', + ], + ], $imageContent->toArray()); + } +} diff --git a/tests/Cases/Message/UserMessageTest.php b/tests/Cases/Message/UserMessageTest.php new file mode 100644 index 0000000..af79474 --- /dev/null +++ b/tests/Cases/Message/UserMessageTest.php @@ -0,0 +1,91 @@ +assertSame('Hello, World!', $message->getContent()); + $this->assertSame([ + 'role' => 'user', + 'content' => 'Hello, World!', + ], $message->toArray()); + } + + public function testAddContent() + { + $message = new UserMessage(''); + $content = UserMessageContent::text('Hello, World!'); + $message->addContent($content); + + $this->assertCount(1, $message->toArray()['content']); + $this->assertSame('Hello, World!', $message->toArray()['content'][0]['text']); + } + + public function testToArray() + { + $message = new UserMessage(''); + $content1 = UserMessageContent::text('Hello, World!'); + $content2 = UserMessageContent::imageUrl('https://example.com/image.jpg'); + $message->addContent($content1)->addContent($content2); + + $expected = [ + [ + 'type' => 'text', + 'text' => 'Hello, World!', + ], + [ + 'type' => 'image_url', + 'image_url' => [ + 'url' => 'https://example.com/image.jpg', + ], + ], + ]; + + $this->assertSame($expected, $message->toArray()['content']); + } + + public function testFromArray() + { + $data = [ + 'content' => [ + [ + 'type' => 'text', + 'text' => 'Hello, World!', + ], + [ + 'type' => 'image_url', + 'image_url' => [ + 'url' => 'https://example.com/image.jpg', + ], + ], + ], + ]; + + $message = UserMessage::fromArray($data); + + $this->assertCount(2, $message->toArray()); + $this->assertSame('Hello, World!', $message->toArray()['content'][0]['text']); + $this->assertSame('https://example.com/image.jpg', $message->toArray()['content'][1]['image_url']['url']); + } +} diff --git a/tests/Cases/Model/AzureOpenAIModelTest.php b/tests/Cases/Model/AzureOpenAIModelTest.php new file mode 100644 index 0000000..ffd78b7 --- /dev/null +++ b/tests/Cases/Model/AzureOpenAIModelTest.php @@ -0,0 +1,78 @@ +model = 'gpt-4o-global'; + $this->config = [ + 'api_key' => env('AZURE_OPENAI_4O_API_KEY'), + 'api_base' => env('AZURE_OPENAI_4O_API_BASE'), + 'api_version' => env('AZURE_OPENAI_4O_API_VERSION'), + 'deployment_name' => env('AZURE_OPENAI_4O_DEPLOYMENT_NAME'), + ]; + } + + public function testChat() + { + $this->markTestSkipped('Difficulties to mock'); + + $skylarkModel = new AzureOpenAIModel($this->model, $this->config); + + $messages = [ + new SystemMessage(''), + new UserMessage('hello'), + ]; + $response = $skylarkModel->chat($messages); + var_dump($response->getFirstChoice()->getMessage()->getContent()); + $this->assertNotEmpty($response->getFirstChoice()->getMessage()->getContent()); + } + + public function testChatStream() + { + $this->markTestSkipped('Difficulties to mock'); + + $skylarkModel = new AzureOpenAIModel($this->model, $this->config); + + $messages = [ + new SystemMessage(''), + new UserMessage('hello'), + ]; + $response = $skylarkModel->chat($messages, stream: true); + $this->assertTrue($response->isChunked()); + $content = ''; + foreach ($response->getStreamIterator() as $choice) { + $content .= $choice->getMessage()?->getContent() ?: ''; + } + var_dump($content); + $this->assertNotEmpty($content); + } +} diff --git a/tests/Cases/Model/DoubaoModelTest.php b/tests/Cases/Model/DoubaoModelTest.php new file mode 100644 index 0000000..6f058da --- /dev/null +++ b/tests/Cases/Model/DoubaoModelTest.php @@ -0,0 +1,77 @@ +model = env('SKYLARK_PRO_32K_ENDPOINT'); + $this->config = [ + 'api_key' => env('SKYLARK_API_KEY'), + 'base_url' => env('SKYLARK_HOST'), + 'model' => env('SKYLARK_PRO_32K_ENDPOINT'), + ]; + } + + public function testChat() + { + $this->markTestSkipped('Difficulties to mock'); + + $skylarkModel = new DoubaoModel($this->model, $this->config); + + $messages = [ + new SystemMessage(''), + new UserMessage('hello'), + ]; + $response = $skylarkModel->chat($messages); + var_dump($response->getFirstChoice()->getMessage()->getContent()); + $this->assertNotEmpty($response->getFirstChoice()->getMessage()->getContent()); + } + + public function testChatStream() + { + $this->markTestSkipped('Difficulties to mock'); + + $skylarkModel = new DoubaoModel($this->model, $this->config); + + $messages = [ + new SystemMessage(''), + new UserMessage('hello'), + ]; + $response = $skylarkModel->chat($messages, stream: true); + $this->assertTrue($response->isChunked()); + $content = ''; + foreach ($response->getStreamIterator() as $choice) { + $content .= $choice->getMessage()?->getContent() ?: ''; + } + var_dump($content); + $this->assertNotEmpty($content); + } +} diff --git a/tests/Cases/Model/OpenAIModelTest.php b/tests/Cases/Model/OpenAIModelTest.php new file mode 100644 index 0000000..9d84210 --- /dev/null +++ b/tests/Cases/Model/OpenAIModelTest.php @@ -0,0 +1,76 @@ +model = env('OPENAI_MODEL'); + $this->config = [ + 'api_key' => env('OPENAI_API_KEY'), + 'base_url' => env('OPENAI_HOST'), + ]; + } + + public function testChat() + { + $this->markTestSkipped('Difficulties to mock'); + + $model = new OpenAIModel($this->model, $this->config); + + $messages = [ + new SystemMessage(''), + new UserMessage('hello'), + ]; + $response = $model->chat($messages); + var_dump($response->getFirstChoice()->getMessage()->getContent()); + $this->assertNotEmpty($response->getFirstChoice()->getMessage()->getContent()); + } + + public function testChatStream() + { + $this->markTestSkipped('Difficulties to mock'); + + $model = new OpenAIModel($this->model, $this->config); + + $messages = [ + new SystemMessage(''), + new UserMessage('hello'), + ]; + $response = $model->chat($messages, stream: true); + $this->assertTrue($response->isChunked()); + $content = ''; + foreach ($response->getStreamIterator() as $choice) { + $content .= $choice->getMessage()?->getContent() ?: ''; + } + var_dump($content); + $this->assertNotEmpty($content); + } +}