HEX
Server: Apache/2.4.65 (Debian)
System: Linux 88f31f35b0b8 6.1.0-38-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.147-1 (2025-08-02) x86_64
User: www-data (33)
PHP: 8.2.29
Disabled: NONE
Upload Files
File: /var/www/html/wp-content/plugins/ai-engine/classes/engines/open-router.php
<?php

// If this isn't defined elsewhere, set it here by default. You can override
// it in your theme's functions.php or your main wp-config.php. If set to true,
// additional time will be spent fetching exact pricing info from OpenRouter
// after each query, resulting in more accurate but potentially slower responses.
if ( !defined( 'MWAI_OPENROUTER_ACCURATE_PRICING' ) ) {
  define( 'MWAI_OPENROUTER_ACCURATE_PRICING', false );
}

class Meow_MWAI_Engines_OpenRouter extends Meow_MWAI_Engines_ChatML {
  /**
  * Keep a static dictionary (query -> price) so that if we see the same query
  * again in another instance, we can immediately return the stored price
  * instead of recomputing.
  * @var array
  */
  private static $accuratePrices = [];

  public function __construct( $core, $env ) {
    parent::__construct( $core, $env );
  }

  protected function set_environment() {
    $env = $this->env;
    $this->apiKey = $env['apikey'];
  }

  protected function build_url( $query, $endpoint = null ) {
    $endpoint = apply_filters( 'mwai_openrouter_endpoint', 'https://openrouter.ai/api/v1', $this->env );
    return parent::build_url( $query, $endpoint );
  }

  protected function build_headers( $query ) {
    $site_url = apply_filters( 'mwai_openrouter_site_url', get_site_url(), $query );
    $site_name = apply_filters( 'mwai_openrouter_site_name', get_bloginfo( 'name' ), $query );
    if ( $query->apiKey ) {
      $this->apiKey = $query->apiKey;
    }
    if ( empty( $this->apiKey ) ) {
      throw new Exception( 'No API Key provided. Please visit the Settings. (OpenRouter Engine)' );
    }
    return [
      'Content-Type' => 'application/json',
      'Authorization' => 'Bearer ' . $this->apiKey,
      'HTTP-Referer' => $site_url,
      'X-Title' => $site_name,
      'User-Agent' => 'AI Engine',
    ];
  }

  protected function build_body( $query, $streamCallback = null, $extra = null ) {
    $body = parent::build_body( $query, $streamCallback, $extra );
    // Only add transforms and usage for chat completions, not embeddings
    if ( !( $query instanceof Meow_MWAI_Query_Embed ) ) {
      $body['transforms'] = ['middle-out'];
      $body['usage'] = [ 'include' => true ];
    }
    else {
      // Only OpenAI embedding models support the dimensions parameter
      // Remove it for other providers to avoid errors
      $model = $query->model ?? '';
      if ( isset( $body['dimensions'] ) && strpos( $model, 'openai/' ) !== 0 ) {
        unset( $body['dimensions'] );
      }
    }
    return $body;
  }

  protected function get_service_name() {
    return 'OpenRouter';
  }

  public function get_models() {
    return $this->core->get_engine_models( 'openrouter' );
  }

  /**
  * Requests usage data if streaming was used and the usage is incomplete.
  */
  public function handle_tokens_usage(
    $reply,
    $query,
    $returned_model,
    $returned_in_tokens,
    $returned_out_tokens,
    $returned_price = null
  ) {
    // If streaming is not enabled, we might already have all usage data
    $everything_is_set = !is_null( $returned_model )
      && !is_null( $returned_in_tokens )
        && !is_null( $returned_out_tokens );

    // Clean up the data
    $returned_in_tokens = $returned_in_tokens ?? $reply->get_in_tokens( $query );
    $returned_out_tokens = $returned_out_tokens ?? $reply->get_out_tokens();
    $returned_price = $returned_price ?? $reply->get_price();

    // Record the usage in the database
    $usage = $this->core->record_tokens_usage(
      $returned_model,
      $returned_in_tokens,
      $returned_out_tokens,
      $returned_price
    );

    // Set the usage back on the reply
    $reply->set_usage( $usage );

    // Set accuracy based on data availability
    if ( !is_null( $returned_price ) && !is_null( $returned_in_tokens ) && !is_null( $returned_out_tokens ) ) {
      // OpenRouter returns price from API = full accuracy
      $reply->set_usage_accuracy( 'full' );
    }
    elseif ( !is_null( $returned_in_tokens ) && !is_null( $returned_out_tokens ) ) {
      // Tokens from API but price calculated = tokens accuracy
      $reply->set_usage_accuracy( 'tokens' );
    }
    else {
      // Everything estimated
      $reply->set_usage_accuracy( 'estimated' );
    }
  }

  public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
    $price = $reply->get_price();
    return is_null( $price ) ? parent::get_price( $query, $reply ) : $price;
  }

  /**
   * OpenRouter uses /chat/completions with modalities parameter for image generation,
   * not the standard /images/generations endpoint.
   */
  public function run_image_query( $query, $streamCallback = null ) {
    $body = [
      'model' => $query->model,
      'messages' => [
        [
          'role' => 'user',
          'content' => $query->get_message()
        ]
      ],
      'modalities' => [ 'text', 'image' ],
    ];

    // Add number of images if specified
    if ( !empty( $query->maxResults ) && $query->maxResults > 1 ) {
      $body['n'] = $query->maxResults;
    }

    // Add image config for Gemini models (aspect ratio support)
    if ( !empty( $query->resolution ) && strpos( $query->model, 'google/' ) === 0 ) {
      $body['image_config'] = [
        'aspect_ratio' => $query->resolution
      ];
    }

    $endpoint = apply_filters( 'mwai_openrouter_endpoint', 'https://openrouter.ai/api/v1', $this->env );
    $url = trailingslashit( $endpoint ) . 'chat/completions';
    $headers = $this->build_headers( $query );
    $options = $this->build_options( $headers, $body );

    try {
      $res = $this->run_query( $url, $options );
      $data = $res['data'];

      if ( empty( $data ) || !isset( $data['choices'] ) ) {
        throw new Exception( 'No image generated in response.' );
      }

      $reply = new Meow_MWAI_Reply( $query );
      $reply->set_type( 'images' );
      $images = [];

      // Extract images from the response
      foreach ( $data['choices'] as $choice ) {
        $message = $choice['message'] ?? [];

        // Check for images in the message (OpenRouter format)
        // Each image is: { "type": "image_url", "image_url": { "url": "data:image/png;base64,..." } }
        if ( isset( $message['images'] ) && is_array( $message['images'] ) ) {
          foreach ( $message['images'] as $image ) {
            if ( is_array( $image ) && isset( $image['image_url']['url'] ) ) {
              $images[] = [ 'url' => $image['image_url']['url'] ];
            }
            elseif ( is_array( $image ) && isset( $image['image_url'] ) && is_string( $image['image_url'] ) ) {
              $images[] = [ 'url' => $image['image_url'] ];
            }
            elseif ( is_string( $image ) ) {
              // Direct base64 string
              $images[] = [ 'url' => $image ];
            }
          }
        }

        // Also check content array for image parts
        if ( isset( $message['content'] ) && is_array( $message['content'] ) ) {
          foreach ( $message['content'] as $part ) {
            if ( isset( $part['type'] ) && $part['type'] === 'image_url' ) {
              if ( isset( $part['image_url']['url'] ) ) {
                $images[] = [ 'url' => $part['image_url']['url'] ];
              }
              elseif ( is_string( $part['image_url'] ) ) {
                $images[] = [ 'url' => $part['image_url'] ];
              }
            }
          }
        }
      }

      if ( empty( $images ) ) {
        throw new Exception( 'No images found in the response.' );
      }

      // Record usage
      $model = $query->model;
      $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';

      if ( isset( $data['usage'] ) ) {
        $usage = $data['usage'];
        $promptTokens = $usage['prompt_tokens'] ?? 0;
        $completionTokens = $usage['completion_tokens'] ?? 0;
        $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
        $usage['queries'] = 1;
        $usage['accuracy'] = 'tokens';
        $reply->set_usage( $usage );
        $reply->set_usage_accuracy( 'tokens' );
      }
      else {
        $usage = $this->core->record_images_usage( $model, $resolution, count( $images ) );
        $reply->set_usage( $usage );
        $reply->set_usage_accuracy( 'estimated' );
      }

      $reply->set_choices( $images );

      // Handle local download if enabled
      if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
        foreach ( $reply->results as &$result ) {
          $fileId = $this->core->files->upload_file( $result, null, 'generated', [
            'query_envId' => $query->envId,
            'query_session' => $query->session,
            'query_model' => $query->model,
          ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
          $fileUrl = $this->core->files->get_url( $fileId );
          $result = $fileUrl;
        }
      }

      $reply->result = $reply->results[0];
      return $reply;
    }
    catch ( Exception $e ) {
      Meow_MWAI_Logging::error( 'OpenRouter: ' . $e->getMessage() );
      throw new Exception( 'OpenRouter: ' . $e->getMessage() );
    }
  }

  /**
  * Retrieve the models from OpenRouter, adding tags/features accordingly.
  */
  public function retrieve_models() {

    // 1. Get the list of models supporting "tools"
    $toolsModels = $this->get_supported_models( 'tools' );

    // 2. Retrieve the full list of chat models
    $url = 'https://openrouter.ai/api/v1/models';
    $response = wp_remote_get( $url );
    if ( is_wp_error( $response ) ) {
      throw new Exception( 'AI Engine: ' . $response->get_error_message() );
    }
    $body = json_decode( $response['body'], true );
    if ( !isset( $body['data'] ) || !is_array( $body['data'] ) ) {
      throw new Exception( 'AI Engine: Invalid response for the list of models.' );
    }

    $models = [];
    foreach ( $body['data'] as $model ) {
      $models[] = $this->build_model_entry( $model, $toolsModels );
    }

    // 3. Retrieve embedding models
    $embeddingsUrl = 'https://openrouter.ai/api/v1/embeddings/models';
    $embeddingsResponse = wp_remote_get( $embeddingsUrl );
    if ( !is_wp_error( $embeddingsResponse ) ) {
      $embeddingsBody = json_decode( $embeddingsResponse['body'], true );
      if ( isset( $embeddingsBody['data'] ) && is_array( $embeddingsBody['data'] ) ) {
        foreach ( $embeddingsBody['data'] as $model ) {
          $models[] = $this->build_model_entry( $model, [], true );
        }
      }
    }

    return $models;
  }

  /**
  * Build a model entry from OpenRouter API data.
  */
  private function build_model_entry( $model, $toolsModels = [], $isEmbedding = false ) {
    // Basic defaults
    $family = 'n/a';
    $maxCompletionTokens = 4096;
    $maxContextualTokens = 8096;
    $priceIn = 0;
    $priceOut = 0;

    // Family from model ID (e.g. "openai/gpt-4/32k" -> "openai")
    if ( isset( $model['id'] ) ) {
      $parts = explode( '/', $model['id'] );
      $family = $parts[0] ?? 'n/a';
    }

    // maxCompletionTokens
    if ( isset( $model['top_provider']['max_completion_tokens'] ) ) {
      $maxCompletionTokens = (int) $model['top_provider']['max_completion_tokens'];
    }

    // maxContextualTokens
    if ( isset( $model['context_length'] ) ) {
      $maxContextualTokens = (int) $model['context_length'];
    }

    // Pricing
    if ( isset( $model['pricing']['prompt'] ) && $model['pricing']['prompt'] > 0 ) {
      $priceIn = $this->truncate_float( floatval( $model['pricing']['prompt'] ) * 1000 );
    }
    if ( isset( $model['pricing']['completion'] ) && $model['pricing']['completion'] > 0 ) {
      $priceOut = $this->truncate_float( floatval( $model['pricing']['completion'] ) * 1000 );
    }

    // Handle embedding models
    if ( $isEmbedding ) {
      $features = [ 'embeddings' ];
      $tags = [ 'core', 'embedding' ];

      // Try to extract dimensions from description
      $dimensions = null;
      if ( isset( $model['description'] ) && preg_match( '/(\d+)-dimensional/', $model['description'], $matches ) ) {
        $dimensions = (int) $matches[1];
      }

      $entry = [
        'model' => $model['id'] ?? '',
        'name' => trim( $model['name'] ?? '' ),
        'family' => $family,
        'features' => $features,
        'price' => [
          'in' => $priceIn,
          'out' => $priceOut,
        ],
        'type' => 'token',
        'unit' => 1 / 1000,
        'maxContextualTokens' => $maxContextualTokens,
        'tags' => $tags,
      ];

      if ( $dimensions ) {
        $entry['dimensions'] = $dimensions;
      }

      return $entry;
    }

    // Basic features and tags for chat models
    $features = [ 'completion' ];
    $tags = [ 'core', 'chat' ];

    // If the name contains (beta), (alpha) or (preview), add 'preview' tag and remove from name
    if ( preg_match( '/\((beta|alpha|preview)\)/i', $model['name'] ) ) {
      $tags[] = 'preview';
      $model['name'] = preg_replace( '/\((beta|alpha|preview)\)/i', '', $model['name'] );
    }

    // If model supports tools
    if ( in_array( $model['id'], $toolsModels, true ) ) {
      $tags[] = 'functions';
      $features[] = 'functions';
    }

    // Check if the model supports "vision" (if "image" is in the left side of the arrow)
    // e.g. "text+image->text" or "image->text"
    $modality = $model['architecture']['modality'] ?? '';
    $modality_lc = strtolower( $modality );
    if (
      strpos( $modality_lc, 'image->' ) !== false ||
        strpos( $modality_lc, 'image+' ) !== false ||
          strpos( $modality_lc, '+image->' ) !== false
    ) {
      // Means it can handle images as input, so we consider that "vision"
      $tags[] = 'vision';
    }

    // Check if the model supports image generation (if "image" is in the output part after "->")
    // e.g. "text->image" or "text+image->text+image" means it can generate images
    $isImageGeneration = false;
    if ( strpos( $modality_lc, '->' ) !== false ) {
      $parts = explode( '->', $modality_lc );
      $outputPart = $parts[1] ?? '';
      $isImageGeneration = strpos( $outputPart, 'image' ) !== false;
    }
    if ( $isImageGeneration ) {
      $features = [ 'text-to-image' ];
      $tags = [ 'core', 'image' ];
    }

    $entry = [
      'model' => $model['id'] ?? '',
      'name' => trim( $model['name'] ?? '' ),
      'family' => $family,
      'features' => $features,
      'price' => [
        'in' => $priceIn,
        'out' => $priceOut,
      ],
      'type' => 'token',
      'unit' => 1 / 1000,
      'maxCompletionTokens' => $maxCompletionTokens,
      'maxContextualTokens' => $maxContextualTokens,
      'tags' => $tags,
    ];

    // Add mode for image generation models
    if ( $isImageGeneration ) {
      $entry['mode'] = 'image';
    }

    return $entry;
  }

  /**
  * Return an array of model IDs that support a certain feature (e.g. "tools").
  */
  private function get_supported_models( $feature ) {
    // Make a request to get models supporting that feature
    $url = 'https://openrouter.ai/api/v1/models?supported_parameters=' . urlencode( $feature );
    $response = wp_remote_get( $url );
    if ( is_wp_error( $response ) ) {
      Meow_MWAI_Logging::error( "OpenRouter: Failed to retrieve models for '$feature': " . $response->get_error_message() );
      return [];
    }
    $body = json_decode( $response['body'], true );
    if ( !isset( $body['data'] ) || !is_array( $body['data'] ) ) {
      Meow_MWAI_Logging::error( "OpenRouter: Invalid response for '$feature' models." );
      return [];
    }

    $modelIDs = [];
    foreach ( $body['data'] as $m ) {
      if ( isset( $m['id'] ) ) {
        $modelIDs[] = $m['id'];
      }
    }

    return $modelIDs;
  }

  /**
  * Utility function to truncate a float to a specific precision.
  */
  private function truncate_float( $number, $precision = 4 ) {
    $factor = pow( 10, $precision );
    return floor( $number * $factor ) / $factor;
  }

  /**
   * Check the connection to OpenRouter by listing models.
   * Uses the existing retrieve_models method for consistency.
   */
  public function connection_check() {
    try {
      // Use the existing retrieve_models method
      $models = $this->retrieve_models();

      if ( !is_array( $models ) ) {
        throw new Exception( 'Invalid response format from OpenRouter' );
      }

      $modelCount = count( $models );
      $availableModels = [];

      // Get first 5 models for display
      $displayModels = array_slice( $models, 0, 5 );
      foreach ( $displayModels as $model ) {
        if ( isset( $model['model'] ) ) {
          $availableModels[] = $model['model'];
        }
      }

      return [
        'success' => true,
        'service' => 'OpenRouter',
        'message' => "Connection successful. Found {$modelCount} models.",
        'details' => [
          'endpoint' => 'https://openrouter.ai/api/v1/models',
          'model_count' => $modelCount,
          'sample_models' => $availableModels
        ]
      ];
    }
    catch ( Exception $e ) {
      return [
        'success' => false,
        'service' => 'OpenRouter',
        'error' => $e->getMessage(),
        'details' => [
          'endpoint' => 'https://openrouter.ai/api/v1/models'
        ]
      ];
    }
  }
}