UeditorAiProxy.php 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. <?php
  2. namespace app\common\util;
  3. /**
  4. * Server-side UEditor AI: calls upstream using ai_seo only (never exposed to browser).
  5. */
  6. class UeditorAiProxy
  7. {
  8. private const ANTHROPIC_VERSION = '2023-06-01';
  9. private const MAX_PROMPT_CHARS = 32000;
  10. /**
  11. * @param array $ai ai_seo from maccms
  12. * @return array{ok:bool,text:string,error:string,log_detail:string}
  13. */
  14. public static function complete(array $ai, $systemPrompt, $userPrompt)
  15. {
  16. $systemPrompt = self::clip((string) $systemPrompt);
  17. $userPrompt = self::clip((string) $userPrompt);
  18. if ($systemPrompt === '' && $userPrompt === '') {
  19. return ['ok' => false, 'text' => '', 'error' => 'empty prompt', 'log_detail' => 'empty prompt'];
  20. }
  21. $key = isset($ai['api_key']) ? trim((string) $ai['api_key']) : '';
  22. if ($key === '') {
  23. return ['ok' => false, 'text' => '', 'error' => 'api key not configured', 'log_detail' => 'no api key'];
  24. }
  25. $timeout = max(5, min(120, (int) (isset($ai['timeout']) ? $ai['timeout'] : 30)));
  26. $model = isset($ai['model']) ? trim((string) $ai['model']) : 'gpt-4o-mini';
  27. if ($model === '') {
  28. $model = 'gpt-4o-mini';
  29. }
  30. $provider = isset($ai['provider']) ? strtolower(trim((string) $ai['provider'])) : 'openai';
  31. if (in_array($provider, ['claude', 'anthropic'], true)) {
  32. return self::callAnthropic($ai, $key, $model, $systemPrompt, $userPrompt, $timeout);
  33. }
  34. return self::callOpenAiCompatible($ai, $key, $model, $systemPrompt, $userPrompt, $timeout);
  35. }
  36. private static function clip($s)
  37. {
  38. if (function_exists('mb_strlen') && mb_strlen($s) > self::MAX_PROMPT_CHARS) {
  39. return mb_substr($s, 0, self::MAX_PROMPT_CHARS);
  40. }
  41. if (strlen($s) > self::MAX_PROMPT_CHARS) {
  42. return substr($s, 0, self::MAX_PROMPT_CHARS);
  43. }
  44. return $s;
  45. }
  46. private static function callOpenAiCompatible(array $ai, $key, $model, $systemPrompt, $userPrompt, $timeout)
  47. {
  48. $base = isset($ai['api_base']) ? rtrim(trim((string) $ai['api_base']), '/') : '';
  49. if ($base === '') {
  50. $base = 'https://api.openai.com/v1';
  51. }
  52. $url = $base . '/chat/completions';
  53. $messages = [];
  54. if ($systemPrompt !== '') {
  55. $messages[] = ['role' => 'system', 'content' => $systemPrompt];
  56. }
  57. $messages[] = ['role' => 'user', 'content' => $userPrompt !== '' ? $userPrompt : $systemPrompt];
  58. $payload = [
  59. 'model' => $model,
  60. 'stream' => false,
  61. 'temperature' => 0.7,
  62. 'messages' => $messages,
  63. ];
  64. $headers = [
  65. 'Content-Type: application/json',
  66. 'Authorization: Bearer ' . $key,
  67. ];
  68. $res = self::httpPostJson($url, $headers, json_encode($payload, JSON_UNESCAPED_UNICODE), $timeout);
  69. if ($res['curl_error'] !== '') {
  70. return [
  71. 'ok' => false,
  72. 'text' => '',
  73. 'error' => self::safeClientMessage('upstream transport error'),
  74. 'log_detail' => 'curl: ' . $res['curl_error'],
  75. ];
  76. }
  77. if ($res['status'] < 200 || $res['status'] >= 300) {
  78. return [
  79. 'ok' => false,
  80. 'text' => '',
  81. 'error' => self::parseUpstreamError($res['body'], $res['status']),
  82. 'log_detail' => 'http ' . $res['status'] . ' ' . self::truncateForLog($res['body']),
  83. ];
  84. }
  85. $json = json_decode((string) $res['body'], true);
  86. if (!is_array($json)) {
  87. return ['ok' => false, 'text' => '', 'error' => 'invalid upstream response', 'log_detail' => 'invalid json'];
  88. }
  89. $text = '';
  90. if (isset($json['choices'][0]['message']['content'])) {
  91. $text = (string) $json['choices'][0]['message']['content'];
  92. }
  93. if ($text === '') {
  94. return ['ok' => false, 'text' => '', 'error' => 'empty model output', 'log_detail' => 'no choices content'];
  95. }
  96. return ['ok' => true, 'text' => $text, 'error' => '', 'log_detail' => 'ok'];
  97. }
  98. private static function callAnthropic(array $ai, $key, $model, $systemPrompt, $userPrompt, $timeout)
  99. {
  100. $base = isset($ai['api_base']) ? rtrim(trim((string) $ai['api_base']), '/') : '';
  101. if ($base === '') {
  102. $url = 'https://api.anthropic.com/v1/messages';
  103. } elseif (substr($base, -9) === '/messages') {
  104. $url = $base;
  105. } else {
  106. $url = $base . '/messages';
  107. }
  108. $payload = [
  109. 'model' => $model,
  110. 'max_tokens' => 4096,
  111. 'messages' => [
  112. [
  113. 'role' => 'user',
  114. 'content' => $userPrompt !== '' ? $userPrompt : $systemPrompt,
  115. ],
  116. ],
  117. ];
  118. if ($systemPrompt !== '' && $userPrompt !== '') {
  119. $payload['system'] = $systemPrompt;
  120. }
  121. $headers = [
  122. 'Content-Type: application/json',
  123. 'x-api-key: ' . $key,
  124. 'anthropic-version: ' . self::ANTHROPIC_VERSION,
  125. ];
  126. $res = self::httpPostJson($url, $headers, json_encode($payload, JSON_UNESCAPED_UNICODE), $timeout);
  127. if ($res['curl_error'] !== '') {
  128. return [
  129. 'ok' => false,
  130. 'text' => '',
  131. 'error' => self::safeClientMessage('upstream transport error'),
  132. 'log_detail' => 'curl: ' . $res['curl_error'],
  133. ];
  134. }
  135. if ($res['status'] < 200 || $res['status'] >= 300) {
  136. return [
  137. 'ok' => false,
  138. 'text' => '',
  139. 'error' => self::parseAnthropicError($res['body'], $res['status']),
  140. 'log_detail' => 'http ' . $res['status'] . ' ' . self::truncateForLog($res['body']),
  141. ];
  142. }
  143. $json = json_decode((string) $res['body'], true);
  144. if (!is_array($json)) {
  145. return ['ok' => false, 'text' => '', 'error' => 'invalid upstream response', 'log_detail' => 'invalid json'];
  146. }
  147. $text = '';
  148. if (!empty($json['content'][0]['text'])) {
  149. $text = (string) $json['content'][0]['text'];
  150. }
  151. if ($text === '') {
  152. return ['ok' => false, 'text' => '', 'error' => 'empty model output', 'log_detail' => 'no content text'];
  153. }
  154. return ['ok' => true, 'text' => $text, 'error' => '', 'log_detail' => 'ok'];
  155. }
  156. private static function httpPostJson($url, array $headers, $body, $timeout)
  157. {
  158. $ch = curl_init($url);
  159. curl_setopt_array($ch, [
  160. CURLOPT_POST => true,
  161. CURLOPT_RETURNTRANSFER => true,
  162. CURLOPT_HTTPHEADER => $headers,
  163. CURLOPT_POSTFIELDS => $body,
  164. CURLOPT_CONNECTTIMEOUT => min(15, $timeout),
  165. CURLOPT_TIMEOUT => $timeout,
  166. CURLOPT_SSL_VERIFYPEER => 0,
  167. CURLOPT_SSL_VERIFYHOST => 2,
  168. ]);
  169. $response = curl_exec($ch);
  170. $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
  171. $err = curl_error($ch);
  172. curl_close($ch);
  173. return [
  174. 'status' => $status,
  175. 'body' => $response === false ? '' : (string) $response,
  176. 'curl_error' => $err !== '' ? preg_replace('/https?:\/\/\S+/', '[url]', $err) : '',
  177. ];
  178. }
  179. private static function parseUpstreamError($body, $status)
  180. {
  181. $json = json_decode((string) $body, true);
  182. $msg = '';
  183. if (is_array($json)) {
  184. if (isset($json['error']['message'])) {
  185. $msg = (string) $json['error']['message'];
  186. } elseif (isset($json['message'])) {
  187. $msg = (string) $json['message'];
  188. }
  189. }
  190. if ($msg === '') {
  191. return self::safeClientMessage('upstream error HTTP ' . $status);
  192. }
  193. return self::safeClientMessage($msg);
  194. }
  195. private static function parseAnthropicError($body, $status)
  196. {
  197. $json = json_decode((string) $body, true);
  198. $msg = '';
  199. if (is_array($json) && isset($json['error']['message'])) {
  200. $msg = (string) $json['error']['message'];
  201. }
  202. if ($msg === '') {
  203. return self::safeClientMessage('Anthropic error HTTP ' . $status);
  204. }
  205. return self::safeClientMessage($msg);
  206. }
  207. private static function safeClientMessage($msg)
  208. {
  209. $msg = (string) $msg;
  210. if (preg_match('/sk-[a-zA-Z0-9_-]{10,}/', $msg)) {
  211. return 'upstream request failed';
  212. }
  213. if (preg_match('/https?:\/\/\S+/', $msg)) {
  214. return 'upstream request failed';
  215. }
  216. return function_exists('mb_substr') ? mb_substr($msg, 0, 300) : substr($msg, 0, 300);
  217. }
  218. private static function truncateForLog($body)
  219. {
  220. $s = preg_replace('/sk-[a-zA-Z0-9_-]+/', '[key]', (string) $body);
  221. return function_exists('mb_substr') ? mb_substr($s, 0, 500) : substr($s, 0, 500);
  222. }
  223. }