349 lines
12 KiB
PHP
349 lines
12 KiB
PHP
<?php
|
|
|
|
namespace app\common\service\ai;
|
|
|
|
use app\common\model\ai\AiConfig;
|
|
|
|
class DeepSeekClient
|
|
{
|
|
private const DEFAULT_OPENAI_API_KEY = '';
|
|
private const DEFAULT_OPENAI_BASE_URL = 'https://lingsuan.top';
|
|
private const DEFAULT_OPENAI_MODEL = 'gpt-5.5';
|
|
private const DEFAULT_OPENAI_WIRE_API = 'responses';
|
|
private const DEFAULT_OPENAI_PROVIDER_LABEL = 'OpenAI';
|
|
private const DEFAULT_OPENAI_REASONING_EFFORT = 'xhigh';
|
|
private const DEFAULT_OPENAI_DISABLE_RESPONSE_STORAGE = '1';
|
|
|
|
protected string $apiKey;
|
|
protected string $baseUrl;
|
|
protected string $model;
|
|
protected string $providerLabel;
|
|
protected string $wireApi;
|
|
protected int $maxTokens;
|
|
protected float $temperature;
|
|
|
|
public function __construct()
|
|
{
|
|
$options = self::defaultModelOptions();
|
|
$this->apiKey = $options['api_key'];
|
|
$this->baseUrl = $options['base_url'];
|
|
$this->model = $options['model'];
|
|
$this->wireApi = strtolower((string) $options['wire_api']);
|
|
$this->providerLabel = $options['provider_label'];
|
|
$this->maxTokens = (int) AiConfig::getVal('max_tokens', '2000');
|
|
$this->temperature = (float) AiConfig::getVal('temperature', '0.7');
|
|
}
|
|
|
|
public static function defaultModelOptions(array $overrides = []): array
|
|
{
|
|
return array_replace([
|
|
'api_key' => self::resolveConfig('openai_api_key', 'OPENAI_API_KEY', self::DEFAULT_OPENAI_API_KEY),
|
|
'base_url' => self::resolveConfig('openai_base_url', 'OPENAI_BASE_URL', self::DEFAULT_OPENAI_BASE_URL),
|
|
'model' => self::resolveConfig('openai_model', 'OPENAI_MODEL', self::DEFAULT_OPENAI_MODEL),
|
|
'wire_api' => strtolower(self::resolveConfig('openai_wire_api', 'OPENAI_WIRE_API', self::DEFAULT_OPENAI_WIRE_API)),
|
|
'provider_label' => self::resolveConfig('openai_provider_label', 'OPENAI_PROVIDER_LABEL', self::DEFAULT_OPENAI_PROVIDER_LABEL),
|
|
'reasoning_effort' => self::resolveConfig('openai_reasoning_effort', 'OPENAI_REASONING_EFFORT', self::DEFAULT_OPENAI_REASONING_EFFORT),
|
|
'disable_response_storage' => self::resolveConfig('openai_disable_response_storage', 'OPENAI_DISABLE_RESPONSE_STORAGE', self::DEFAULT_OPENAI_DISABLE_RESPONSE_STORAGE),
|
|
], $overrides);
|
|
}
|
|
|
|
private static function resolveConfig(string $configName, string $envName, string $default): string
|
|
{
|
|
$value = trim((string) env($envName, ''));
|
|
if ($value === '') {
|
|
$value = trim((string) AiConfig::getVal($configName, ''));
|
|
}
|
|
return $value !== '' ? $value : $default;
|
|
}
|
|
|
|
/**
|
|
* 发送聊天请求
|
|
*/
|
|
public function chat(string $systemPrompt, string $userMessage, array $options = []): array
|
|
{
|
|
$messages = [
|
|
['role' => 'system', 'content' => $systemPrompt],
|
|
['role' => 'user', 'content' => $userMessage],
|
|
];
|
|
|
|
return $this->request($messages, $options);
|
|
}
|
|
|
|
public function chatMessages(array $messages, array $options = []): array
|
|
{
|
|
if (empty($messages)) {
|
|
return ['success' => false, 'error' => 'messages不能为空', 'tokens' => 0];
|
|
}
|
|
|
|
return $this->request($messages, $options);
|
|
}
|
|
|
|
public function chatWithImages(string $systemPrompt, string $userMessage, array $imageUrls, array $options = []): array
|
|
{
|
|
$content = [
|
|
['type' => 'text', 'text' => $userMessage],
|
|
];
|
|
|
|
foreach ($imageUrls as $imageUrl) {
|
|
if ($imageUrl === '') {
|
|
continue;
|
|
}
|
|
$content[] = [
|
|
'type' => 'image_url',
|
|
'image_url' => ['url' => $imageUrl],
|
|
];
|
|
}
|
|
|
|
$messages = [
|
|
['role' => 'system', 'content' => $systemPrompt],
|
|
['role' => 'user', 'content' => $content],
|
|
];
|
|
|
|
return $this->request($messages, $options);
|
|
}
|
|
|
|
protected function request(array $messages, array $options = []): array
|
|
{
|
|
$apiKey = $options['api_key'] ?? $this->apiKey;
|
|
$providerLabel = $options['provider_label'] ?? $this->providerLabel;
|
|
if (empty($apiKey)) {
|
|
return ['success' => false, 'error' => $providerLabel . ' API Key未配置', 'tokens' => 0, 'model' => $options['model'] ?? $this->model, 'provider_label' => $providerLabel];
|
|
}
|
|
|
|
$startTime = microtime(true);
|
|
$baseUrl = rtrim($options['base_url'] ?? $this->baseUrl, '/');
|
|
$wireApi = strtolower((string) ($options['wire_api'] ?? $this->wireApi));
|
|
$payload = $wireApi === 'responses'
|
|
? $this->buildResponsesPayload($messages, $options)
|
|
: $this->buildChatPayload($messages, $options);
|
|
$endpoint = $wireApi === 'responses'
|
|
? $this->buildResponsesEndpoint($baseUrl)
|
|
: $this->buildChatEndpoint($baseUrl);
|
|
$timeout = $this->resolveTimeout($options['timeout'] ?? null);
|
|
|
|
$ch = curl_init($endpoint);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_POST => true,
|
|
CURLOPT_HTTPHEADER => [
|
|
'Content-Type: application/json',
|
|
'Authorization: Bearer ' . $apiKey,
|
|
],
|
|
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_CONNECTTIMEOUT => min(15, $timeout),
|
|
CURLOPT_TIMEOUT => $timeout,
|
|
CURLOPT_SSL_VERIFYPEER => false,
|
|
]);
|
|
|
|
$response = curl_exec($ch);
|
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
$error = curl_error($ch);
|
|
curl_close($ch);
|
|
|
|
$costMs = (int) ((microtime(true) - $startTime) * 1000);
|
|
|
|
if ($error) {
|
|
return ['success' => false, 'error' => 'cURL错误: ' . $error, 'tokens' => 0, 'cost_ms' => $costMs, 'model' => $payload['model'], 'provider_label' => $providerLabel];
|
|
}
|
|
|
|
$data = json_decode((string) $response, true);
|
|
if (!is_array($data)) {
|
|
$errMsg = json_last_error() === JSON_ERROR_NONE ? '响应JSON结构异常' : '响应不是有效JSON';
|
|
if ($httpCode > 0) {
|
|
$errMsg .= '(HTTP ' . $httpCode . ')';
|
|
}
|
|
return ['success' => false, 'error' => $providerLabel . ' 返回异常: ' . $errMsg, 'tokens' => 0, 'cost_ms' => $costMs, 'model' => $payload['model'], 'provider_label' => $providerLabel];
|
|
}
|
|
|
|
$content = $wireApi === 'responses' ? $this->extractResponsesContent($data) : ($data['choices'][0]['message']['content'] ?? '');
|
|
if ($httpCode !== 200 || $content === '') {
|
|
$errMsg = $data['error']['message'] ?? ('HTTP ' . $httpCode);
|
|
return ['success' => false, 'error' => $providerLabel . ' 返回异常: ' . $errMsg, 'tokens' => 0, 'cost_ms' => $costMs, 'model' => $payload['model'], 'provider_label' => $providerLabel];
|
|
}
|
|
|
|
$tokens = $data['usage']['total_tokens'] ?? (($data['usage']['input_tokens'] ?? 0) + ($data['usage']['output_tokens'] ?? 0));
|
|
|
|
return [
|
|
'success' => true,
|
|
'content' => $content,
|
|
'tokens' => $tokens,
|
|
'cost_ms' => $costMs,
|
|
'model' => $payload['model'],
|
|
'provider_label' => $providerLabel,
|
|
];
|
|
}
|
|
|
|
protected function buildChatPayload(array $messages, array $options): array
|
|
{
|
|
return [
|
|
'model' => $options['model'] ?? $this->model,
|
|
'messages' => $messages,
|
|
'max_tokens' => $options['max_tokens'] ?? $this->maxTokens,
|
|
'temperature' => $options['temperature'] ?? $this->temperature,
|
|
];
|
|
}
|
|
|
|
protected function buildResponsesPayload(array $messages, array $options): array
|
|
{
|
|
$instructions = [];
|
|
$input = [];
|
|
foreach ($messages as $message) {
|
|
$role = (string) ($message['role'] ?? 'user');
|
|
$content = $message['content'] ?? '';
|
|
if ($role === 'system') {
|
|
$text = $this->contentToText($content);
|
|
if ($text !== '') {
|
|
$instructions[] = $text;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
$input[] = [
|
|
'role' => $role === 'assistant' ? 'assistant' : 'user',
|
|
'content' => $this->convertResponsesContent($content),
|
|
];
|
|
}
|
|
|
|
$payload = [
|
|
'model' => $options['model'] ?? $this->model,
|
|
'input' => $input,
|
|
'max_output_tokens' => $options['max_tokens'] ?? $this->maxTokens,
|
|
'temperature' => $options['temperature'] ?? $this->temperature,
|
|
];
|
|
if (!empty($instructions)) {
|
|
$payload['instructions'] = implode("\n\n", $instructions);
|
|
}
|
|
$reasoningEffort = trim((string) ($options['reasoning_effort'] ?? ''));
|
|
if ($reasoningEffort !== '') {
|
|
$payload['reasoning'] = ['effort' => $reasoningEffort];
|
|
}
|
|
if (self::truthy($options['disable_response_storage'] ?? false)) {
|
|
$payload['store'] = false;
|
|
}
|
|
return $payload;
|
|
}
|
|
|
|
private static function truthy($value): bool
|
|
{
|
|
return in_array(strtolower(trim((string) $value)), ['1', 'true', 'yes', 'on'], true);
|
|
}
|
|
|
|
protected function convertResponsesContent($content): array
|
|
{
|
|
if (is_string($content)) {
|
|
return [['type' => 'input_text', 'text' => $content]];
|
|
}
|
|
|
|
$rows = [];
|
|
foreach ((array) $content as $item) {
|
|
$type = (string) ($item['type'] ?? '');
|
|
if ($type === 'text') {
|
|
$rows[] = ['type' => 'input_text', 'text' => (string) ($item['text'] ?? '')];
|
|
} elseif ($type === 'image_url') {
|
|
$imageUrl = $item['image_url']['url'] ?? '';
|
|
if ($imageUrl !== '') {
|
|
$rows[] = ['type' => 'input_image', 'image_url' => (string) $imageUrl];
|
|
}
|
|
}
|
|
}
|
|
return $rows ?: [['type' => 'input_text', 'text' => '']];
|
|
}
|
|
|
|
protected function contentToText($content): string
|
|
{
|
|
if (is_string($content)) {
|
|
return trim($content);
|
|
}
|
|
|
|
$texts = [];
|
|
foreach ((array) $content as $item) {
|
|
if (($item['type'] ?? '') === 'text') {
|
|
$texts[] = (string) ($item['text'] ?? '');
|
|
}
|
|
}
|
|
return trim(implode("\n", $texts));
|
|
}
|
|
|
|
protected function extractResponsesContent(?array $data): string
|
|
{
|
|
if (empty($data)) {
|
|
return '';
|
|
}
|
|
|
|
if (!empty($data['output_text'])) {
|
|
return (string) $data['output_text'];
|
|
}
|
|
|
|
$texts = [];
|
|
foreach (($data['output'] ?? []) as $output) {
|
|
foreach (($output['content'] ?? []) as $content) {
|
|
if (isset($content['text'])) {
|
|
$texts[] = (string) $content['text'];
|
|
}
|
|
}
|
|
}
|
|
return trim(implode("\n", $texts));
|
|
}
|
|
|
|
protected function buildChatEndpoint(string $baseUrl): string
|
|
{
|
|
if (str_ends_with($baseUrl, '/chat/completions')) {
|
|
return $baseUrl;
|
|
}
|
|
|
|
if (str_ends_with($baseUrl, '/v1')) {
|
|
return $baseUrl . '/chat/completions';
|
|
}
|
|
|
|
return $baseUrl . '/v1/chat/completions';
|
|
}
|
|
|
|
protected function buildResponsesEndpoint(string $baseUrl): string
|
|
{
|
|
if (str_ends_with($baseUrl, '/responses')) {
|
|
return $baseUrl;
|
|
}
|
|
|
|
if (str_ends_with($baseUrl, '/v1')) {
|
|
return $baseUrl . '/responses';
|
|
}
|
|
|
|
return $baseUrl . '/v1/responses';
|
|
}
|
|
|
|
protected function resolveTimeout($timeout): int
|
|
{
|
|
$value = (int) ($timeout ?: AiConfig::getVal('ai_request_timeout', '180'));
|
|
return max(30, min($value, 300));
|
|
}
|
|
|
|
/**
|
|
* 发送请求并解析JSON结果
|
|
*/
|
|
public function chatJson(string $systemPrompt, string $userMessage, array $options = []): array
|
|
{
|
|
$result = $this->chat($systemPrompt, $userMessage, $options);
|
|
if (!$result['success']) {
|
|
return $result;
|
|
}
|
|
|
|
$content = $result['content'];
|
|
// 尝试提取JSON块
|
|
if (preg_match('/```json\s*(.*?)\s*```/s', $content, $m)) {
|
|
$content = $m[1];
|
|
} elseif (preg_match('/\{.*\}/s', $content, $m)) {
|
|
$content = $m[0];
|
|
}
|
|
|
|
$parsed = json_decode($content, true);
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
$result['parsed'] = null;
|
|
$result['raw_content'] = $result['content'];
|
|
} else {
|
|
$result['parsed'] = $parsed;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
}
|