wpmind/includes/SDK/SDKAdapter.php
LinuxJoy 6c36d563e4 security: Codex 审计问题修复 + WordPress 插件开发指导文档
安全修复(Codex 审计 11 项):
- transcribe() SSRF 防护: wp_http_validate_url + 协议白名单
- transcribe() 本地文件读取限制: realpath + uploads 目录校验
- transcribe() 文件大小上限 25MB + 扩展名白名单
- ErrorHandler 响应体截断到 500 字符,防止信息泄露
- SDKAdapter getTokenUsage() 空值保护
- SDKAdapter 异常消息脱敏,仅 WP_DEBUG 记录详情
- stream() 添加 allow_url_fopen 检测
- embed() JSON 解码显式校验
- speech() file_put_contents 返回值检查

新增文档:
- WordPress 插件开发指导手册 v1.1.0 (1225 行)
- 覆盖项目准备、编码规范、架构设计、开发流程等 10 章
- 基于 Codex 审计反馈补充安全基线和 WP 生态兼容性

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

208 lines
No EOL
5.6 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* WP AI Client SDK 适配器
*
* 封装 SDK 调用,提供与 PublicAPI 兼容的接口。
* SDK 使用异常而非 WP_Error响应是 GenerativeAiResult 对象而非数组。
* 本适配器负责两者之间的转换。
*
* @package WPMind\SDK
* @since 3.6.0
*/
declare(strict_types=1);
namespace WPMind\SDK;
use WP_Error;
/**
* SDK 适配器
*
* 将 WP AI Client SDK 的调用方式适配为 PublicAPI 兼容的数组格式。
*
* @since 3.6.0
*/
class SDKAdapter {
/**
* SDK 内置 Provider 映射
*
* @var array<string, string>
*/
private const BUILTIN_PROVIDERS = [
'openai' => 'WordPress\\AiClient\\Providers\\ProviderImplementations\\OpenAi\\OpenAiProvider',
'anthropic' => 'WordPress\\AiClient\\Providers\\ProviderImplementations\\Anthropic\\AnthropicProvider',
'google' => 'WordPress\\AiClient\\Providers\\ProviderImplementations\\Google\\GoogleProvider',
];
/**
* AI 对话
*
* @param array $args 请求参数messages, max_tokens, temperature, json_mode, tools, tool_choice
* @param string $provider 服务商标识
* @param string $model 模型标识
* @return array|WP_Error
*/
public function chat(array $args, string $provider, string $model): array|WP_Error {
// 检查 SDK 可用性
if (!class_exists('WordPress\\AiClient\\AiClient')) {
return new WP_Error('wpmind_sdk_unavailable', __('WP AI Client SDK 不可用', 'wpmind'));
}
// 解析 Provider 类名
$provider_class = $this->resolve_provider_class($provider);
try {
$registry = \WordPress\AiClient\AiClient::defaultRegistry();
// 获取模型实例
$model_instance = null;
if ($provider_class && $model !== 'auto' && $model !== 'default') {
try {
$model_instance = $registry->getProviderModel($provider_class, $model);
} catch (\Exception $e) {
// 模型不存在,尝试不指定模型
}
}
// 构建 PromptBuilder
$builder = \WordPress\AiClient\AiClient::prompt($args['messages']);
if ($model_instance) {
$builder->usingModel($model_instance);
} elseif ($provider_class) {
$builder->usingProvider($provider_class);
}
$builder->usingTemperature($args['temperature'] ?? 0.7);
$builder->usingMaxTokens($args['max_tokens'] ?? 2000);
// 提取 System instruction
$system_msg = null;
foreach ($args['messages'] as $msg) {
if (($msg['role'] ?? '') === 'system') {
$system_msg = $msg['content'] ?? '';
break;
}
}
if ($system_msg) {
$builder->usingSystemInstruction($system_msg);
}
// JSON mode
if (!empty($args['json_mode'])) {
$builder->asJsonResponse();
}
// 执行请求
$result = $builder->generateTextResult();
// 提取 finish_reason
$finish_reason = '';
$candidates = $result->getCandidates();
if (!empty($candidates)) {
$fr = $candidates[0]->getFinishReason();
if ($fr !== null) {
$finish_reason = is_object($fr) && property_exists($fr, 'value') ? $fr->value : (string) $fr;
}
}
return [
'content' => $result->toText(),
'provider' => $provider,
'model' => $model,
'usage' => $this->extract_token_usage($result),
'finish_reason' => $finish_reason,
];
} catch (\InvalidArgumentException $e) {
return new WP_Error('wpmind_sdk_invalid_args', $e->getMessage());
} catch (\RuntimeException $e) {
return $this->convert_exception_to_wp_error($e);
} catch (\Exception $e) {
return $this->convert_exception_to_wp_error($e);
}
}
/**
* 安全提取 token 用量
*
* @param object $result SDK 结果对象
* @return array
*/
private function extract_token_usage(object $result): array {
try {
$usage = $result->getTokenUsage();
if ($usage === null) {
return ['prompt_tokens' => 0, 'completion_tokens' => 0, 'total_tokens' => 0];
}
return [
'prompt_tokens' => $usage->getPromptTokens() ?? 0,
'completion_tokens' => $usage->getCompletionTokens() ?? 0,
'total_tokens' => $usage->getTotalTokens() ?? 0,
];
} catch (\Throwable $e) {
return ['prompt_tokens' => 0, 'completion_tokens' => 0, 'total_tokens' => 0];
}
}
/**
* 解析 Provider 类名
*
* 先检查 WPMind 注册的 Provider再检查 SDK 内置 Provider。
*
* @param string $provider 服务商标识
* @return string|null Provider 完整类名,未找到返回 null
*/
private function resolve_provider_class(string $provider): ?string {
// 先检查 WPMind 注册的 Provider
if (class_exists('WPMind\\Providers\\ProviderRegistrar')) {
$class = \WPMind\Providers\ProviderRegistrar::getProviderClass($provider);
if ($class) {
return $class;
}
}
// 再检查 SDK 内置 Provider
return self::BUILTIN_PROVIDERS[$provider] ?? null;
}
/**
* 将异常转换为 WP_Error
*
* 尝试从异常消息中提取 HTTP 状态码。
*
* @param \Exception $e 异常
* @return WP_Error
*/
private function convert_exception_to_wp_error(\Exception $e): WP_Error {
$message = $e->getMessage();
$status = 0;
// 尝试从异常消息中提取 HTTP 状态码
if (preg_match('/\b(4\d{2}|5\d{2})\b/', $message, $matches)) {
$status = (int) $matches[1];
}
$error_data = [];
if ($status > 0) {
$error_data['status'] = $status;
}
// 仅在 debug 模式下记录完整异常信息
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log(sprintf('[WPMind SDK] Exception: %s', $message));
}
// 对外返回通用描述,不暴露内部细节
$user_message = $status > 0
? sprintf(__('SDK 请求失败 (HTTP %d)', 'wpmind'), $status)
: __('SDK 请求失败', 'wpmind');
return new WP_Error(
'wpmind_sdk_error',
$user_message,
$error_data
);
}
}