metadata()->getId(); $cacheable = CloudflareTransporter::isDeterministic(is_array($data) ? $data : null); $url = $path === 'chat/completions' ? Gateway::chatUrl() : Gateway::directUrl(Gateway::chatUrl()); // Only chat is used by this model. // Vision: Cloudflare's OpenAI-compatible endpoint rejects image parts for // Llama 3.2 Vision ("Unable to add image when there are no ... messages", // code 3030). The native run endpoint accepts the same messages plus a // top-level base64 `image`, so image prompts are re-shaped and sent there. if (is_array($data) && $this->hasImagePart($data)) { $data = $this->toNativeVisionBody($data); $url = Gateway::runUrl(strpos($modelId, 'workers-ai/') === 0 ? substr($modelId, 11) : $modelId); } $headers = $headers + Gateway::headers(TaskContext::current(), $cacheable, $modelId); return new Request($method, $url, $headers, $data, $this->effectiveRequestOptions()); } private function hasImagePart(array $data): bool { foreach ((array) ($data['messages'] ?? []) as $m) { if (is_array($m) && isset($m['content']) && is_array($m['content'])) { foreach ($m['content'] as $part) { if (is_array($part) && ($part['type'] ?? '') === 'image_url') { return true; } } } } return false; } private function toNativeVisionBody(array $data): array { $messages = []; $image = null; foreach ((array) ($data['messages'] ?? []) as $m) { if (!is_array($m)) { continue; } $text = ''; if (isset($m['content']) && is_array($m['content'])) { foreach ($m['content'] as $part) { if (!is_array($part)) { continue; } if (($part['type'] ?? '') === 'text') { $text .= (string) ($part['text'] ?? ''); } elseif (($part['type'] ?? '') === 'image_url' && $image === null) { $url = (string) ($part['image_url']['url'] ?? ''); $comma = strpos($url, 'base64,'); $image = $comma !== false ? substr($url, $comma + 7) : $url; } } } else { $text = (string) ($m['content'] ?? ''); } if ($text !== '' || ($m['role'] ?? '') === 'system') { $messages[] = ['role' => (string) ($m['role'] ?? 'user'), 'content' => $text]; } } if ($messages === []) { $messages[] = ['role' => 'user', 'content' => 'Describe this image.']; } // The native endpoint rejects messages+image without a system message // ("Unable to add image when there are no ... system-supplied messages"). $hasSystem = false; foreach ($messages as $m) { if ($m['role'] === 'system') { $hasSystem = true; break; } } if (!$hasSystem) { $sys = \WordPress\CloudflareWorkersAiProvider\Prompts::render('vision_system'); array_unshift($messages, ['role' => 'system', 'content' => $sys !== '' ? $sys : 'You are a helpful assistant.']); } // Cloudflare accepts `messages` + image as a byte array, or `prompt` + // image as base64 - but not `messages` + base64 (error 3030). Byte // arrays keep the system/user roles but cost ~16 bytes of PHP memory per // image byte, so use them only for small images; above that fold the // roles into a single prompt. $bytes = $image !== null ? base64_decode($image, true) : false; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- image bytes for the vision endpoint $maxBytes = (int) apply_filters('aipcf_vision_byte_array_max', 512 * 1024); if ($bytes !== false && strlen($bytes) <= $maxBytes) { $byteArray = unpack('C*', $bytes) ?: []; unset($bytes); $body = [ 'messages' => $messages, 'image' => array_values($byteArray), ]; unset($byteArray); } else { $prompt = ''; foreach ($messages as $m) { $prompt .= ($prompt === '' ? '' : "\n\n") . $m['content']; } $body = ['prompt' => $prompt !== '' ? $prompt : 'Describe this image.']; if ($image !== null) { $body['image'] = $image; } } foreach (['max_tokens', 'temperature', 'top_p'] as $k) { if (isset($data[$k])) { $body[$k] = $data[$k]; } } return $body; } protected function parseResponseToGenerativeAiResult(Response $response): \WordPress\AiClient\Results\DTO\GenerativeAiResult { $data = $response->getData(); if (is_array($data) && isset($data['result']) && is_array($data['result']) && array_key_exists('response', $data['result'])) { $r = $data['result']; $usage = isset($r['usage']) && is_array($r['usage']) ? $r['usage'] : []; $shape = [ 'id' => '', 'model' => $this->metadata()->getId(), 'choices' => [[ 'index' => 0, 'message' => ['role' => 'assistant', 'content' => is_scalar($r['response']) ? (string) $r['response'] : (string) wp_json_encode($r['response'])], 'finish_reason' => 'stop', ]], 'usage' => [ 'prompt_tokens' => (int) ($usage['prompt_tokens'] ?? 0), 'completion_tokens' => (int) ($usage['completion_tokens'] ?? 0), 'total_tokens' => (int) ($usage['total_tokens'] ?? 0), 'neurons' => $usage['neurons'] ?? 0, ], ]; $response = new Response($response->getStatusCode(), $response->getHeaders(), (string) wp_json_encode($shape)); } return parent::parseResponseToGenerativeAiResult($response); } public function getHttpTransporter(): HttpTransporterInterface { return new CloudflareTransporter(parent::getHttpTransporter(), $this->metadata()->getId()); } private function effectiveRequestOptions(): RequestOptions { $options = $this->getRequestOptions(); if ($options !== null && $options->getTimeout() !== null) { return $options; } $timeout = (float) apply_filters('aipcf_request_timeout', self::DEFAULT_TIMEOUT, $this->metadata()->getId()); $merged = $options !== null ? clone $options : new RequestOptions(); $merged->setTimeout($timeout > 0 ? $timeout : self::DEFAULT_TIMEOUT); return $merged; } protected function prepareMessagesParam(array $messages, ?string $systemInstruction = null): array { if ($systemInstruction !== null && $systemInstruction !== '') { $systemInstruction .= $this->systemInstructionSuffix(); } $prepared = parent::prepareMessagesParam($messages, $systemInstruction); foreach ($prepared as &$message) { if (!isset($message['content']) || !is_array($message['content'])) { continue; } $text = ''; $allTextOnly = true; foreach ($message['content'] as $part) { if (is_array($part) && ($part['type'] ?? '') === 'text' && isset($part['text'])) { $text .= (string) $part['text']; continue; } $allTextOnly = false; break; } if ($allTextOnly) { $message['content'] = $text; } } unset($message); return $prepared; } protected function prepareGenerateTextParams(array $prompt): array { $params = parent::prepareGenerateTextParams($prompt); $modelId = $this->metadata()->getId(); // Budget guard (stop mode) - before anything is sent. if (UsageTracker::overBudget() && UsageTracker::budgetAction() === 'stop') { throw new RuntimeException(sprintf( /* translators: 1: neurons used today, 2: daily budget */ __('The daily budget is used up (%1$s of %2$s neurons). Requests resume at midnight UTC. To allow more, raise the daily budget under AIPCF > Settings > Budget.', 'aipcf-ai-provider-for-cloudflare'), number_format(UsageTracker::todayNeurons(), 0), number_format(UsageTracker::budget()) )); } if ($this->hasImagePart($params)) { // Image prompts go to the native run endpoint of this exact vision model: a // text model cannot read the image and a dynamic route has no run URL. // There is no cheaper vision model, so "cheapest" pauses here as well. if (UsageTracker::overBudget()) { throw new RuntimeException(__('The daily budget is used up. Image reading resumes at midnight UTC. To allow more, raise the daily budget under AIPCF > Settings > Budget.', 'aipcf-ai-provider-for-cloudflare')); } $params['model'] = $modelId; } else { // Light-model routing / budget downgrade, then gateway model naming. $modelId = TaskContext::routeTextModel($modelId); $params['model'] = Gateway::modelParam($modelId); } if (!isset($params['max_tokens'])) { $default = (int) apply_filters('aipcf_default_max_tokens', 2048, $modelId); if ($default > 0) { $params['max_tokens'] = $default; } } if (isset($params['chat_template_kwargs'])) { return $params; } // Mistral tokenizers reject chat_template_kwargs outright (HTTP 400), // Llama/Granite/Nemotron ignore it, so this stays an allowlist. $supportsSwitch = (bool) preg_match('#/(gemma-4|glm-|qwen3|deepseek-v4|nemotron-)#i', $modelId); if (apply_filters('aipcf_disable_thinking', $supportsSwitch, $modelId)) { $params['chat_template_kwargs'] = ['enable_thinking' => false]; } return $params; } private function systemInstructionSuffix(): string { $text = \WordPress\CloudflareWorkersAiProvider\Prompts::render('system_suffix'); $suffix = $text !== '' ? "\n\n" . $text : ''; return (string) apply_filters('aipcf_system_instruction_suffix', $suffix, $this->metadata()->getId()); } protected function getMessagePartContentData(MessagePart $part): ?array { $file = $part->getType()->isFile() ? $part->getFile() : null; if ($file !== null && $file->isRemote() && $file->isImage()) { $url = (string) $file->getUrl(); return [ 'type' => 'image_url', 'image_url' => ['url' => $this->fetchImageAsDataUri($url)], ]; } return parent::getMessagePartContentData($part); } private function fetchImageAsDataUri(string $url): string { $response = wp_remote_get( $url, [ 'timeout' => 20, 'limit_response_size' => self::MAX_INLINE_IMAGE_BYTES, ] ); if (is_wp_error($response)) { throw new InvalidArgumentException( /* translators: 1: image URL, 2: error message */ sprintf(__('Could not fetch the remote image "%1$s": %2$s', 'aipcf-ai-provider-for-cloudflare'), $url, $response->get_error_message()) ); } $status = (int) wp_remote_retrieve_response_code($response); $body = (string) wp_remote_retrieve_body($response); if ($status < 200 || $status >= 300 || $body === '') { throw new InvalidArgumentException( /* translators: 1: image URL, 2: HTTP status code */ sprintf(__('Could not fetch the remote image "%1$s" (HTTP %2$d).', 'aipcf-ai-provider-for-cloudflare'), $url, $status) ); } $mime = (string) wp_remote_retrieve_header($response, 'content-type'); $mime = strtolower(trim(explode(';', $mime)[0])); if (strpos($mime, 'image/') !== 0) { $detected = function_exists('finfo_open') ? (string) (new \finfo(FILEINFO_MIME_TYPE))->buffer($body) : ''; $mime = strpos($detected, 'image/') === 0 ? $detected : 'image/jpeg'; } return 'data:' . $mime . ';base64,' . base64_encode($body); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Workers AI only accepts inline images } protected function parseResponseChoiceMessageParts(array $messageData, int $index): array { // The AI plugin's alt-text ability expects the exact token [[DECORATIVE_ALT]] // when an image needs empty alt text; Llama wraps it in prose ("The alt // text for this image is: [[DECORATIVE_ALT]]"), which the ability would // then store verbatim as alt text. Normalise to the bare token. if ( isset($messageData['content']) && is_string($messageData['content']) && stripos($messageData['content'], '[[DECORATIVE_ALT]]') !== false ) { $messageData['content'] = '[[DECORATIVE_ALT]]'; } $parts = parent::parseResponseChoiceMessageParts($messageData, $index); $hasReasoningContent = isset($messageData['reasoning_content']) && is_string($messageData['reasoning_content']) && $messageData['reasoning_content'] !== ''; if ( !$hasReasoningContent && isset($messageData['reasoning']) && is_string($messageData['reasoning']) && $messageData['reasoning'] !== '' ) { array_unshift($parts, new MessagePart($messageData['reasoning'], MessagePartChannelEnum::thought())); } return $parts; } protected function throwIfNotSuccessful(Response $response): void { if ($response->isSuccessful()) { return; } if ($response->getStatusCode() === 429) { $d = $response->getData(); $m = is_array($d) ? ($d['errors'][0]['message'] ?? $d['error']['message'] ?? $d['error'] ?? '') : ''; $m = is_scalar($m) ? (string) $m : ''; throw new RuntimeException( 'Cloudflare Workers AI request was rate-limited (HTTP 429)' . ($m !== '' ? ': ' . $m : ' - an AI Gateway rate limit or spend limit was reached, or Workers AI is throttling this account.') ); } $data = $response->getData(); if (Gateway::isLicenceGate($data)) { $modelId = $this->metadata()->getId(); Gateway::recordLicenceGate($modelId, (string) ($data['errors'][0]['message'] ?? '')); throw new RuntimeException(Gateway::licenceInstructions($modelId)); } if (is_array($data) && isset($data['errors']) && is_array($data['errors']) && $data['errors'] !== []) { $first = $data['errors'][0]; $message = is_array($first) && isset($first['message']) ? (string) $first['message'] : ''; $code = is_array($first) && isset($first['code']) ? (string) $first['code'] : ''; if ($message !== '') { throw new RuntimeException( sprintf( 'Cloudflare Workers AI request failed (HTTP %d%s): %s', $response->getStatusCode(), $code !== '' ? ', code ' . $code : '', $message ) ); } } parent::throwIfNotSuccessful($response); } protected function parseResponseChoiceToCandidate(array $choiceData, int $index): \WordPress\AiClient\Results\DTO\Candidate { $msg = isset($choiceData['message']) && is_array($choiceData['message']) ? $choiceData['message'] : []; $hasText = isset($msg['content']) && is_string($msg['content']) && trim($msg['content']) !== ''; $hasReasoning = (isset($msg['reasoning']) && is_string($msg['reasoning']) && $msg['reasoning'] !== '') || (isset($msg['reasoning_content']) && is_string($msg['reasoning_content']) && $msg['reasoning_content'] !== ''); // Qwen3 with thinking switched off returns its answer under `reasoning` // with an empty `content`. A completed reply with only reasoning is the // answer, not a thought - promote it. if (!$hasText && $hasReasoning && ($choiceData['finish_reason'] ?? '') === 'stop' && empty($msg['tool_calls'])) { $answer = isset($msg['content']) && is_string($msg['content']) && trim($msg['content']) !== '' ? $msg['content'] : (string) ($msg['reasoning'] ?? $msg['reasoning_content'] ?? ''); $choiceData['message']['content'] = trim($answer); $choiceData['message'] = array_diff_key((array) $choiceData['message'], ['reasoning' => 1, 'reasoning_content' => 1]); } if (!$hasText && $hasReasoning && ($choiceData['finish_reason'] ?? '') === 'length' && empty($msg['tool_calls'])) { throw new RuntimeException(sprintf( /* translators: %s: model ID */ __('The Cloudflare Workers AI model "%s" used all its tokens on reasoning and returned no answer. Raise the maximum tokens for this request or choose a model without reasoning.', 'aipcf-ai-provider-for-cloudflare'), $this->metadata()->getId() )); } if (isset($choiceData['finish_reason'])) { $known = ['stop', 'length', 'content_filter', 'tool_calls']; if (!is_string($choiceData['finish_reason']) || !in_array($choiceData['finish_reason'], $known, true)) { $choiceData['finish_reason'] = 'stop'; } } else { $choiceData['finish_reason'] = 'stop'; } return parent::parseResponseChoiceToCandidate($choiceData, $index); } }