wpmind/modules/api-gateway/includes/Transform/RequestTransformer.php
LinuxJoy 36b69d300d security: API Gateway 全量代码审计修复 (59 issues)
Codex 审计发现 124 个问题,修复 59 个 P0-P2 级别问题:

P0 (CRITICAL):
- TOCTOU 竞态: TransientRateStore/SseConcurrencyGuard 改用 wp_cache_add 原子锁
- REST 路由: __return_true → check_bearer_present 轻量鉴权
- Schema 验证: messages/tools/tool_choice 添加完整 validate_callback
- SQL 列名不匹配: upsert 查询对齐 SchemaManager 定义
- SQL 表名插值: 统一使用 %i 占位符

P1 (HIGH):
- IP hash 加盐 (HMAC) + IP 解析统一 (context 共享)
- Header 注入防护 (X-Request-Id/响应头 \r\n 过滤)
- 数值参数范围校验 (temperature/top_p/penalties/max_tokens)
- insert_key 错误处理 + revoke_key 审计日志
- Pipeline finally 保护 + RateLimiter rollback 双 store
- wp_unslash 补全 + strtotime 返回值检查
- RedisRateStore $rid 冒号验证
- UpstreamStreamClient catch \Throwable

P2 (MEDIUM):
- AuditLogRepository DRY (提取 build_where_clause)
- Token 估算 CJK 优化 (mb_strlen)
- ResponseTransformMiddleware null 检查
- 嵌入响应 usage 数据修复
- SchemaManager autoload 优化

25 files changed, ~530 insertions, ~190 deletions

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

326 lines
8.8 KiB
PHP

<?php
/**
* Request Transformer
*
* Converts OpenAI-compatible REST requests into WPMind internal payloads.
*
* @package WPMind\Modules\ApiGateway\Transform
* @since 1.0.0
*/
declare(strict_types=1);
namespace WPMind\Modules\ApiGateway\Transform;
/**
* Class RequestTransformer
*
* Transforms incoming OpenAI-format REST requests into the internal
* payload structure consumed by WPMind's PublicAPI.
*/
final class RequestTransformer {
/**
* Transform a REST request into an internal payload.
*
* @param string $operation Operation type (e.g. 'chat.completions').
* @param \WP_REST_Request $request WordPress REST request.
* @return array{payload?: array, error?: \WP_Error} Result with payload or error.
*/
public function transform( string $operation, \WP_REST_Request $request ): array {
$body_check = $this->check_body_size( $request );
if ( is_wp_error( $body_check ) ) {
return [ 'error' => $body_check ];
}
return match ( $operation ) {
'chat.completions' => $this->transform_chat_completions( $request ),
'embeddings' => $this->transform_embeddings( $request ),
'responses' => $this->transform_responses( $request ),
'models' => [ 'payload' => [ 'operation' => 'models' ] ],
'model_detail' => [ 'payload' => [ 'operation' => 'model_detail' ] ],
'status' => [ 'payload' => [ 'operation' => 'status' ] ],
default => [
'error' => new \WP_Error(
'unsupported_operation',
sprintf( 'Unsupported operation: %s', sanitize_text_field( $operation ) ),
[ 'status' => 400 ]
),
],
};
}
/**
* Check request body size against configured limit.
*
* @param \WP_REST_Request $request REST request.
* @return true|\WP_Error True if within limit, WP_Error if exceeded.
*/
private function check_body_size( \WP_REST_Request $request ): true|\WP_Error {
$max_bytes = (int) get_option( 'wpmind_gateway_max_body_bytes', 10485760 );
$body_size = strlen( $request->get_body() );
if ( $body_size > $max_bytes ) {
return new \WP_Error(
'request_too_large',
sprintf(
'Request body size %d bytes exceeds maximum %d bytes.',
$body_size,
$max_bytes
),
[ 'status' => 413 ]
);
}
return true;
}
/**
* Enforce max_tokens cap from site configuration.
*
* @param int|null $max_tokens Requested max_tokens value.
* @return int|null Capped value or null.
*/
private function cap_max_tokens( ?int $max_tokens ): ?int {
if ( $max_tokens === null ) {
return null;
}
$cap = (int) get_option( 'wpmind_gateway_max_tokens_cap', 16384 );
return min( $max_tokens, $cap );
}
/**
* Transform a chat completions request.
*
* @param \WP_REST_Request $request REST request.
* @return array{payload?: array, error?: \WP_Error}
*/
private function transform_chat_completions( \WP_REST_Request $request ): array {
$model = $request->get_param( 'model' );
$resolved = ModelMapper::resolve( (string) $model );
if ( $resolved === null ) {
return [
'error' => new \WP_Error(
'model_not_found',
sprintf( 'Model "%s" is not available.', sanitize_text_field( (string) $model ) ),
[ 'status' => 400 ]
),
];
}
$messages = $request->get_param( 'messages' );
$max_tokens = $request->get_param( 'max_tokens' );
$stream = (bool) $request->get_param( 'stream' );
$options = [
'provider' => $resolved['provider'],
'model' => $resolved['model'],
];
$temperature = $request->get_param( 'temperature' );
if ( $temperature !== null ) {
$options['temperature'] = (float) $temperature;
}
$capped_max_tokens = $this->cap_max_tokens(
$max_tokens !== null ? (int) $max_tokens : null
);
if ( $capped_max_tokens !== null ) {
$options['max_tokens'] = $capped_max_tokens;
}
$top_p = $request->get_param( 'top_p' );
if ( $top_p !== null ) {
$options['top_p'] = (float) $top_p;
}
$frequency_penalty = $request->get_param( 'frequency_penalty' );
if ( $frequency_penalty !== null ) {
$options['frequency_penalty'] = (float) $frequency_penalty;
}
$presence_penalty = $request->get_param( 'presence_penalty' );
if ( $presence_penalty !== null ) {
$options['presence_penalty'] = (float) $presence_penalty;
}
$stop = $request->get_param( 'stop' );
if ( $stop !== null ) {
$options['stop'] = $stop;
}
$tools = $request->get_param( 'tools' );
if ( $tools !== null ) {
$options['tools'] = $tools;
}
$tool_choice = $request->get_param( 'tool_choice' );
if ( $tool_choice !== null ) {
$options['tool_choice'] = $tool_choice;
}
return [
'payload' => [
'operation' => 'chat.completions',
'provider' => $resolved['provider'],
'model' => $resolved['model'],
'original_model' => (string) $model,
'messages' => $messages,
'options' => $options,
'stream' => $stream,
],
];
}
/**
* Transform an embeddings request.
*
* @param \WP_REST_Request $request REST request.
* @return array{payload?: array, error?: \WP_Error}
*/
private function transform_embeddings( \WP_REST_Request $request ): array {
$model = $request->get_param( 'model' );
$resolved = ModelMapper::resolve( (string) $model );
if ( $resolved === null ) {
return [
'error' => new \WP_Error(
'model_not_found',
sprintf( 'Model "%s" is not available.', sanitize_text_field( (string) $model ) ),
[ 'status' => 400 ]
),
];
}
$input = $request->get_param( 'input' );
$encoding_format = $request->get_param( 'encoding_format' );
$options = [
'provider' => $resolved['provider'],
'model' => $resolved['model'],
];
if ( $encoding_format !== null ) {
$options['encoding_format'] = (string) $encoding_format;
}
return [
'payload' => [
'operation' => 'embeddings',
'provider' => $resolved['provider'],
'model' => $resolved['model'],
'original_model' => (string) $model,
'input' => $input,
'options' => $options,
],
];
}
/**
* Transform a Responses API request into chat format.
*
* @param \WP_REST_Request $request REST request.
* @return array{payload?: array, error?: \WP_Error}
*/
private function transform_responses( \WP_REST_Request $request ): array {
$model = $request->get_param( 'model' );
$resolved = ModelMapper::resolve( (string) $model );
if ( $resolved === null ) {
return [
'error' => new \WP_Error(
'model_not_found',
sprintf( 'Model "%s" is not available.', sanitize_text_field( (string) $model ) ),
[ 'status' => 400 ]
),
];
}
$input = $request->get_param( 'input' );
$instructions = $request->get_param( 'instructions' );
$max_tokens = $request->get_param( 'max_tokens' );
$stream = (bool) $request->get_param( 'stream' );
$messages = $this->convert_responses_input_to_messages( $input, $instructions );
$options = [
'provider' => $resolved['provider'],
'model' => $resolved['model'],
];
$temperature = $request->get_param( 'temperature' );
if ( $temperature !== null ) {
$options['temperature'] = (float) $temperature;
}
$capped_max_tokens = $this->cap_max_tokens(
$max_tokens !== null ? (int) $max_tokens : null
);
if ( $capped_max_tokens !== null ) {
$options['max_tokens'] = $capped_max_tokens;
}
$tools = $request->get_param( 'tools' );
if ( $tools !== null ) {
$options['tools'] = $tools;
}
return [
'payload' => [
'operation' => 'chat.completions',
'provider' => $resolved['provider'],
'model' => $resolved['model'],
'original_model' => (string) $model,
'messages' => $messages,
'options' => $options,
'stream' => $stream,
'source_format' => 'responses',
],
];
}
/**
* Convert Responses API input to chat messages format.
*
* @param string|array $input The input field from Responses API.
* @param string|null $instructions Optional system instructions.
* @return array Chat messages array.
*/
private function convert_responses_input_to_messages( string|array $input, ?string $instructions ): array {
$messages = [];
if ( $instructions !== null && $instructions !== '' ) {
$messages[] = [
'role' => 'system',
'content' => $instructions,
];
}
if ( is_string( $input ) ) {
$messages[] = [
'role' => 'user',
'content' => $input,
];
} elseif ( is_array( $input ) ) {
foreach ( $input as $item ) {
if ( is_string( $item ) ) {
$messages[] = [
'role' => 'user',
'content' => $item,
];
} elseif ( is_array( $item ) && isset( $item['role'], $item['content'] ) ) {
$messages[] = [
'role' => (string) $item['role'],
'content' => $item['content'],
];
}
}
}
return $messages;
}
}