Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
196 changes: 155 additions & 41 deletions admin/class-everyalt-openai.php
Original file line number Diff line number Diff line change
Expand Up @@ -252,50 +252,75 @@ 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;
$max_tokens = apply_filters( 'everyalt_max_completion_tokens', $max_tokens );
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'] );

$response = wp_remote_post(
$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 ),
)
);
Expand All @@ -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 ) . '¢';
Expand All @@ -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,
Expand All @@ -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.
*
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down
20 changes: 14 additions & 6 deletions includes/class-everyalt-providers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand All @@ -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(
Expand All @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Loading