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/labs/wpai-gateway-model.php
<?php
/**
 * TextGenerationModel for the AI Engine gateway.
 *
 * Translates AiClient's `array<Message> $prompt` into `Meow_MWAI_Query_Text`,
 * dispatches it through AI Engine's engine for the matching provider, and
 * wraps the reply as a `GenerativeAiResult`.
 */

use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Messages\DTO\Message;
use WordPress\AiClient\Messages\DTO\MessagePart;
use WordPress\AiClient\Messages\DTO\ModelMessage;
use WordPress\AiClient\Messages\Enums\MessageRoleEnum;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface;
use WordPress\AiClient\Results\DTO\Candidate;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;
use WordPress\AiClient\Results\DTO\TokenUsage;
use WordPress\AiClient\Results\Enums\FinishReasonEnum;

class Meow_MWAI_Labs_WPAI_Gateway_Model implements ModelInterface, TextGenerationModelInterface {

  private ModelMetadata $modelMetadata;
  private ProviderMetadata $providerMetadata;
  private ModelConfig $config;

  public function __construct( ModelMetadata $modelMetadata, ProviderMetadata $providerMetadata ) {
    $this->modelMetadata    = $modelMetadata;
    $this->providerMetadata = $providerMetadata;
    $this->config           = new ModelConfig();
  }

  public function metadata(): ModelMetadata         { return $this->modelMetadata; }
  public function providerMetadata(): ProviderMetadata { return $this->providerMetadata; }
  public function setConfig( ModelConfig $config ): void { $this->config = $config; }
  public function getConfig(): ModelConfig          { return $this->config; }

  public function generateTextResult( array $prompt ): GenerativeAiResult {
    // KNOWN LIMITATIONS (to address as we harden this gateway):
    //
    //  1. Phase 4 TODO — validate model-specific constraints before
    //     dispatching. E.g. cap `maxTokens` to the model's real ceiling
    //     (AI Engine stores it on each model), reject `temperature` on
    //     models that forbid it (o1 family), give a clear error up-front
    //     instead of a downstream API rejection.
    //  2. File parts (image, document, audio) are now round-tripped via
    //     `extract_parts` → `Meow_MWAI_Query_DroppedFile` → `$query->add_file`.
    //     Function-call / function-response parts on inbound messages are
    //     still ignored on purpose: AI Engine handles tools through its own
    //     filter pipeline (`mwai_functions_list`, `mwai_ai_feedback`).
    //  3. Streaming is not exposed. AiClient has no streaming hook today,
    //     but if it ever does, we need to bridge to `run_query( $q, $cb )`.
    //  4. Errors from `run_query()` surface as thrown RuntimeException;
    //     partial failures (e.g. a function-call tool erroring mid-loop)
    //     aren't translated into the `GenerativeAiResult` error shape.
    //
    $core = Meow_MWAI_Labs_WPAI_Gateway::$core;
    if ( ! $core ) {
      throw new RuntimeException( 'AI Engine core unavailable.' );
    }

    $model_id = $this->modelMetadata->getId();
    $env      = $this->resolve_env_for_model( $core, $model_id );
    if ( ! $env ) {
      throw new RuntimeException( sprintf(
        'No AI Engine environment is configured for model "%s".',
        $model_id
      ) );
    }

    // Translate AiClient messages → AI Engine role/content pairs, and collect
    // file parts (images, documents, audio) for attachment on the query.
    $messages = [];
    $files    = [];
    foreach ( $prompt as $msg ) {
      if ( ! $msg instanceof Message ) {
        continue;
      }
      $role = $msg->getRole()->isModel() ? 'assistant' : 'user';
      list( $text, $msg_files ) = $this->extract_parts( $msg );
      foreach ( $msg_files as $f ) {
        $files[] = $f;
      }
      if ( $text === '' && empty( $msg_files ) ) {
        continue;
      }
      // AI Engine expects a non-empty content per message; if the message
      // carried only files, leave a placeholder so the message slot exists.
      if ( $text === '' ) {
        $text = '[attachment]';
      }
      $messages[] = [ 'role' => $role, 'content' => $text ];
    }

    // Split off the last user turn as the main "message"; everything before
    // is history.
    $last_user_idx = null;
    for ( $i = count( $messages ) - 1; $i >= 0; $i-- ) {
      if ( $messages[ $i ]['role'] === 'user' ) {
        $last_user_idx = $i;
        break;
      }
    }
    $current = $last_user_idx !== null ? $messages[ $last_user_idx ]['content'] : '';
    $history = $last_user_idx !== null
      ? array_merge( array_slice( $messages, 0, $last_user_idx ), array_slice( $messages, $last_user_idx + 1 ) )
      : $messages;

    $query = new Meow_MWAI_Query_Text( $current );
    // Stamp the call with the framework name so it's identifiable in Insights.
    $query->set_scope( 'wp-ai-client' );
    $query->set_env_id( $env['id'] );
    $query->set_model( $model_id );
    if ( ! empty( $history ) ) {
      $query->set_messages( $history );
    }
    foreach ( $files as $dropped ) {
      $query->add_file( $dropped );
    }
    $system = $this->config->getSystemInstruction();
    if ( $system ) {
      $query->set_instructions( $system );
    }
    $maxTokens = $this->config->getMaxTokens();
    if ( $maxTokens !== null ) {
      $query->set_max_tokens( (int) $maxTokens );
    }
    $temperature = $this->config->getTemperature();
    if ( $temperature !== null ) {
      $query->set_temperature( (float) $temperature );
    }

    $reply = $core->run_query( $query );

    return $this->build_result( $reply );
  }

  // ───────────────────────────────────────────────────────────────────────

  private function resolve_env_for_model( $core, string $model_id ): ?array {
    $options = $core->get_all_options();
    $engines = $options['ai_engines'] ?? [];
    $engine_type_for_model = null;
    foreach ( $engines as $engine ) {
      foreach ( $engine['models'] ?? [] as $m ) {
        if ( ( $m['model'] ?? '' ) === $model_id ) {
          $engine_type_for_model = $engine['type'] ?? null;
          break 2;
        }
      }
    }
    if ( ! $engine_type_for_model ) {
      return null;
    }
    foreach ( $options['ai_envs'] ?? [] as $env ) {
      if ( ( $env['type'] ?? '' ) === $engine_type_for_model ) {
        return $env;
      }
    }
    return null;
  }

  /**
   * Pulls text and file attachments out of an AiClient Message.
   * Function-call / function-response parts are intentionally ignored —
   * AI Engine handles tool-calling through its own filter pipeline
   * (`mwai_functions_list`, `mwai_ai_feedback`), not via inbound messages.
   *
   * @return array{0: string, 1: array<int, Meow_MWAI_Query_DroppedFile>}
   */
  private function extract_parts( Message $msg ): array {
    $text  = '';
    $files = [];
    foreach ( $msg->getParts() as $part ) {
      if ( ! $part instanceof MessagePart ) {
        continue;
      }
      $type = $part->getType();
      if ( $type->isText() ) {
        $text .= (string) $part->getText();
      }
      elseif ( $type->isFile() ) {
        $file = $part->getFile();
        if ( $file === null ) {
          continue;
        }
        $dropped = $this->file_to_dropped( $file );
        if ( $dropped !== null ) {
          $files[] = $dropped;
        }
      }
    }
    return [ $text, $files ];
  }

  /**
   * Converts an AiClient `File` (URL, base64, or data URI) into AI Engine's
   * `Meow_MWAI_Query_DroppedFile` so the underlying engine can attach it to
   * the request (vision payload, document upload, etc.).
   */
  private function file_to_dropped( $file ): ?Meow_MWAI_Query_DroppedFile {
    try {
      $mime = $file->getMimeType();
      $url  = $file->getUrl();
      if ( $url ) {
        return Meow_MWAI_Query_DroppedFile::from_url( $url, 'analysis', $mime );
      }
      $b64 = $file->getBase64Data();
      if ( $b64 ) {
        return Meow_MWAI_Query_DroppedFile::from_data( base64_decode( $b64 ), 'analysis', $mime );
      }
    }
    catch ( \Throwable $e ) {
      // Skip the file rather than failing the whole prompt.
    }
    return null;
  }

  private function build_result( $reply ): GenerativeAiResult {
    $text = (string) ( $reply->result ?? '' );
    $part = new MessagePart( $text );
    $message = new ModelMessage( [ $part ] );
    $candidate = new Candidate( $message, FinishReasonEnum::stop() );

    $usage = $reply->usage ?? [];
    $prompt = (int) ( $usage['prompt_tokens'] ?? 0 );
    $completion = (int) ( $usage['completion_tokens'] ?? 0 );
    $total = (int) ( $usage['total_tokens'] ?? ( $prompt + $completion ) );
    $tokenUsage = new TokenUsage( $prompt, $completion, $total );

    $id = (string) ( $reply->id ?? uniqid( 'mwai-', true ) );
    return new GenerativeAiResult(
      $id,
      [ $candidate ],
      $tokenUsage,
      $this->providerMetadata,
      $this->modelMetadata
    );
  }
}