$texts * @param string $task Usage task the neurons are recorded under: 'embed' for content, * or the feature that embeds a query ('search', 'insights', ...). * @return list>|\WP_Error One vector per text, in the same order. */ public static function embed(array $texts, ?string $modelId = null, string $task = self::TASK) { $modelId = $modelId ?? Models::current(); $model = Models::get($modelId); if ($model === null) { /* translators: %s: model ID */ return new \WP_Error('aipcf_embed_input', sprintf(__('%s is not an embedding model this plugin supports. Choose another model on Settings > Content index.', 'aipcf-ai-provider-for-cloudflare'), $modelId)); } if (get_account_id() === '' || get_api_key() === '') { return new \WP_Error( 'aipcf_embed_auth', // A token saved in the plugin earlier still wins over Connectors: then the Overview tab is the place. core_connector_is_available() && (string) (get_plugin_settings()['api_key'] ?? '') === '' ? __('The Cloudflare Account ID or API token is missing. Add your API token on Settings > Connectors and your Account ID on Settings > Connection.', 'aipcf-ai-provider-for-cloudflare') : __('The Cloudflare Account ID or API token is missing. Add your Account ID and API token on Settings > Connection.', 'aipcf-ai-provider-for-cloudflare') ); } $texts = array_values(array_map('strval', $texts)); foreach ($texts as $i => $text) { if (trim($text) === '') { return new \WP_Error('aipcf_embed_input', __('A passage to embed is empty.', 'aipcf-ai-provider-for-cloudflare'), ['index' => $i]); } } $vectors = []; foreach (array_chunk($texts, self::BATCH) as $batch) { if (UsageTracker::overBudget()) { return new \WP_Error('aipcf_retry', __('The daily budget is used up. Embeddings resume at midnight UTC.', 'aipcf-ai-provider-for-cloudflare')); } $result = self::embedBatch($batch, $modelId, $model['dims'], $task); if (is_wp_error($result)) { return $result; } $tokens = 0; foreach ($batch as $text) { $tokens += Models::estimateTokens($text); } UsageTracker::record($modelId, Models::neuronsFor($modelId, $tokens), $tokens, 0, $task); array_push($vectors, ...$result); } return $vectors; } /** * @param list $batch * @return list>|\WP_Error */ private static function embedBatch(array $batch, string $modelId, int $dims, string $task = self::TASK) { $body = (string) wp_json_encode(['text' => $batch, 'truncate_inputs' => true]); $url = Gateway::runUrl($modelId); $auth = [ 'Authorization' => 'Bearer ' . get_api_key(), 'Content-Type' => 'application/json', ]; $viaGateway = Gateway::isGatewayUrl($url); $headers = $auth + ($viaGateway ? Gateway::headers($task, false, $modelId) : []); if ($viaGateway) { // Never keep the embedded text in the gateway's logs: it is the site's content, in // bulk, possibly private posts. Cost and metadata are still logged. $headers['cf-aig-collect-log-payload'] = 'false'; } $response = wp_remote_post($url, ['timeout' => self::TIMEOUT, 'headers' => $headers, 'body' => $body]); if ($viaGateway) { if (is_wp_error($response)) { // A timeout may already have been billed; only a failure to reach the gateway is retried direct. if (self::isTimeout($response)) { return new \WP_Error('aipcf_retry', $response->get_error_message()); } /* translators: %s: error message */ Gateway::markDown(sprintf(__('Network error: %s', 'aipcf-ai-provider-for-cloudflare'), $response->get_error_message())); $response = wp_remote_post(Gateway::directUrl($url), ['timeout' => self::TIMEOUT, 'headers' => $auth, 'body' => $body]); } else { $status = (int) wp_remote_retrieve_response_code($response); $data = json_decode((string) wp_remote_retrieve_body($response), true); if (Gateway::isGatewayFailure($status, is_array($data) ? $data : null)) { /* translators: %d: HTTP status code */ Gateway::markDown(sprintf(__('The gateway answered HTTP %d (Embeddings).', 'aipcf-ai-provider-for-cloudflare'), $status)); $response = wp_remote_post(Gateway::directUrl($url), ['timeout' => self::TIMEOUT, 'headers' => $auth, 'body' => $body]); } } } if (is_wp_error($response)) { return new \WP_Error('aipcf_retry', $response->get_error_message()); } $status = (int) wp_remote_retrieve_response_code($response); $data = json_decode((string) wp_remote_retrieve_body($response), true); $data = is_array($data) ? $data : []; if ($status < 200 || $status >= 300) { return self::errorFor($status, $data); } $vectors = $data['result']['data'] ?? null; if (!is_array($vectors) || count($vectors) !== count($batch)) { return new \WP_Error('aipcf_embed_response', sprintf( /* translators: 1: vectors expected, 2: vectors received */ __('Workers AI returned an unexpected answer: expected %1$d vectors, got %2$d.', 'aipcf-ai-provider-for-cloudflare'), count($batch), is_array($vectors) ? count($vectors) : 0 )); } $out = []; foreach ($vectors as $i => $vector) { if (!is_array($vector) || count($vector) !== $dims) { return new \WP_Error('aipcf_embed_response', sprintf( /* translators: 1: model ID, 2: numbers the model should return, 3: numbers received, 4: vector position */ __('Workers AI returned an unexpected answer: %1$s should return vectors of %2$d numbers, got %3$d (vector #%4$d).', 'aipcf-ai-provider-for-cloudflare'), $modelId, $dims, is_array($vector) ? count($vector) : 0, $i )); } $out[] = array_map('floatval', array_values($vector)); } return $out; } private static function errorFor(int $status, array $data): \WP_Error { $first = isset($data['errors'][0]) && is_array($data['errors'][0]) ? $data['errors'][0] : []; $code = (int) ($first['code'] ?? 0); // Cloudflare's own message is kept as it is (callers prefix where it came from). $message = isset($first['message']) && is_scalar($first['message']) ? (string) $first['message'] : sprintf('HTTP %d', $status); $context = ['status' => $status, 'cf_code' => $code]; if ($status === 429 || $status >= 500) { return new \WP_Error('aipcf_retry', $message, $context); } if ($status === 401 || $status === 403) { return new \WP_Error('aipcf_embed_auth', $message, $context); } // 3030 "Sequence too long" cannot recur on a retry; truncate_inputs normally prevents it. return new \WP_Error('aipcf_embed_input', $message, $context); } private static function isTimeout(\WP_Error $error): bool { return (bool) preg_match('/timed? ?out|cURL error 28/i', $error->get_error_message()); } }