diff --git a/README.md b/README.md index d4d9566..92fd441 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,8 @@ The plugin uses the `everyalt` text domain and ships with a `.pot` in `languages ## ๐Ÿ“ Changelog ### Unreleased +- **Fixed** estimated costs now include "thinking" tokens that some providers report outside the output count (Gemini's were missing, so Gemini costs were under-reported by about a third). Logs show them as **Thinking Tokens**, and the spending limit uses the corrected amounts. +- **Improved** Gemini now uses Google's Interactions API, which sends images at low resolution (280 tokens instead of 1,120), cutting Gemini's cost per image by roughly half or more. Requests are sent with `store: false`, so Google doesn't keep them for later retrieval. - **New: alt text shows up in existing posts.** WordPress copies an image's alt text into a post when the image is inserted, so alt text generated later never reached older posts. EveryAlt now fills in empty alt text in post content from the Media Library as pages are displayed (WordPress 6.0+). Posts aren't modified, and alt text written in a post is never replaced. On by default; turn off under **Settings โ†’ Existing posts**. - **New: background processing.** Bulk generation and new uploads now run in a background queue: close the tab and it keeps going, failures retry automatically (after 1, 5, and 30 minutes), and uploads finish immediately. The queue runs on WP-Cron and also whenever an EveryAlt page is open, so it works on sites where WP-Cron is blocked (e.g. behind HTTP auth). Bulk tabs gain a **Generate for all** button. Prefer the old behavior for uploads? Choose **Settings โ†’ New uploads โ†’ Generate during the upload**. - **New: decorative image detection.** When the AI judges an image clearly decorative, its alt text is left empty on purpose (per WCAG) and it's flagged on the Review Alt Text tab with a **Describe anyway** button. When in doubt, the AI describes the image. Turn off under **Settings โ†’ Decorative images**. diff --git a/admin/class-everyalt-openai.php b/admin/class-everyalt-openai.php index 2da024f..5d33f71 100644 --- a/admin/class-everyalt-openai.php +++ b/admin/class-everyalt-openai.php @@ -252,8 +252,7 @@ private function generate_text( $attachment_id, $prompt, $metadata = null ) { 'cost' => '', ); } - $mime = $this->get_mime_type( $path ); - $data_url = 'data:' . $mime . ';base64,' . $base64; + $mime = $this->get_mime_type( $path ); $saved_max = get_option( 'every_alt_max_completion_tokens', '' ); $max_tokens = $saved_max !== '' ? max( 1, (int) $saved_max ) : self::DEFAULT_MAX_COMPLETION_TOKENS; @@ -261,30 +260,59 @@ private function generate_text( $attachment_id, $prompt, $metadata = null ) { if ( ! empty( $this->provider_def['max_tokens_cap'] ) ) { $max_tokens = min( (int) $max_tokens, (int) $this->provider_def['max_tokens_cap'] ); } - $image_url = array( 'url' => $data_url ); - if ( ! empty( $this->provider_def['image_detail'] ) ) { - // OpenAI-only hint: low detail keeps image input tokens (and cost) small; plenty for alt text. - $image_url['detail'] = $this->provider_def['image_detail']; - } - $body = array( - 'model' => $this->model, - $this->provider_def['token_param'] => (int) $max_tokens, - 'messages' => array( - array( - 'role' => 'user', - 'content' => array( - array( - 'type' => 'text', - 'text' => $prompt, - ), - array( - 'type' => 'image_url', - 'image_url' => $image_url, + $is_interactions = $this->is_interactions_api(); + if ( $is_interactions ) { + // Gemini Interactions API. Unlike its OpenAI-compatible endpoint, it lets us set the image + // resolution: "low" is 280 tokens per image instead of the default 1,120. + $image = array( + 'type' => 'image', + 'data' => $base64, + 'mime_type' => $mime, + ); + if ( ! empty( $this->provider_def['image_resolution'] ) ) { + $image['resolution'] = $this->provider_def['image_resolution']; + } + $body = array( + 'model' => $this->model, + 'input' => array( + array( + 'type' => 'text', + 'text' => $prompt, + ), + $image, + ), + 'generation_config' => array( 'max_output_tokens' => (int) $max_tokens ), + // Don't keep the request and image on Google's side for later retrieval. + 'store' => false, + ); + $headers = array( 'x-goog-api-key' => $this->api_key ); + } else { + $image_url = array( 'url' => 'data:' . $mime . ';base64,' . $base64 ); + if ( ! empty( $this->provider_def['image_detail'] ) ) { + // OpenAI-only hint: low detail keeps image input tokens (and cost) small; plenty for alt text. + $image_url['detail'] = $this->provider_def['image_detail']; + } + $body = array( + 'model' => $this->model, + $this->provider_def['token_param'] => (int) $max_tokens, + 'messages' => array( + array( + 'role' => 'user', + 'content' => array( + array( + 'type' => 'text', + 'text' => $prompt, + ), + array( + 'type' => 'image_url', + 'image_url' => $image_url, + ), ), ), ), - ), - ); + ); + $headers = array( 'Authorization' => 'Bearer ' . $this->api_key ); + } $body = array_merge( $body, $this->model_def['params'] ); @@ -292,10 +320,7 @@ private function generate_text( $attachment_id, $prompt, $metadata = null ) { $this->provider_def['endpoint'], array( 'timeout' => 60, - 'headers' => array( - 'Authorization' => 'Bearer ' . $this->api_key, - 'Content-Type' => 'application/json', - ), + 'headers' => $headers + array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( $body ), ) ); @@ -312,7 +337,7 @@ private function generate_text( $attachment_id, $prompt, $metadata = null ) { $code = wp_remote_retrieve_response_code( $response ); $body_raw = wp_remote_retrieve_body( $response ); $json = json_decode( $body_raw, true ); - $usage_raw = isset( $json['usage'] ) ? $json['usage'] : null; + $usage_raw = $this->normalize_usage( is_array( $json ) && isset( $json['usage'] ) ? $json['usage'] : null ); $usage = self::format_usage( $usage_raw ); $cost_usd = $this->cost_usd( $usage_raw ); $cost = $cost_usd === null ? '' : number_format( $cost_usd * 100, 4 ) . 'ยข'; @@ -334,24 +359,36 @@ private function generate_text( $attachment_id, $prompt, $metadata = null ) { 'cost' => $cost, ); } - $content = isset( $json['choices'][0]['message']['content'] ) ? $json['choices'][0]['message']['content'] : null; - $finish_reason = isset( $json['choices'][0]['finish_reason'] ) ? $json['choices'][0]['finish_reason'] : ''; - - // Content can be a string or an array of content parts (e.g. [ { "type": "text", "text": "..." } ] ). - $text = ''; - if ( is_string( $content ) ) { - $text = $content; - } elseif ( is_array( $content ) ) { - foreach ( $content as $part ) { - if ( isset( $part['type'] ) && $part['type'] === 'text' && isset( $part['text'] ) ) { - $text .= $part['text']; + $text = ''; + $truncated = false; + if ( $is_interactions ) { + // { status, steps: [ { type: "model_output", content: [ { type: "text", text } ] } ] } + $status = isset( $json['status'] ) ? $json['status'] : ''; + if ( $status === 'failed' || $status === 'cancelled' ) { + return (object) array( + 'text' => null, + 'error' => $this->provider_def['label'] . ' request ' . $status . '.', + 'error_detail' => $body_raw, + 'usage' => $usage, + 'cost' => $cost, + ); + } + $truncated = $status === 'incomplete'; + foreach ( isset( $json['steps'] ) && is_array( $json['steps'] ) ? $json['steps'] : array() as $step ) { + if ( isset( $step['type'] ) && $step['type'] === 'model_output' && ! empty( $step['content'] ) ) { + $text .= self::text_from_parts( $step['content'] ); } } + } else { + $content = isset( $json['choices'][0]['message']['content'] ) ? $json['choices'][0]['message']['content'] : null; + $truncated = isset( $json['choices'][0]['finish_reason'] ) && $json['choices'][0]['finish_reason'] === 'length'; + // Content can be a string or an array of content parts (e.g. [ { "type": "text", "text": "..." } ] ). + $text = is_string( $content ) ? $content : self::text_from_parts( $content ); } $text = trim( $text ); - if ( $finish_reason === 'length' ) { + if ( $truncated ) { $length_message = __( 'Response was cut off (max tokens reached). Increase Max completion tokens in Settings to resolve this.', 'everyalt' ); return (object) array( 'text' => null, @@ -375,6 +412,79 @@ private function generate_text( $attachment_id, $prompt, $metadata = null ) { return (object) array( 'text' => $text, 'error' => null, 'error_detail' => null, 'usage' => $usage, 'cost' => $cost ); } + /** + * Whether this provider uses the Gemini Interactions API (else OpenAI-style Chat Completions). + * + * @return bool + */ + private function is_interactions_api() { + return isset( $this->provider_def['api'] ) && $this->provider_def['api'] === 'interactions'; + } + + /** + * Map a provider's usage object onto Chat Completions field names (prompt_tokens, completion_tokens, + * total_tokens, plus reasoning_tokens when reported separately). + * + * @param array|null $usage + * @return array|null + */ + private function normalize_usage( $usage ) { + if ( ! is_array( $usage ) ) { + return null; + } + if ( ! $this->is_interactions_api() ) { + return $usage; + } + $out = array(); + foreach ( array( + 'prompt_tokens' => 'total_input_tokens', + 'completion_tokens' => 'total_output_tokens', + 'reasoning_tokens' => 'total_thought_tokens', + 'total_tokens' => 'total_tokens', + ) as $to => $from ) { + if ( isset( $usage[ $from ] ) ) { + $out[ $to ] = (int) $usage[ $from ]; + } + } + return $out; + } + + /** + * Output tokens the provider bills, including thinking/reasoning tokens. + * + * OpenAI counts reasoning inside completion_tokens; Gemini reports thinking separately and leaves it + * out of the output count. Anything in the total beyond the prompt is billed as output either way, + * so take the larger of the two views. + * + * @param array $usage Normalized usage. + * @return int + */ + private static function billable_output_tokens( $usage ) { + $p = isset( $usage['prompt_tokens'] ) ? (int) $usage['prompt_tokens'] : 0; + $c = isset( $usage['completion_tokens'] ) ? (int) $usage['completion_tokens'] : 0; + $r = isset( $usage['reasoning_tokens'] ) ? (int) $usage['reasoning_tokens'] : 0; + if ( isset( $usage['total_tokens'] ) ) { + return max( $c, (int) $usage['total_tokens'] - $p ); + } + return $c + $r; + } + + /** + * Concatenate the text parts of a content array ([ { type: "text", text } ]). + * + * @param mixed $parts + * @return string + */ + private static function text_from_parts( $parts ) { + $text = ''; + foreach ( is_array( $parts ) ? $parts : array() as $part ) { + if ( isset( $part['type'], $part['text'] ) && $part['type'] === 'text' ) { + $text .= $part['text']; + } + } + return $text; + } + /** * Format usage array from API response for display. * @@ -398,6 +508,10 @@ private static function format_usage( $usage ) { if ( $c !== null ) { $parts[] = 'Output Tokens: ' . $c; } + $thinking = self::billable_output_tokens( $usage ) - (int) $c; + if ( $c !== null && $thinking > 0 ) { + $parts[] = 'Thinking Tokens: ' . $thinking; + } if ( $t !== null ) { $parts[] = 'Total: ' . $t; } @@ -415,7 +529,7 @@ private function cost_usd( $usage ) { return null; } $p = isset( $usage['prompt_tokens'] ) ? (int) $usage['prompt_tokens'] : 0; - $c = isset( $usage['completion_tokens'] ) ? (int) $usage['completion_tokens'] : 0; + $c = self::billable_output_tokens( $usage ); if ( $p === 0 && $c === 0 ) { return null; } diff --git a/includes/class-everyalt-providers.php b/includes/class-everyalt-providers.php index 8d55126..c5c1b52 100644 --- a/includes/class-everyalt-providers.php +++ b/includes/class-everyalt-providers.php @@ -2,8 +2,10 @@ /** * Registry of supported AI providers and models. * - * Every provider exposes an OpenAI-compatible Chat Completions endpoint that accepts a base64 image - * (data URL), so one request builder (Every_Alt_OpenAI) serves all of them. + * OpenAI and DeepInfra use OpenAI-style Chat Completions ('api' => 'chat'). Gemini uses its native + * Interactions API ('api' => 'interactions'), because only that API lets us request low image resolution + * (280 tokens per image instead of 1,120) and reports thinking tokens separately. Every_Alt_OpenAI builds + * both request styles; every provider receives the image inline as base64. * * Prices are the providers' published regular (non-promotional) rates in USD per 1M tokens, as of September 2026. * They are used for the cost estimates shown in Settings and Logs, and can be overridden with the @@ -34,6 +36,7 @@ public static function providers() { 'openai' => array( 'label' => 'OpenAI', 'key_option' => 'every_alt_openai_key', + 'api' => 'chat', 'endpoint' => 'https://api.openai.com/v1/chat/completions', 'models_url' => 'https://api.openai.com/v1/models', 'token_param' => 'max_completion_tokens', @@ -47,9 +50,12 @@ public static function providers() { 'gemini' => array( 'label' => 'Google Gemini', 'key_option' => 'every_alt_gemini_key', - 'endpoint' => 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions', + 'api' => 'interactions', + 'endpoint' => 'https://generativelanguage.googleapis.com/v1beta/interactions', + // Key validation uses the OpenAI-compatible model list, which accepts a Bearer key. 'models_url' => 'https://generativelanguage.googleapis.com/v1beta/openai/models', - 'token_param' => 'max_completion_tokens', + // 280 tokens per image instead of the default 1,120. Plenty of detail for alt text. + 'image_resolution' => 'low', 'key_url' => 'https://aistudio.google.com/apikey', 'pricing_url' => 'https://ai.google.dev/gemini-api/docs/pricing', 'privacy_urls' => array( @@ -59,6 +65,7 @@ public static function providers() { 'deepinfra' => array( 'label' => 'DeepInfra', 'key_option' => 'every_alt_deepinfra_key', + 'api' => 'chat', 'endpoint' => 'https://api.deepinfra.com/v1/openai/chat/completions', 'models_url' => 'https://api.deepinfra.com/v1/openai/models', 'token_param' => 'max_tokens', @@ -99,8 +106,9 @@ public static function models() { 'label' => 'Gemini 3.1 Flash-Lite', 'input_price' => 0.25, 'output_price' => 1.50, - // Gemini 3 thinking cannot be turned off; "low" is the cheapest supported level. - 'params' => array( 'reasoning_effort' => 'low' ), + // Thinking can't be turned off on Gemini 3. Flash-Lite models default to the lowest level + // (minimal), which is cheaper than any level we could request, so none is set here. + 'params' => array(), ), 'deepinfra-deepseek-v4.1-flash' => array( 'provider' => 'deepinfra',