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; } }