'text', 'text' => 'How long is the battery warranty?' ) * array( 'type' => 'image', 'url' => 'https://…', 'mime' => 'image/jpeg', 'data' => '' ) * * `url` is what OpenAI and OpenRouter take. `data` (base64) is what Gemini takes; `url` is kept * beside it so one canonical part can serve both — how the bytes get there is the upload path's * decision (docs/16 muc 5.4), not this class's. * * TWO RULES, and `tools/check-attachments.mjs` keeps them: * * 1. **A string stays a string when there is no media.** Legacy callers, and anything hooked to * `mybot_llm_messages`, keep working untouched, and a text-only request is byte-for-byte what * 1.11.1 sent. `to_openai()` returns a string in that case, exactly as before. * 2. **No part is ever dropped.** A part whose type the wire format cannot express is converted * into text carrying its own JSON rather than vanishing: it is visible in the request, it can * be grepped in a log, and it can never turn into "the visitor sent nothing". * * @package AI House */ defined( 'ABSPATH' ) || exit; /** * Class MYBOT_LLM_Content */ class MYBOT_LLM_Content { /** * Part type: text. * * @var string */ const TEXT = 'text'; /** * Part type: an image. * * @var string */ const IMAGE = 'image'; /** * Normalises content into the canonical list of parts. * * Accepts everything the three entry points can hand over: a legacy string (what every caller * sends today), a list of parts, or a list of plain strings. * * @param string|array $content Raw content. * @return array List of canonical parts. */ public static function parts( $content ) { if ( is_string( $content ) ) { $content = ( '' === $content ) ? array() : array( $content ); } if ( ! is_array( $content ) ) { return array(); } $parts = array(); foreach ( $content as $part ) { if ( is_string( $part ) ) { $parts[] = array( 'type' => self::TEXT, 'text' => $part, ); continue; } if ( ! is_array( $part ) ) { continue; } $type = isset( $part['type'] ) ? (string) $part['type'] : ''; if ( self::IMAGE === $type ) { $parts[] = array( 'type' => self::IMAGE, 'url' => isset( $part['url'] ) ? (string) $part['url'] : '', 'mime' => isset( $part['mime'] ) ? (string) $part['mime'] : '', 'data' => isset( $part['data'] ) ? (string) $part['data'] : '', ); continue; } if ( self::TEXT === $type || isset( $part['text'] ) ) { $parts[] = array( 'type' => self::TEXT, 'text' => isset( $part['text'] ) ? (string) $part['text'] : '', ); continue; } /* * An unknown part keeps its JSON as text (rule 2 above). It is deliberately NOT dropped: * a part that disappears is a part the model never saw, and nothing would say so. */ $parts[] = array( 'type' => self::TEXT, 'text' => (string) wp_json_encode( $part ), ); } return $parts; } /** * The plain text of some content. * * Used where a string is genuinely required: a system instruction, a log line, a database row. * Images contribute nothing here — they are not text — so this never fabricates a description * of a picture it cannot see. * * @param string|array $content Raw content. * @return string */ public static function text( $content ) { $text = array(); foreach ( self::parts( $content ) as $part ) { if ( self::TEXT === $part['type'] && '' !== $part['text'] ) { $text[] = $part['text']; } } return implode( "\n", $text ); } /** * Whether the content carries anything a text-only model could not read. * * This is what a capability gate asks: sending this message to a model that cannot see images * means the image is ignored, and the caller has to decide what to do about that *before* the * request rather than after (docs/16 muc 5.6). * * @param string|array $content Raw content. * @return bool */ public static function has_media( $content ) { foreach ( self::parts( $content ) as $part ) { if ( self::TEXT !== $part['type'] ) { return true; } } return false; } /** * Content in the OpenAI / OpenRouter wire format. * * A string when the content is text only — that is the shape 1.11.1 sent, and keeping it means * this change cannot alter a single text request. A list of parts as soon as there is media. * * @param string|array $content Raw content. * @return string|array */ public static function to_openai( $content ) { $parts = self::parts( $content ); if ( ! self::has_media( $parts ) ) { return self::text( $parts ); } $out = array(); foreach ( $parts as $part ) { if ( self::IMAGE === $part['type'] ) { $out[] = array( 'type' => 'image_url', 'image_url' => array( 'url' => '' !== $part['url'] ? $part['url'] : self::data_uri( $part ) ), ); continue; } $out[] = array( 'type' => 'text', 'text' => $part['text'], ); } return $out; } /** * Content in the Gemini wire format: always a list of `parts`. * * Gemini takes image bytes, not a URL, so a part with base64 `data` becomes `inline_data`. A part * with only a `url` cannot be sent as an http link: Gemini's `file_data` accepts **its own Files * API URIs**, not arbitrary addresses. The upload path (docs/16 muc 5.4) decides whether bytes * are fetched and encoded here, or whether Gemini is simply not offered for images; until that * decision is made, such a part is carried as **text naming the problem**, so it is visible in * the request instead of being dropped. * * @param string|array $content Raw content. * @return array */ public static function to_gemini( $content ) { $out = array(); foreach ( self::parts( $content ) as $part ) { if ( self::IMAGE === $part['type'] ) { if ( '' !== $part['data'] ) { $out[] = array( 'inline_data' => array( 'mime_type' => '' !== $part['mime'] ? $part['mime'] : 'image/jpeg', 'data' => $part['data'], ), ); continue; } $unsent = wp_json_encode( array( 'mybot_unsent_image' => $part['url'], 'reason' => 'no base64 data for this image part', ) ); $out[] = array( 'text' => (string) $unsent ); continue; } $out[] = array( 'text' => $part['text'] ); } return $out; } /** * A data URI for a part that carries bytes but no URL. * * @param array $part Canonical image part. * @return string */ protected static function data_uri( $part ) { if ( '' === $part['data'] ) { return ''; } return 'data:' . ( '' !== $part['mime'] ? $part['mime'] : 'image/jpeg' ) . ';base64,' . $part['data']; } }