'system'|'user'|'assistant', 'content' => string], ...] * $opts: json — ask for a JSON object (the prompt must still say so) * max_tokens — cap on the reply (default 4000) * reasoning — 'off' | 'low' | 'high' | 'max' (default 'low') * timeout — seconds to wait for the whole reply (default 90) * * Returns ['content' => string, 'finish_reason' => ?string, 'model' => string, * 'usage' => [prompt_tokens, cache_hit_tokens, completion_tokens, reasoning_tokens]]. */ function aiChat(array $messages, array $opts = []): array { if (!aiEnabled()) { throw new AiUnavailable('The AI advisor is switched off.'); } $body = [ 'model' => aiModel(), 'messages' => $messages, 'stream' => false, 'max_tokens' => max(256, (int)($opts['max_tokens'] ?? 4000)), ]; if (!empty($opts['json'])) { $body['response_format'] = ['type' => 'json_object']; } // DeepSeek's thinking switch: `thinking.type` turns it on or off and // `reasoning_effort` (top level) says how hard. Other OpenAI-format servers // may reject unknown fields — set AI_REASONING_PARAM to false in config.php // to leave both out. if (!defined('AI_REASONING_PARAM') || AI_REASONING_PARAM !== false) { $reasoning = (string)($opts['reasoning'] ?? 'low'); if ($reasoning === 'off') { $body['thinking'] = ['type' => 'disabled']; } else { $body['thinking'] = ['type' => 'enabled']; $body['reasoning_effort'] = $reasoning; } } $ch = curl_init(aiBaseUrl() . '/chat/completions'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Accept: application/json', 'Authorization: Bearer ' . AI_API_KEY, ], CURLOPT_POSTFIELDS => json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), CURLOPT_RETURNTRANSFER => true, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => max(10, (int)($opts['timeout'] ?? 90)), CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, ]); $raw = curl_exec($ch); $errno = curl_errno($ch); $error = curl_error($ch); $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); if ($raw === false || $errno !== 0) { throw new AiUnavailable('Could not reach the AI service' . ($error ? " ($error)" : '') . '.'); } $data = json_decode((string)$raw, true); if ($status >= 500) { throw new AiUnavailable("The AI service is having trouble (HTTP $status) — try again later."); } if ($status === 429) { throw new AiRejected('The AI service is busy — try again in a moment.'); } if ($status >= 400) { $msg = is_array($data) ? ($data['error']['message'] ?? null) : null; throw new AiRejected('The AI service rejected the request' . (is_string($msg) && $msg !== '' ? ': ' . mb_substr($msg, 0, 200) : " (HTTP $status).")); } if (!is_array($data)) { throw new AiBadReply('The AI service sent something that is not JSON.'); } $choice = $data['choices'][0] ?? null; $content = is_array($choice) ? ($choice['message']['content'] ?? '') : ''; if (!is_string($content) || trim($content) === '') { throw new AiBadReply('The AI service returned an empty reply.'); } $u = is_array($data['usage'] ?? null) ? $data['usage'] : []; return [ 'content' => $content, 'finish_reason' => is_array($choice) ? ($choice['finish_reason'] ?? null) : null, 'model' => is_string($data['model'] ?? null) ? $data['model'] : aiModel(), 'usage' => [ 'prompt_tokens' => (int)($u['prompt_tokens'] ?? 0), 'cache_hit_tokens' => (int)($u['prompt_cache_hit_tokens'] ?? 0), 'completion_tokens' => (int)($u['completion_tokens'] ?? 0), 'reasoning_tokens' => (int)($u['completion_tokens_details']['reasoning_tokens'] ?? 0), ], ]; }