<?php
/**
 * AiClient provider adapters for Aimogen-managed providers.
 *
 * WordPress AI features route through the bundled `php-ai-client`, which only
 * knows providers that have a registered adapter ("AI Provider" plugin).
 * Dedicated plugins exist for OpenAI/Anthropic/Google, but not for the rest
 * of Aimogen's providers - users would see "needs its AI provider plugin
 * installed" for plugins that don't exist.
 *
 * All of these providers expose OpenAI-compatible APIs (Google through its
 * `/v1beta/openai` compatibility endpoint), so the AI Client's bundled
 * OpenAI-compatible abstract implementations cover them. This file registers
 * one thin adapter per provider, with model listing via each provider's
 * `/models` endpoint and a fallback to Aimogen's curated model lists for
 * providers without a listing endpoint (Perplexity, Anthropic compat).
 *
 * Only loaded in managed mode, and never overrides an already-registered
 * adapter (e.g. the official "AI Provider for OpenAI" plugin).
 *
 * @package Aimogen_Pro
 */

use WordPress\AiClient\AiClient;
use WordPress\AiClient\Common\Exception\RuntimeException;
use WordPress\AiClient\Messages\Enums\ModalityEnum;
use WordPress\AiClient\Providers\ApiBasedImplementation\AbstractApiProvider;
use WordPress\AiClient\Providers\Contracts\ModelMetadataDirectoryInterface;
use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface;
use WordPress\AiClient\Providers\DTO\ProviderMetadata;
use WordPress\AiClient\Providers\Enums\ProviderTypeEnum;
use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication;
use WordPress\AiClient\Providers\Http\DTO\Request;
use WordPress\AiClient\Providers\Http\DTO\Response;
use WordPress\AiClient\Providers\Http\Enums\HttpMethodEnum;
use WordPress\AiClient\Providers\Http\Enums\RequestAuthenticationMethod;
use WordPress\AiClient\Providers\Http\Exception\ResponseException;
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelMetadata;
use WordPress\AiClient\Providers\Models\DTO\SupportedOption;
use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum;
use WordPress\AiClient\Providers\Models\Enums\OptionEnum;
use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleImageGenerationModel;
use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleModelMetadataDirectory;
use WordPress\AiClient\Providers\OpenAiCompatibleImplementation\AbstractOpenAiCompatibleTextGenerationModel;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Registrar + shared configuration for Aimogen's AiClient provider adapters.
 */
final class Aiomatic_WPAI_Providers {

	/**
	 * Provider configuration.
	 *
	 * 'listing' => 'http'   - provider has an OpenAI-compatible GET /models
	 *                         endpoint (curated Aimogen list as fallback).
	 *            => 'static' - no usable listing endpoint; models come from
	 *                         Aimogen's curated lists only.
	 *
	 * @return array<string, array{name: string, base_url: string, credentials_url: ?string, type: string, listing: string, class: class-string, strip_prefix?: string}>
	 */
	public static function config(): array {
		$config = array(
			'openai'      => array(
				'name'            => 'OpenAI',
				'base_url'        => 'https://api.openai.com/v1',
				'credentials_url' => 'https://platform.openai.com/api-keys',
				'type'            => 'cloud',
				'listing'         => 'http',
				'class'           => Aiomatic_WPAI_Provider_OpenAI::class,
			),
			'anthropic'   => array(
				'name'            => 'Anthropic',
				// Anthropic's OpenAI SDK compatibility layer.
				'base_url'        => 'https://api.anthropic.com/v1',
				'credentials_url' => 'https://console.anthropic.com/settings/keys',
				'type'            => 'cloud',
				// /v1/models needs x-api-key auth, not the Bearer token the
				// compat layer uses - curated list avoids the mismatch.
				'listing'         => 'static',
				'class'           => Aiomatic_WPAI_Provider_Anthropic::class,
			),
			'google'      => array(
				'name'            => 'Google',
				// Gemini's OpenAI compatibility endpoint.
				'base_url'        => 'https://generativelanguage.googleapis.com/v1beta/openai',
				'credentials_url' => 'https://aistudio.google.com/api-keys',
				'type'            => 'cloud',
				'listing'         => 'http',
				'strip_prefix'    => 'models/',
				'class'           => Aiomatic_WPAI_Provider_Google::class,
			),
			'openrouter'  => array(
				'name'            => 'OpenRouter',
				'base_url'        => 'https://openrouter.ai/api/v1',
				'credentials_url' => 'https://openrouter.ai/keys',
				'type'            => 'cloud',
				'listing'         => 'http',
				'class'           => Aiomatic_WPAI_Provider_OpenRouter::class,
			),
			'perplexity'  => array(
				'name'            => 'Perplexity',
				'base_url'        => 'https://api.perplexity.ai',
				'credentials_url' => 'https://www.perplexity.ai/settings/api',
				'type'            => 'cloud',
				// Perplexity has no /models endpoint.
				'listing'         => 'static',
				'class'           => Aiomatic_WPAI_Provider_Perplexity::class,
			),
			'groq'        => array(
				'name'            => 'Groq',
				'base_url'        => 'https://api.groq.com/openai/v1',
				'credentials_url' => 'https://console.groq.com/keys',
				'type'            => 'cloud',
				'listing'         => 'http',
				'class'           => Aiomatic_WPAI_Provider_Groq::class,
			),
			'nvidia'      => array(
				'name'            => 'NVIDIA',
				'base_url'        => 'https://integrate.api.nvidia.com/v1',
				'credentials_url' => 'https://build.nvidia.com/',
				'type'            => 'cloud',
				'listing'         => 'http',
				'class'           => Aiomatic_WPAI_Provider_NVIDIA::class,
			),
			'xai'         => array(
				'name'            => 'xAI',
				'base_url'        => 'https://api.x.ai/v1',
				'credentials_url' => 'https://console.x.ai/',
				'type'            => 'cloud',
				'listing'         => 'http',
				'class'           => Aiomatic_WPAI_Provider_XAI::class,
			),
			'huggingface' => array(
				'name'            => 'Hugging Face',
				// HF Inference Providers router (OpenAI-compatible).
				'base_url'        => 'https://router.huggingface.co/v1',
				'credentials_url' => 'https://huggingface.co/settings/tokens',
				'type'            => 'cloud',
				'listing'         => 'http',
				'class'           => Aiomatic_WPAI_Provider_HuggingFace::class,
			),
			'ollama'      => array(
				'name'            => 'Ollama',
				'base_url'        => '', // Dynamic, from Aimogen's Ollama URL setting.
				'credentials_url' => null,
				'type'            => 'server',
				'listing'         => 'http',
				'class'           => Aiomatic_WPAI_Provider_Ollama::class,
			),
		);

		/**
		 * Filters the AiClient provider adapter configuration.
		 *
		 * @param array $config Provider config keyed by provider/connector ID.
		 */
		return (array) apply_filters( 'aiomatic_wpai_provider_adapters', $config );
	}

	/**
	 * Registers all adapters with the AiClient default registry and hooks up
	 * authentication from Aimogen's stored credentials.
	 */
	public static function register(): void {
		if ( ! class_exists( AiClient::class ) ) {
			return;
		}

		try {
			$registry = AiClient::defaultRegistry();
		} catch ( Throwable $e ) {
			return;
		}

		foreach ( self::config() as $provider_id => $config ) {
			try {
				// A dedicated provider plugin (e.g. "AI Provider for OpenAI")
				// always wins; we only fill the gaps.
				if ( $registry->hasProvider( $provider_id ) || $registry->hasProvider( $config['class'] ) ) {
					continue;
				}

				$registry->registerProvider( $config['class'] );

				// Hook up the key straight from Aimogen's settings; core's
				// `_wp_connectors_pass_default_keys_to_ai_client` (init@20)
				// re-applies the same value from the mirrored connector
				// option, so both paths agree.
				$key = Aiomatic_WP_AI_Connectors::aimogen_credential( $provider_id );
				if ( $provider_id === 'ollama' ) {
					// Ollama ignores authentication, but the AI Client
					// requires an instance to issue requests at all.
					$key = 'ollama';
				}
				if ( $key !== '' ) {
					$registry->setProviderRequestAuthentication(
						$provider_id,
						new ApiKeyRequestAuthentication( $key )
					);
				}
			} catch ( Throwable $e ) {
				if ( function_exists( 'aiomatic_log_to_file' ) ) {
					aiomatic_log_to_file( 'WP AI Connector: failed to register AiClient adapter for ' . $provider_id . ': ' . $e->getMessage() );
				}
			}
		}
	}

	/** Builds a full endpoint URL for a provider. */
	public static function url( string $provider_id, string $path = '' ): string {
		$base = self::base_url( $provider_id );

		if ( $path === '' ) {
			return $base;
		}

		return $base . '/' . ltrim( $path, '/' );
	}

	/** Resolves a provider's API base URL (Ollama's comes from settings). */
	public static function base_url( string $provider_id ): string {
		if ( $provider_id === 'ollama' ) {
			$url = Aiomatic_WP_AI_Connectors::aimogen_credential( 'ollama' );
			$url = rtrim( $url, '/' );
			if ( $url === '' ) {
				$url = 'http://localhost:11434';
			}
			if ( substr( $url, -3 ) !== '/v1' ) {
				$url .= '/v1';
			}
			return $url;
		}

		$config = self::config();

		return isset( $config[ $provider_id ]['base_url'] ) ? rtrim( (string) $config[ $provider_id ]['base_url'], '/' ) : '';
	}

	/**
	 * Builds ModelMetadata objects from Aimogen's curated model lists, used
	 * for providers without a listing endpoint and as an offline fallback.
	 *
	 * @return array<string, ModelMetadata> Map of model ID to metadata.
	 */
	public static function curated_model_metadata( string $provider_id ): array {
		$lists = Aiomatic_WP_AI_Connectors::aimogen_models_for( $provider_id );
		$map   = array();

		foreach ( $lists['text'] as $model_id ) {
			$map[ $model_id ] = self::text_model_metadata( $model_id );
		}
		foreach ( $lists['image'] as $model_id ) {
			if ( ! isset( $map[ $model_id ] ) ) {
				$map[ $model_id ] = self::image_model_metadata( $model_id );
			}
		}

		return $map;
	}

	/** Standard metadata for an OpenAI-compatible chat model. */
	public static function text_model_metadata( string $model_id, string $display_name = '' ): ModelMetadata {
		$options = array(
			new SupportedOption( OptionEnum::systemInstruction() ),
			new SupportedOption( OptionEnum::candidateCount() ),
			new SupportedOption( OptionEnum::maxTokens() ),
		);

		if ( ! self::rejects_sampling_params( $model_id ) ) {
			$options[] = new SupportedOption( OptionEnum::temperature() );
			$options[] = new SupportedOption( OptionEnum::topP() );
		}

		$options = array_merge(
			$options,
			array(
				new SupportedOption( OptionEnum::stopSequences() ),
				new SupportedOption( OptionEnum::outputMimeType(), array( 'text/plain', 'application/json' ) ),
				new SupportedOption( OptionEnum::outputSchema() ),
				new SupportedOption( OptionEnum::customOptions() ),
				new SupportedOption( OptionEnum::inputModalities(), array( array( ModalityEnum::text() ) ) ),
				new SupportedOption( OptionEnum::outputModalities(), array( array( ModalityEnum::text() ) ) ),
			)
		);

		return new ModelMetadata(
			$model_id,
			$display_name !== '' ? $display_name : $model_id,
			array(
				CapabilityEnum::textGeneration(),
				CapabilityEnum::chatHistory(),
			),
			$options
		);
	}

	/** Standard metadata for an OpenAI-compatible image generation model. */
	public static function image_model_metadata( string $model_id, string $display_name = '' ): ModelMetadata {
		return new ModelMetadata(
			$model_id,
			$display_name !== '' ? $display_name : $model_id,
			array(
				CapabilityEnum::imageGeneration(),
			),
			array(
				new SupportedOption( OptionEnum::candidateCount() ),
				new SupportedOption( OptionEnum::outputMimeType(), array( 'image/png' ) ),
				new SupportedOption( OptionEnum::customOptions() ),
				new SupportedOption( OptionEnum::inputModalities(), array( array( ModalityEnum::text() ) ) ),
				new SupportedOption( OptionEnum::outputModalities(), array( array( ModalityEnum::image() ) ) ),
			)
		);
	}

	/** True for model IDs that generate images on OpenAI-compatible APIs. */
	public static function is_image_model_id( string $model_id ): bool {
		return (bool) preg_match( '/(dall-e|gpt-image|imagen|grok-[0-9.]+-image|flux|stable-diffusion|sdxl)/i', $model_id );
	}

	/** True for model IDs that can't serve chat/completions or images at all. */
	public static function is_unsupported_model_id( string $model_id ): bool {
		return (bool) preg_match(
			'/(embed|whisper|transcribe|moderation|guard|rerank|^tts-|-tts$|-tts-|audio|realtime|sora|veo-|babbage|davinci|-instruct|aqa)/i',
			$model_id
		);
	}

	/**
	 * Anthropic's newer reasoning models reject OpenAI-compat sampling
	 * params (`temperature`, `top_p`, …) with HTTP 400 even at default
	 * values. Matches Aimogen's own advanced-model list and OpenRouter
	 * paths such as `anthropic/claude-opus-4-7`.
	 */
	public static function rejects_sampling_params( string $model_id ): bool {
		if ( strpos( $model_id, '/' ) !== false ) {
			$model_id = substr( $model_id, strrpos( $model_id, '/' ) + 1 );
		}

		$prefixes = array(
			'claude-opus-4-6',
			'claude-opus-4-7',
			'claude-opus-4-8',
			'claude-sonnet-5',
			'claude-sonnet-4-6',
			'claude-fable-5',
			'claude-mythos-5',
		);

		/**
		 * Filters model ID prefixes that must not receive sampling params.
		 *
		 * @param string[] $prefixes Model ID prefixes.
		 * @param string   $model_id Normalized model ID (no provider path).
		 */
		$prefixes = (array) apply_filters( 'aiomatic_wpai_no_sampling_model_prefixes', $prefixes, $model_id );

		foreach ( $prefixes as $prefix ) {
			if ( ! is_string( $prefix ) || $prefix === '' ) {
				continue;
			}
			if ( $model_id === $prefix || strpos( $model_id, $prefix . '-' ) === 0 ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Removes OpenAI-compat sampling params that some providers reject.
	 *
	 * @param array<string, mixed> $params Request body parameters.
	 */
	public static function strip_rejected_sampling_params( array $params, string $model_id ): array {
		if ( ! self::rejects_sampling_params( $model_id ) ) {
			return $params;
		}

		foreach ( array( 'temperature', 'top_p', 'presence_penalty', 'frequency_penalty', 'logprobs', 'top_logprobs' ) as $key ) {
			unset( $params[ $key ] );
		}

		return $params;
	}
}

/**
 * Generic text generation model: standard OpenAI-compatible chat/completions,
 * routed to the owning provider's base URL.
 */
class Aiomatic_WPAI_Text_Model extends AbstractOpenAiCompatibleTextGenerationModel {
	protected function createRequest( HttpMethodEnum $method, string $path, array $headers = array(), $data = null ): Request {
		return new Request(
			$method,
			Aiomatic_WPAI_Providers::url( $this->providerMetadata()->getId(), $path ),
			$headers,
			$data
		);
	}

	/**
	 * {@inheritDoc}
	 *
	 * Strips sampling parameters for Anthropic reasoning models that return
	 * `temperature is deprecated for this model` when WP AI forwards them.
	 */
	protected function prepareGenerateTextParams( array $prompt ): array {
		$params = parent::prepareGenerateTextParams( $prompt );

		return Aiomatic_WPAI_Providers::strip_rejected_sampling_params(
			$params,
			$this->metadata()->getId()
		);
	}
}

/**
 * Generic image generation model (OpenAI-compatible images/generations).
 */
class Aiomatic_WPAI_Image_Model extends AbstractOpenAiCompatibleImageGenerationModel {
	protected function createRequest( HttpMethodEnum $method, string $path, array $headers = array(), $data = null ): Request {
		return new Request(
			$method,
			Aiomatic_WPAI_Providers::url( $this->providerMetadata()->getId(), $path ),
			$headers,
			$data
		);
	}
}

/**
 * Model metadata directory: GET /models in OpenAI format where available,
 * falling back to Aimogen's curated lists when the endpoint is missing,
 * unreachable, or the provider is configured for static listing.
 */
class Aiomatic_WPAI_Model_Directory extends AbstractOpenAiCompatibleModelMetadataDirectory {

	private $provider_id;

	public function __construct( string $provider_id ) {
		$this->provider_id = $provider_id;
	}

	/**
	 * The parent keys the cache by class name only; all our providers share
	 * this class, so the provider ID must be part of the key.
	 */
	protected function getBaseCacheKey(): string {
		return 'ai_client_' . AiClient::VERSION . '_' . md5( static::class . '_' . $this->provider_id );
	}

	protected function sendListModelsRequest(): array {
		// No credential in Aimogen -> the provider must not report a model
		// catalog at all; otherwise the curated fallback below would make
		// `isProviderConfigured()` true and the connector show "connected".
		if ( Aiomatic_WP_AI_Connectors::aimogen_credential( $this->provider_id ) === '' ) {
			throw new RuntimeException(
				sprintf( 'No %s credential is configured in Aimogen Pro.', $this->provider_id )
			);
		}

		$config  = Aiomatic_WPAI_Providers::config();
		$listing = $config[ $this->provider_id ]['listing'] ?? 'http';

		if ( $listing !== 'http' ) {
			return Aiomatic_WPAI_Providers::curated_model_metadata( $this->provider_id );
		}

		try {
			return parent::sendListModelsRequest();
		} catch ( Throwable $e ) {
			// Offline/unreachable listing endpoint: fall back to the curated
			// list so a transient network failure can't disable the provider.
			$fallback = Aiomatic_WPAI_Providers::curated_model_metadata( $this->provider_id );
			if ( ! empty( $fallback ) ) {
				return $fallback;
			}
			throw $e;
		}
	}

	protected function createRequest( HttpMethodEnum $method, string $path, array $headers = array(), $data = null ): Request {
		return new Request(
			$method,
			Aiomatic_WPAI_Providers::url( $this->provider_id, $path ),
			$headers,
			$data
		);
	}

	protected function parseResponseToModelMetadataList( Response $response ): array {
		$data = $response->getData();
		if ( ! isset( $data['data'] ) || ! is_array( $data['data'] ) || empty( $data['data'] ) ) {
			throw ResponseException::fromMissingData( $this->provider_id, 'data' );
		}

		$config = Aiomatic_WPAI_Providers::config();
		$strip  = $config[ $this->provider_id ]['strip_prefix'] ?? '';

		$models = array();

		foreach ( $data['data'] as $model_data ) {
			if ( ! is_array( $model_data ) || empty( $model_data['id'] ) || ! is_string( $model_data['id'] ) ) {
				continue;
			}

			$model_id = $model_data['id'];
			if ( $strip !== '' && strpos( $model_id, $strip ) === 0 ) {
				$model_id = substr( $model_id, strlen( $strip ) );
			}

			$display_name = isset( $model_data['name'] ) && is_string( $model_data['name'] ) ? $model_data['name'] : $model_id;

			if ( Aiomatic_WPAI_Providers::is_image_model_id( $model_id ) ) {
				$models[] = Aiomatic_WPAI_Providers::image_model_metadata( $model_id, $display_name );
				continue;
			}

			if ( Aiomatic_WPAI_Providers::is_unsupported_model_id( $model_id ) ) {
				continue;
			}

			$models[] = Aiomatic_WPAI_Providers::text_model_metadata( $model_id, $display_name );
		}

		return $models;
	}
}

/**
 * Availability check: a provider is configured only when Aimogen actually
 * holds a credential for it (for Ollama, the server URL).
 *
 * `ListModelsApiBasedProviderAvailability` alone is not enough here: model
 * catalogs are cached for 24h, so after a key is removed from Aimogen the
 * cached catalog would keep `isProviderConfigured()` - and therefore the
 * Connectors page "connected" badge - true.
 */
class Aiomatic_WPAI_Provider_Availability implements ProviderAvailabilityInterface {

	private $provider_id;

	private $directory;

	public function __construct( string $provider_id, ModelMetadataDirectoryInterface $directory ) {
		$this->provider_id = $provider_id;
		$this->directory   = $directory;
	}

	public function isConfigured(): bool {
		if ( Aiomatic_WP_AI_Connectors::aimogen_credential( $this->provider_id ) === '' ) {
			return false;
		}

		try {
			$this->directory->listModelMetadata();
			return true;
		} catch ( Exception $e ) {
			return false;
		}
	}
}

/**
 * Shared provider base: all configuration comes from the registrar's map.
 */
abstract class Aiomatic_WPAI_Abstract_Provider extends AbstractApiProvider {

	/** The provider/connector ID this class serves. */
	abstract protected static function id(): string;

	protected static function baseUrl(): string {
		return Aiomatic_WPAI_Providers::base_url( static::id() );
	}

	protected static function createModel( ModelMetadata $modelMetadata, ProviderMetadata $providerMetadata ): ModelInterface {
		foreach ( $modelMetadata->getSupportedCapabilities() as $capability ) {
			if ( $capability->isImageGeneration() ) {
				return new Aiomatic_WPAI_Image_Model( $modelMetadata, $providerMetadata );
			}
		}

		return new Aiomatic_WPAI_Text_Model( $modelMetadata, $providerMetadata );
	}

	protected static function createProviderMetadata(): ProviderMetadata {
		$config = Aiomatic_WPAI_Providers::config();
		$id     = static::id();
		$entry  = $config[ $id ];

		$args = array(
			$id,
			(string) $entry['name'],
			$entry['type'] === 'server' ? ProviderTypeEnum::server() : ProviderTypeEnum::cloud(),
			isset( $entry['credentials_url'] ) ? $entry['credentials_url'] : null,
			RequestAuthenticationMethod::apiKey(),
		);

		// Description argument exists since AI Client 1.2.0.
		if ( version_compare( AiClient::VERSION, '1.2.0', '>=' ) ) {
			$args[] = sprintf( '%s, managed by Aimogen Pro.', (string) $entry['name'] );
		}

		return new ProviderMetadata( ...$args );
	}

	protected static function createProviderAvailability(): ProviderAvailabilityInterface {
		return new Aiomatic_WPAI_Provider_Availability( static::id(), static::modelMetadataDirectory() );
	}

	protected static function createModelMetadataDirectory(): ModelMetadataDirectoryInterface {
		return new Aiomatic_WPAI_Model_Directory( static::id() );
	}
}

// One concrete class per provider: the AI Client registry and the
// AbstractProvider static caches are keyed by class name.

class Aiomatic_WPAI_Provider_OpenAI extends Aiomatic_WPAI_Abstract_Provider {
	protected static function id(): string {
		return 'openai';
	}
}

class Aiomatic_WPAI_Provider_Anthropic extends Aiomatic_WPAI_Abstract_Provider {
	protected static function id(): string {
		return 'anthropic';
	}
}

class Aiomatic_WPAI_Provider_Google extends Aiomatic_WPAI_Abstract_Provider {
	protected static function id(): string {
		return 'google';
	}
}

class Aiomatic_WPAI_Provider_OpenRouter extends Aiomatic_WPAI_Abstract_Provider {
	protected static function id(): string {
		return 'openrouter';
	}
}

class Aiomatic_WPAI_Provider_Perplexity extends Aiomatic_WPAI_Abstract_Provider {
	protected static function id(): string {
		return 'perplexity';
	}
}

class Aiomatic_WPAI_Provider_Groq extends Aiomatic_WPAI_Abstract_Provider {
	protected static function id(): string {
		return 'groq';
	}
}

class Aiomatic_WPAI_Provider_NVIDIA extends Aiomatic_WPAI_Abstract_Provider {
	protected static function id(): string {
		return 'nvidia';
	}
}

class Aiomatic_WPAI_Provider_XAI extends Aiomatic_WPAI_Abstract_Provider {
	protected static function id(): string {
		return 'xai';
	}
}

class Aiomatic_WPAI_Provider_HuggingFace extends Aiomatic_WPAI_Abstract_Provider {
	protected static function id(): string {
		return 'huggingface';
	}
}

class Aiomatic_WPAI_Provider_Ollama extends Aiomatic_WPAI_Abstract_Provider {
	protected static function id(): string {
		return 'ollama';
	}
}
