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/custom.php
<?php

/**
 * Generic OpenAI-compatible engine. Lets users point AI Engine at any server that speaks the
 * OpenAI Chat Completions API (Ollama, LM Studio, vLLM, llama.cpp, LocalAI, TGI in OAI mode,
 * smaller hosted providers, etc.). Endpoint is user-configurable; API key is optional since
 * many local servers run unauthenticated.
 */
class Meow_MWAI_Engines_Custom extends Meow_MWAI_Engines_ChatML {
  public function __construct( $core, $env ) {
    parent::__construct( $core, $env );
  }

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

  protected function get_service_name() {
    return ! empty( $this->env['name'] ) ? $this->env['name'] : 'Custom';
  }

  public function get_models() {
    // Prefer dynamically-fetched models, fall back to whatever the user added manually for this
    // env type via the Custom Models UI. No static list, since the model lineup is whatever the
    // user's server happens to expose.
    return $this->core->get_engine_models( 'custom' );
  }

  public static function get_models_static() {
    return [];
  }

  protected function build_url( $query, $endpoint = null ) {
    $base = $this->resolve_endpoint();
    if ( $query instanceof Meow_MWAI_Query_Text || $query instanceof Meow_MWAI_Query_Feedback ) {
      return $base . '/chat/completions';
    }
    if ( $query instanceof Meow_MWAI_Query_Embed ) {
      return $base . '/embeddings';
    }
    throw new Exception( 'Unsupported query type for the Custom (OpenAI-Compatible) provider.' );
  }

  /**
   * Bearer auth is optional here. Most local servers (Ollama default install, LM Studio,
   * llama.cpp server) do not require an API key; hosted OpenAI-compatible endpoints typically
   * do. Only attach the Authorization header when the user has set a key.
   */
  protected function build_headers( $query ) {
    if ( $query->apiKey ) {
      $this->apiKey = $query->apiKey;
    }
    $headers = [
      'Content-Type' => 'application/json',
      'User-Agent' => 'AI Engine',
    ];
    if ( ! empty( $this->apiKey ) ) {
      $headers['Authorization'] = 'Bearer ' . $this->apiKey;
    }
    return $headers;
  }

  protected function build_body( $query, $streamCallback = null, $extra = null ) {
    $body = parent::build_body( $query, $streamCallback, $extra );

    // Most OAI-compatible servers expect the older max_tokens field, not max_completion_tokens.
    if ( isset( $body['max_completion_tokens'] ) ) {
      $body['max_tokens'] = $body['max_completion_tokens'];
      unset( $body['max_completion_tokens'] );
    }

    return $body;
  }

  /**
   * Resolve the endpoint URL. Defaults to the Ollama localhost URL since that is the most
   * common starting point; users override per-env in settings.
   */
  private function resolve_endpoint() {
    $endpoint = ! empty( $this->env['endpoint'] ) ? $this->env['endpoint'] : 'http://localhost:11434/v1';
    $endpoint = apply_filters( 'mwai_custom_endpoint', $endpoint, $this->env );
    return rtrim( $endpoint, '/' );
  }

  /**
   * Try to discover models from /v1/models. Servers that don't implement this endpoint
   * (some llama.cpp builds, custom proxies) just return an empty list — users add models
   * manually through AI Engine's existing custom-models UI.
   */
  public function retrieve_models() {
    $base = $this->resolve_endpoint();
    $url = $base . '/models';

    $headers = [ 'User-Agent' => 'AI Engine' ];
    if ( ! empty( $this->apiKey ) ) {
      $headers['Authorization'] = 'Bearer ' . $this->apiKey;
    }

    $response = wp_remote_get( $url, [
      'headers' => $headers,
      'timeout' => 10,
      'sslverify' => MWAI_SSL_VERIFY,
    ] );

    if ( is_wp_error( $response ) ) {
      Meow_MWAI_Logging::log( 'Custom (OpenAI-Compatible): /models fetch failed: ' . $response->get_error_message() );
      return [];
    }

    $code = wp_remote_retrieve_response_code( $response );
    if ( $code >= 400 ) {
      Meow_MWAI_Logging::log( "Custom (OpenAI-Compatible): /models returned HTTP {$code}." );
      return [];
    }

    $body = json_decode( wp_remote_retrieve_body( $response ), true );
    if ( ! isset( $body['data'] ) || ! is_array( $body['data'] ) ) {
      return [];
    }

    $models = [];
    foreach ( $body['data'] as $remote ) {
      $modelId = $remote['id'] ?? '';
      if ( empty( $modelId ) ) {
        continue;
      }
      $isEmbedding = strpos( strtolower( $modelId ), 'embed' ) !== false;
      $features = $isEmbedding ? [ 'embedding' ] : [ 'completion' ];
      $tags = [ 'core', $isEmbedding ? 'embedding' : 'chat' ];

      $modelData = [
        'model' => $modelId,
        'name' => $modelId,
        'family' => 'custom',
        'features' => $features,
        // Pricing is unknown for self-hosted/third-party servers — zero out so usage tracking
        // doesn't invent costs. Users can add per-model pricing through the Custom Models UI.
        'price' => [ 'in' => 0, 'out' => 0 ],
        'type' => 'token',
        'unit' => 1 / 1000000,
        'maxCompletionTokens' => isset( $remote['max_output_tokens'] ) ? (int) $remote['max_output_tokens'] : 4096,
        'maxContextualTokens' => isset( $remote['context_window'] ) ? (int) $remote['context_window'] : 8192,
        'tags' => $tags,
      ];

      if ( $isEmbedding ) {
        $modelData['dimensions'] = isset( $remote['dimensions'] ) ? (int) $remote['dimensions'] : 1536;
      }

      $models[] = $modelData;
    }
    return $models;
  }

  /**
   * Hits /v1/models directly so the user gets a real success/error response. Works without an
   * API key, surfaces real HTTP errors when they happen.
   */
  public function connection_check() {
    $base = $this->resolve_endpoint();
    $url = $base . '/models';
    $details = [ 'endpoint' => $url ];

    $headers = [ 'User-Agent' => 'AI Engine' ];
    if ( ! empty( $this->apiKey ) ) {
      $headers['Authorization'] = 'Bearer ' . $this->apiKey;
    }

    $response = wp_remote_get( $url, [
      'headers' => $headers,
      'timeout' => 10,
      'sslverify' => MWAI_SSL_VERIFY,
    ] );

    if ( is_wp_error( $response ) ) {
      return [
        'success' => false, 'service' => $this->get_service_name(),
        'error' => $response->get_error_message(),
        'details' => $details,
      ];
    }

    $code = wp_remote_retrieve_response_code( $response );
    $body = json_decode( wp_remote_retrieve_body( $response ), true );

    if ( $code >= 400 || ( is_array( $body ) && isset( $body['error'] ) ) ) {
      $message = is_array( $body ) && isset( $body['error'] )
        ? ( is_string( $body['error'] ) ? $body['error'] : ( $body['error']['message'] ?? json_encode( $body['error'] ) ) )
        : "HTTP {$code} from {$url}.";
      return [
        'success' => false, 'service' => $this->get_service_name(),
        'error' => $message,
        'details' => array_merge( $details, [ 'http_code' => $code ] ),
      ];
    }

    $modelIds = [];
    if ( isset( $body['data'] ) && is_array( $body['data'] ) ) {
      foreach ( array_slice( $body['data'], 0, 5 ) as $m ) {
        if ( isset( $m['id'] ) ) {
          $modelIds[] = $m['id'];
        }
      }
    }
    return [
      'success' => true, 'service' => $this->get_service_name(),
      'message' => 'Connection successful. Found ' . count( $body['data'] ?? [] ) . ' models.',
      'details' => array_merge( $details, [
        'model_count' => count( $body['data'] ?? [] ),
        'sample_models' => $modelIds,
      ] ),
    ];
  }
}