Files
sbnews/server/app/common/service/ai/DeepSeekClient.php
T

461 lines
16 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);
$retryTimes = $this->resolveRetryTimes($options['retry_times'] ?? null);
$retrySleepMs = $this->resolveRetrySleepMs($options['retry_sleep_ms'] ?? null);
$lastResult = null;
for ($attempt = 0; $attempt <= $retryTimes; $attempt++) {
$transport = $this->sendHttpRequest($endpoint, $payload, $apiKey, $timeout);
$response = (string) ($transport['response'] ?? '');
$httpCode = (int) ($transport['http_code'] ?? 0);
$error = (string) ($transport['error'] ?? '');
$costMs = (int) ((microtime(true) - $startTime) * 1000);
if ($error !== '') {
$lastResult = $this->buildFailureResult(
'cURL错误: ' . $error,
$payload,
$providerLabel,
$costMs,
$attempt
);
if ($this->shouldRetry($attempt, $retryTimes, $httpCode, $error)) {
$this->sleepBeforeRetry($attempt, $retrySleepMs);
continue;
}
return $lastResult;
}
$data = json_decode($response, true);
if (!is_array($data)) {
$errMsg = json_last_error() === JSON_ERROR_NONE ? '响应JSON结构异常' : '响应不是有效JSON';
if ($httpCode > 0) {
$errMsg .= '(HTTP ' . $httpCode . ')';
}
$lastResult = $this->buildFailureResult(
$providerLabel . ' 返回异常: ' . $errMsg,
$payload,
$providerLabel,
$costMs,
$attempt
);
if ($this->shouldRetry($attempt, $retryTimes, $httpCode, '')) {
$this->sleepBeforeRetry($attempt, $retrySleepMs);
continue;
}
return $lastResult;
}
$content = $wireApi === 'responses' ? $this->extractResponsesContent($data) : ($data['choices'][0]['message']['content'] ?? '');
if ($httpCode !== 200 || $content === '') {
$errMsg = $data['error']['message'] ?? ('HTTP ' . $httpCode);
$lastResult = $this->buildFailureResult(
$providerLabel . ' 返回异常: ' . $errMsg,
$payload,
$providerLabel,
$costMs,
$attempt
);
if ($this->shouldRetry($attempt, $retryTimes, $httpCode, '')) {
$this->sleepBeforeRetry($attempt, $retrySleepMs);
continue;
}
return $lastResult;
}
$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,
];
}
return $lastResult ?: ['success' => false, 'error' => $providerLabel . ' 返回异常: 未知错误', 'tokens' => 0, 'cost_ms' => (int) ((microtime(true) - $startTime) * 1000), 'model' => $payload['model'], 'provider_label' => $providerLabel];
}
protected function sendHttpRequest(string $endpoint, array $payload, string $apiKey, int $timeout): array
{
$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 = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
return [
'response' => $response === false ? '' : (string) $response,
'http_code' => $httpCode,
'error' => (string) $error,
];
}
protected function buildFailureResult(string $error, array $payload, string $providerLabel, int $costMs, int $attempt): array
{
if ($attempt > 0) {
$error .= ',已重试' . $attempt . '次';
}
return [
'success' => false,
'error' => $error,
'tokens' => 0,
'cost_ms' => $costMs,
'model' => $payload['model'],
'provider_label' => $providerLabel,
];
}
protected function shouldRetry(int $attempt, int $retryTimes, int $httpCode, string $curlError): bool
{
if ($attempt >= $retryTimes) {
return false;
}
if ($curlError !== '') {
return true;
}
return $httpCode === 429 || ($httpCode >= 500 && $httpCode <= 599);
}
protected function sleepBeforeRetry(int $attempt, int $sleepMs): void
{
if ($sleepMs <= 0) {
return;
}
usleep($sleepMs * 1000 * max(1, $attempt + 1));
}
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);
}
if (!empty($options['tools']) && is_array($options['tools'])) {
$payload['tools'] = array_values($options['tools']);
}
if (!empty($options['tool_choice'])) {
$payload['tool_choice'] = $options['tool_choice'];
}
if (!empty($options['include']) && is_array($options['include'])) {
$payload['include'] = array_values($options['include']);
}
$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));
}
protected function resolveRetryTimes($retryTimes): int
{
$value = $retryTimes === null ? (int) AiConfig::getVal('ai_request_retry_times', '1') : (int) $retryTimes;
return max(0, min($value, 3));
}
protected function resolveRetrySleepMs($retrySleepMs): int
{
$value = $retrySleepMs === null ? (int) AiConfig::getVal('ai_request_retry_sleep_ms', '600') : (int) $retrySleepMs;
return max(0, min($value, 5000));
}
/**
* 发送请求并解析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;
}
}