'Write concise alt text (max 125 chars) for this image context: {context}. Output only the alt text.', 'link_text' => "Suggest a more descriptive link text (max 8 words) to replace the generic text \"{current}\". Output only the replacement text, no quotes, no explanation.\n\nSurrounding content: {context}\nLink destination: {url}", 'fix' => "Suggest how to fix this WCAG {wcag} accessibility violation. Be concise and developer-focused.\n\n1. Explain what needs to change in 1–2 sentences.\n2. Show the corrected code snippet (if applicable).\n\nIssue: {title}\nCurrent HTML:\n{html}", 'explain' => "Explain this accessibility issue to a non-technical WordPress site owner in 2 sentences.\n\nIssue: {title}\nWCAG: {wcag}", 'colour' => 'Suggest the nearest colour to #{colour} that passes WCAG 1.4.3 contrast against #{background}. Return JSON: {"suggested":"#hexcode","ratio":"X.X:1"}', 'report' => 'Write a 3-sentence plain-English compliance summary for a WordPress site with score {score}/100, {critical} critical issues, {warnings} warnings.', ]; /** * Test a provider connection using explicit credentials (no saved settings required). * Falls back to the saved encrypted key when $key is empty (key already saved, no change). */ public static function test( string $provider, string $key, string $model ): array { if ( ! AccessMate_Providers::is_valid( $provider ) ) { return [ 'error' => __( 'No AI provider configured.', 'accessmate' ) ]; } if ( empty( $key ) ) { $settings = get_option( 'accessmate_settings', [] ); $key = self::decrypt_key( $settings['ai_key'] ?? '' ); } $def = AccessMate_Providers::get( $provider ); $model = $model ?: $def['default']; $prompt = 'Reply with exactly one word: OK.'; return self::send( $def, $key, $model, $prompt ); } /** * Entry point. Reads provider config from wp_options, builds prompt, calls provider. */ public static function call( string $task, array $params ): array { if ( ! in_array( $task, self::VALID_TASKS, true ) ) { return [ 'error' => __( 'Invalid task.', 'accessmate' ) ]; } if ( in_array( $task, self::PREMIUM_TASKS, true ) && ! AccessMate_Licensing::is_premium() ) { return [ 'error' => __( 'This task is not available.', 'accessmate' ) ]; } $settings = get_option( 'accessmate_settings', [] ); $provider = $settings['ai_provider'] ?? 'none'; if ( $provider === 'none' || ! AccessMate_Providers::is_valid( $provider ) ) { return [ 'error' => __( 'No AI provider configured.', 'accessmate' ) ]; } $key = self::decrypt_key( $settings['ai_key'] ?? '' ); $def = AccessMate_Providers::get( $provider ); $model = $settings['ai_model'] ?? $def['default']; // alt_text requests can include the attachment being described, so the // model actually sees the image instead of just guessing from its // filename/title/caption — which, correctly, a capable model will // refuse to do rather than invent a description for. $image = null; if ( 'alt_text' === $task && ! empty( $params['attachment_id'] ) ) { $image = self::resolve_image_for_vision( (int) $params['attachment_id'] ); } unset( $params['attachment_id'] ); // never interpolated into the prompt text $prompt = self::build_prompt( $task, $params ); return self::send( $def, $key, $model, $prompt, $image ); } /** * Reads a local, already-uploaded attachment's file for a vision request. * Returns null (falling back to text-only) if the ID isn't a real * attachment, the file is missing/oversized, or its format (e.g. * HEIC/AVIF) isn't one vision APIs reliably accept — never fetches an * arbitrary URL, so there's no SSRF surface here. */ private static function resolve_image_for_vision( int $attachment_id ): ?array { if ( $attachment_id <= 0 || 'attachment' !== get_post_type( $attachment_id ) ) { return null; } // Prefer a mid-sized rendition over the original — faster to send and // still plenty of detail for subject/colour/setting, and avoids a // multi-megabyte original blowing past MAX_VISION_BYTES. $path = null; $meta = wp_get_attachment_metadata( $attachment_id ); $dir = dirname( (string) get_attached_file( $attachment_id ) ); foreach ( [ 'medium', 'medium_large' ] as $size ) { if ( ! empty( $meta['sizes'][ $size ]['file'] ) ) { $candidate = $dir . '/' . $meta['sizes'][ $size ]['file']; if ( file_exists( $candidate ) ) { $path = $candidate; break; } } } if ( ! $path ) { $path = get_attached_file( $attachment_id ); } if ( ! $path || ! file_exists( $path ) ) { return null; } $filesize = filesize( $path ); if ( ! $filesize || $filesize > self::MAX_VISION_BYTES ) { return null; } $mime = wp_check_filetype( $path )['type'] ?? ''; if ( ! in_array( $mime, self::VISION_MIME_TYPES, true ) ) { return null; } $bytes = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_read_file_get_contents if ( false === $bytes ) { return null; } return [ 'mime' => $mime, 'data' => base64_encode( $bytes ) ]; } // ── Encryption ────────────────────────────────────────────────────────── public static function encrypt_key( string $key ): string { $secret = hash( 'sha256', wp_salt( 'auth' ), true ); $iv = openssl_random_pseudo_bytes( 16 ); $encrypted = openssl_encrypt( $key, 'AES-256-CBC', $secret, 0, $iv ); return base64_encode( $iv . $encrypted ); } public static function decrypt_key( string $stored ): string { if ( empty( $stored ) ) return ''; $secret = hash( 'sha256', wp_salt( 'auth' ), true ); $decoded = base64_decode( $stored ); $iv = substr( $decoded, 0, 16 ); $encrypted = substr( $decoded, 16 ); $result = openssl_decrypt( $encrypted, 'AES-256-CBC', $secret, 0, $iv ); return $result !== false ? $result : ''; } // ── Prompt builder ────────────────────────────────────────────────────── private static function build_prompt( string $task, array $params ): string { $template = self::PROMPTS[ $task ] ?? '{context}'; foreach ( $params as $k => $v ) { // Sanitise param values — they go into a prompt, not HTML output. $template = str_replace( '{' . $k . '}', (string) $v, $template ); } return $template; } // ── HTTP dispatch ─────────────────────────────────────────────────────── private static function send( array $def, string $key, string $model, string $prompt, ?array $image = null ): array { $format = $def['format']; $url = $def['endpoint']; switch ( $format ) { case 'anthropic': $content = $image ? [ [ 'type' => 'image', 'source' => [ 'type' => 'base64', 'media_type' => $image['mime'], 'data' => $image['data'] ] ], [ 'type' => 'text', 'text' => $prompt ], ] : $prompt; $body = wp_json_encode([ 'model' => $model, 'max_tokens' => 512, 'messages' => [[ 'role' => 'user', 'content' => $content ]], ]); $headers = [ 'Content-Type' => 'application/json', 'x-api-key' => $key, 'anthropic-version' => '2023-06-01', ]; break; case 'openai': $content = $image ? [ [ 'type' => 'text', 'text' => $prompt ], [ 'type' => 'image_url', 'image_url' => [ 'url' => 'data:' . $image['mime'] . ';base64,' . $image['data'] ] ], ] : $prompt; $body = wp_json_encode([ 'model' => $model, 'messages' => [[ 'role' => 'user', 'content' => $content ]], ]); $headers = [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $key, ]; break; case 'gemini': $url = str_replace( '{model}', rawurlencode( $model ), $url ) . '?key=' . rawurlencode( $key ); $parts = $image ? [ [ 'text' => $prompt ], [ 'inline_data' => [ 'mime_type' => $image['mime'], 'data' => $image['data'] ] ] ] : [ [ 'text' => $prompt ] ]; $body = wp_json_encode([ 'contents' => [[ 'parts' => $parts ]], ]); $headers = [ 'Content-Type' => 'application/json' ]; break; default: // ollama — none of our configured local models reliably support vision, so always text-only $body = wp_json_encode([ 'model' => $model, 'messages' => [[ 'role' => 'user', 'content' => $prompt ]], 'stream' => false, ]); $headers = [ 'Content-Type' => 'application/json' ]; break; } $response = wp_remote_post( $url, [ 'headers' => $headers, 'body' => $body, 'timeout' => 30, ]); if ( is_wp_error( $response ) ) { return [ 'error' => __( "Couldn't reach the AI provider — check your site's internet connection and try again.", 'accessmate' ) ]; } $status = wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $status !== 200 ) { $raw = $data['error']['message'] ?? ( 'HTTP ' . $status ); if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { error_log( 'AccessMate AI proxy (' . $format . ', HTTP ' . $status . '): ' . $raw ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } return [ 'error' => self::friendly_error( $status, $raw ) ]; } return [ 'text' => self::extract_text( $format, $data ) ]; } /** * Translate a raw provider error (HTTP status + message) into something a * WordPress admin can act on, instead of leaking provider-internal wording * ("models/gemini-1.5-flash is not found for API version v1beta..."). * The raw message is logged (see above) for debugging. */ private static function friendly_error( int $status, string $raw ): string { if ( $status === 401 || $status === 403 ) { return __( 'AI request failed: your API key was rejected. Check the key in Settings.', 'accessmate' ); } if ( $status === 429 ) { return __( 'AI request failed: rate limit reached. Wait a moment and try again.', 'accessmate' ); } if ( $status === 404 || stripos( $raw, 'not found' ) !== false || stripos( $raw, 'not supported' ) !== false ) { return __( 'AI request failed: the selected model is unavailable. Pick a different model in Settings.', 'accessmate' ); } if ( $status >= 500 ) { return __( 'AI request failed: the AI provider is having issues right now. Try again shortly.', 'accessmate' ); } return __( 'AI request failed. Check your AI provider settings and try again.', 'accessmate' ); } private static function extract_text( string $format, array $data ): string { switch ( $format ) { case 'anthropic': return $data['content'][0]['text'] ?? ''; case 'openai': return $data['choices'][0]['message']['content'] ?? ''; case 'gemini': return $data['candidates'][0]['content']['parts'][0]['text'] ?? ''; default: return $data['message']['content'] ?? ''; } } }