wpmind/includes/API/Services/ChatService.php
LinuxJoy bb5e58282c fix: Vision API failover 限制为支持视觉的 Provider
ChatService.chat() 新增 failover_providers 选项,允许调用方约束
故障转移链。PublicAPI.vision() 传入 VisionHelper 的已配置视觉
Provider 列表,避免 failover 尝试不支持图片输入的 Provider
(如 DeepSeek/Moonshot/Doubao 等),减少无效重试。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-02-09 12:24:16 +08:00

618 lines
16 KiB
PHP

<?php
/**
* Chat Service
*
* 处理 AI 对话和流式输出
*
* @package WPMind
* @subpackage API\Services
* @since 3.7.0
*/
declare(strict_types=1);
namespace WPMind\API\Services;
use WP_Error;
/**
* Chat Service
*
* @since 3.7.0
*/
class ChatService extends AbstractService {
/**
* AI 对话(核心实现)
*
* @param string|array $messages 消息
* @param array $options 选项
* @return array|WP_Error
*/
public function chat($messages, array $options = []) {
$defaults = [
'context' => '',
'system' => '',
'max_tokens' => 1000,
'temperature' => 0.7,
'model' => 'auto',
'provider' => 'auto',
'json_mode' => false,
'cache_ttl' => 0,
'tools' => [],
'tool_choice' => 'auto',
'failover_providers' => [],
];
$options = wp_parse_args($options, $defaults);
$context = $options['context'];
$normalized_messages = $this->normalize_messages($messages, $options);
$args = [
'messages' => $normalized_messages,
'max_tokens' => $options['max_tokens'],
'temperature' => $options['temperature'],
'json_mode' => $options['json_mode'],
'tools' => $options['tools'],
'tool_choice' => $options['tool_choice'],
];
$args = apply_filters('wpmind_chat_args', $args, $context, $messages);
$original_model = $options['model'];
$model_is_auto = ($original_model === 'auto');
if ($model_is_auto) {
$model = $this->get_default_model();
} else {
$model = $original_model;
}
$model = apply_filters('wpmind_select_model', $model, $context, get_current_user_id());
$provider = $this->resolve_provider($options['provider'], $context);
$failover_chain = $this->get_failover_chain($provider);
// Filter failover chain to only supported providers (e.g. vision-capable).
if (!empty($options['failover_providers'])) {
$allowed = $options['failover_providers'];
$failover_chain = array_values(array_filter($failover_chain, function ($p) use ($allowed) {
return in_array($p, $allowed, true);
}));
if (empty($failover_chain)) {
return new WP_Error(
'wpmind_no_supported_provider',
__('没有可用的服务商支持此请求类型', 'wpmind')
);
}
}
if (!empty($failover_chain) && $failover_chain[0] !== $provider) {
do_action('wpmind_provider_failover', $provider, $failover_chain[0], $context);
}
$cache_key = $this->generate_cache_key('chat', $args, $provider, $model);
$cache_lookup = $this->get_cached_value($cache_key, (int) $options['cache_ttl']);
if ($cache_lookup['hit']) {
return $cache_lookup['value'];
}
do_action('wpmind_before_request', 'chat', $args, $context);
$result = null;
$last_error = null;
$tried_providers = [];
$failover_count = count($failover_chain);
foreach ($failover_chain as $index => $try_provider) {
$tried_providers[] = $try_provider;
$is_last_provider = ($index === $failover_count - 1);
$max_retries = $is_last_provider ? 3 : 1;
if ($model_is_auto) {
$try_model = $this->get_current_model($try_provider);
} else {
$try_model = $model;
$endpoints = $this->get_endpoints();
$provider_models = $endpoints[$try_provider]['models'] ?? [];
if (!empty($provider_models) && !in_array($try_model, $provider_models, true)) {
$try_model = $this->get_current_model($try_provider);
}
}
for ($attempt = 0; $attempt <= $max_retries; $attempt++) {
$result = $this->execute_chat_request($args, $try_provider, $try_model);
if (!is_wp_error($result)) {
if ($try_provider !== $provider) {
$result['failover'] = [
'original_provider' => $provider,
'actual_provider' => $try_provider,
'tried_providers' => $tried_providers,
];
}
if ($try_model !== $model) {
$result['model_fallback'] = true;
$result['original_model'] = $model;
}
break 2;
}
$last_error = $result;
$error_code = $result->get_error_code();
if (in_array($error_code, ['wpmind_api_key_missing', 'wpmind_provider_not_found'], true)) {
break;
}
$error_data = $result->get_error_data();
$status = is_array($error_data) && isset($error_data['status']) ? (int) $error_data['status'] : 0;
if ($status > 0 && !\WPMind\ErrorHandler::should_retry($status)) {
break;
}
if ($attempt < $max_retries) {
$delay_ms = \WPMind\ErrorHandler::get_retry_delay($attempt + 1);
do_action('wpmind_retry', $try_provider, $attempt + 1, $status);
sleep((int) ($delay_ms / 1000));
}
}
}
if (is_wp_error($result)) {
do_action('wpmind_error', $result, 'chat', $args);
return $result;
}
$result = apply_filters('wpmind_chat_response', $result, $args, $context);
$usage = $result['usage'] ?? [];
do_action('wpmind_after_request', 'chat', $result, $args, $usage);
if (!is_wp_error($result)) {
$this->set_cached_value($cache_key, $result, (int) $options['cache_ttl'], [
'type' => 'chat',
'context' => $context,
'provider' => $provider,
'model' => $model,
]);
}
return $result;
}
/**
* 流式输出
*
* @since 2.6.0
* @param array|string $messages 消息
* @param callable $callback 回调函数
* @param array $options 选项
* @return bool|WP_Error
*/
public function stream($messages, callable $callback, array $options = []) {
$defaults = [
'context' => '',
'system' => '',
'max_tokens' => 2000,
'temperature' => 0.7,
'model' => 'auto',
'provider' => 'auto',
];
$options = wp_parse_args($options, $defaults);
$context = $options['context'];
$normalized_messages = $this->normalize_messages($messages, $options);
$original_model = $options['model'];
$model_is_auto = ($original_model === 'auto');
$provider = $this->resolve_provider($options['provider'], $context);
$failover_chain = $this->get_failover_chain($provider);
if (!empty($failover_chain) && $failover_chain[0] !== $provider) {
do_action('wpmind_provider_failover', $provider, $failover_chain[0], $context);
}
do_action('wpmind_before_request', 'stream', compact('messages', 'options'), $context);
$endpoints = $this->get_endpoints();
$last_error = null;
foreach ($failover_chain as $index => $try_provider) {
if (!isset($endpoints[$try_provider])) {
$last_error = new WP_Error('wpmind_provider_not_found',
sprintf(__('服务商 %s 未配置', 'wpmind'), $try_provider));
continue;
}
$endpoint = $endpoints[$try_provider];
$api_key = $endpoint['api_key'] ?? '';
if (empty($api_key)) {
$last_error = new WP_Error('wpmind_api_key_missing',
sprintf(__('服务商 %s 未配置 API Key', 'wpmind'), $try_provider));
continue;
}
$model = $model_is_auto
? $this->get_current_model($try_provider)
: $original_model;
if ($model === 'auto' || $model === 'default') {
$model = $endpoint['models'][0] ?? 'gpt-3.5-turbo';
}
$base_url = $endpoint['custom_base_url'] ?? $endpoint['base_url'] ?? '';
$api_url = trailingslashit($base_url) . 'chat/completions';
$request_body = [
'model' => $model,
'messages' => $normalized_messages,
'max_tokens' => $options['max_tokens'],
'temperature' => $options['temperature'],
'stream' => true,
];
$stream_context_options = [
'http' => [
'method' => 'POST',
'header' => [
'Content-Type: application/json',
'Authorization: Bearer ' . $api_key,
],
'content' => wp_json_encode($request_body),
'timeout' => 120,
],
'ssl' => [
'verify_peer' => true,
],
];
$start_time = microtime(true);
// 检查 allow_url_fopen 是否启用
if (!ini_get('allow_url_fopen')) {
$last_error = new WP_Error('wpmind_stream_unsupported',
__('流式输出需要 PHP allow_url_fopen 配置启用', 'wpmind'));
continue;
}
$stream_ctx = stream_context_create($stream_context_options);
$stream = @fopen($api_url, 'r', false, $stream_ctx);
if (!$stream) {
$latency_ms = (int)((microtime(true) - $start_time) * 1000);
$this->record_result($try_provider, false, $latency_ms);
$last_error = new WP_Error('wpmind_stream_failed',
sprintf(__('服务商 %s 无法建立流式连接', 'wpmind'), $try_provider));
continue;
}
$full_content = '';
while (!feof($stream)) {
$line = fgets($stream);
if (empty($line)) continue;
$line = trim($line);
if (strpos($line, 'data: ') !== 0) continue;
$data = substr($line, 6);
if ($data === '[DONE]') break;
$json = json_decode($data, true);
if (!$json) continue;
$delta = $json['choices'][0]['delta']['content'] ?? '';
if (!empty($delta)) {
$full_content .= $delta;
call_user_func($callback, $delta, $json);
}
}
fclose($stream);
$latency_ms = (int)((microtime(true) - $start_time) * 1000);
$this->record_result($try_provider, true, $latency_ms);
do_action('wpmind_after_request', 'stream', ['content' => $full_content], compact('messages', 'options'), []);
return true;
}
if ($last_error) {
do_action('wpmind_error', $last_error, 'stream', compact('messages', 'options'));
return $last_error;
}
return new WP_Error('wpmind_stream_failed', __('无法建立流式连接', 'wpmind'));
}
/**
* 标准化消息格式
*
* @param array|string $messages 原始消息
* @param array $options 选项
* @return array
*/
public function normalize_messages($messages, array $options): array {
if (is_string($messages)) {
$normalized = [];
if (!empty($options['system'])) {
$normalized[] = [
'role' => 'system',
'content' => $options['system'],
];
}
$normalized[] = [
'role' => 'user',
'content' => $messages,
];
return $normalized;
}
return $messages;
}
/**
* 判断是否应该使用 SDK 执行请求
*
* @since 3.6.0
* @param string $provider 服务商 ID
* @param array $args 请求参数
* @return bool
*/
private function should_use_sdk(string $provider, array $args): bool {
if (!class_exists('\\WPMind\\SDK\\SDKAdapter')) {
return false;
}
if (!class_exists('WordPress\\AiClient\\AiClient')) {
return false;
}
if (!get_option('wpmind_sdk_enabled', true)) {
return false;
}
if (!empty($args['tools'])) {
return false;
}
$sdk_providers = apply_filters('wpmind_sdk_providers', ['anthropic', 'google']);
return in_array($provider, $sdk_providers, true);
}
/**
* 执行 Chat 请求(路由方法)
*
* @since 3.6.0
* @param array $args 请求参数
* @param string $provider 服务商
* @param string $model 模型
* @return array|WP_Error
*/
private function execute_chat_request(array $args, string $provider, string $model) {
if ($this->should_use_sdk($provider, $args)) {
$sdk = new \WPMind\SDK\SDKAdapter();
$start_time = microtime(true);
$result = $sdk->chat($args, $provider, $model);
$latency_ms = (int)((microtime(true) - $start_time) * 1000);
if (!is_wp_error($result)) {
$this->record_result($provider, true, $latency_ms);
return $result;
}
$error_code = $result->get_error_code();
if ($error_code === 'wpmind_sdk_invalid_args' || $error_code === 'wpmind_sdk_unavailable') {
do_action('wpmind_sdk_fallback', $provider, $error_code, $result->get_error_message());
} else {
$this->record_result($provider, false, $latency_ms);
return $result;
}
}
return $this->execute_chat_request_native($args, $provider, $model);
}
/**
* 通过原生 HTTP 执行 chat 请求
*
* @since 3.6.0
* @param array $args 请求参数
* @param string $provider 服务商
* @param string $model 模型
* @return array|WP_Error
*/
private function execute_chat_request_native(array $args, string $provider, string $model) {
$endpoints = $this->get_endpoints();
if (!isset($endpoints[$provider])) {
return new WP_Error(
'wpmind_provider_not_found',
sprintf(__('服务商 %s 未配置', 'wpmind'), $provider)
);
}
$endpoint = $endpoints[$provider];
$api_key = $endpoint['api_key'] ?? '';
if (empty($api_key)) {
return new WP_Error(
'wpmind_api_key_missing',
sprintf(__('服务商 %s 未配置 API Key', 'wpmind'), $provider)
);
}
if ($model === 'auto' || empty($model) || $model === 'default') {
$model = $endpoint['models'][0] ?? 'gpt-3.5-turbo';
}
$request_body = [
'model' => $model,
'messages' => $args['messages'],
'max_tokens' => $args['max_tokens'],
'temperature' => $args['temperature'],
];
if (!empty($args['json_mode'])) {
$request_body['response_format'] = ['type' => 'json_object'];
}
if (!empty($args['tools'])) {
$request_body['tools'] = $args['tools'];
if (!empty($args['tool_choice']) && $args['tool_choice'] !== 'auto') {
$request_body['tool_choice'] = $args['tool_choice'];
}
}
$base_url = $endpoint['custom_base_url'] ?? $endpoint['base_url'] ?? '';
$api_url = trailingslashit($base_url) . 'chat/completions';
$start_time = microtime(true);
$response = wp_remote_post($api_url, [
'headers' => [
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
],
'body' => wp_json_encode($request_body),
'timeout' => 60,
]);
$latency_ms = (int)((microtime(true) - $start_time) * 1000);
if (is_wp_error($response)) {
$this->record_result($provider, false, $latency_ms);
return new WP_Error(
'wpmind_request_failed',
sprintf(__('请求失败: %s', 'wpmind'), $response->get_error_message())
);
}
$status_code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if ($status_code !== 200) {
$this->record_result($provider, false, $latency_ms);
$error_message = $data['error']['message'] ?? __('未知错误', 'wpmind');
return new WP_Error(
'wpmind_api_error',
sprintf(__('API 错误 (%d): %s', 'wpmind'), $status_code, $error_message),
['status' => $status_code, 'body' => substr((string) $body, 0, 500)]
);
}
if (!is_array($data)) {
$this->record_result($provider, false, $latency_ms);
return new WP_Error(
'wpmind_invalid_response',
__('服务商返回了无效的响应格式', 'wpmind'),
['status' => $status_code, 'body' => substr((string) $body, 0, 500)]
);
}
$this->record_result($provider, true, $latency_ms);
return $this->parse_chat_response($data, $provider, $model);
}
/**
* 解析 Chat 响应
*
* @param array $response 原始响应
* @param string $provider 服务商
* @param string $model 模型
* @return array
*/
public function parse_chat_response(array $response, string $provider, string $model): array {
$content = '';
$tool_calls = [];
$finish_reason = '';
$usage = [
'prompt_tokens' => 0,
'completion_tokens' => 0,
'total_tokens' => 0,
];
$message = $response['choices'][0]['message'] ?? [];
$finish_reason = $response['choices'][0]['finish_reason'] ?? '';
if (isset($message['content'])) {
$content = $message['content'];
} elseif (isset($response['content'][0]['text'])) {
$content = $response['content'][0]['text'];
}
if (isset($message['tool_calls']) && is_array($message['tool_calls'])) {
foreach ($message['tool_calls'] as $call) {
$tool_calls[] = [
'id' => $call['id'] ?? '',
'type' => $call['type'] ?? 'function',
'function' => [
'name' => $call['function']['name'] ?? '',
'arguments' => $call['function']['arguments'] ?? '{}',
],
];
}
}
if (isset($response['usage'])) {
$usage = [
'prompt_tokens' => $response['usage']['prompt_tokens'] ?? 0,
'completion_tokens' => $response['usage']['completion_tokens'] ?? 0,
'total_tokens' => $response['usage']['total_tokens'] ?? 0,
];
}
$result = [
'content' => $content,
'provider' => $provider,
'model' => $model,
'usage' => $usage,
'finish_reason' => $finish_reason,
];
if (!empty($tool_calls)) {
$result['tool_calls'] = $tool_calls;
}
return $result;
}
/**
* 默认响应过滤器
*
* @param array $response 响应
* @param array $args 参数
* @param string $context 上下文
* @return array
*/
public function filter_chat_response(array $response, array $args, string $context): array {
return $response;
}
/**
* 公共访问器:获取当前模型(供 Facade 的 get_status() 使用)
*
* @param string $provider 服务商
* @return string
*/
public function get_current_model_public(string $provider): string {
return $this->get_current_model($provider);
}
}