PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / trunk
AI Engine – The Chatbot, AI Framework & MCP for WordPress vtrunk
3.5.8 3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / classes / engines / open-router.php
ai-engine / classes / engines Last commit date
anthropic.php 22 hours ago chatml.php 1 week ago core.php 2 days ago custom.php 1 month ago factory.php 1 week ago google-interactions.php 2 days ago google.php 1 week ago mistral.php 1 week ago open-router.php 3 weeks ago openai.php 3 weeks ago ovh.php 1 week ago perplexity.php 6 months ago replicate.php 5 months ago xai.php 1 month ago
open-router.php
597 lines
1 <?php
2
3 // If this isn't defined elsewhere, set it here by default. You can override
4 // it in your theme's functions.php or your main wp-config.php. If set to true,
5 // additional time will be spent fetching exact pricing info from OpenRouter
6 // after each query, resulting in more accurate but potentially slower responses.
7 if ( !defined( 'MWAI_OPENROUTER_ACCURATE_PRICING' ) ) {
8 define( 'MWAI_OPENROUTER_ACCURATE_PRICING', false );
9 }
10
11 class Meow_MWAI_Engines_OpenRouter extends Meow_MWAI_Engines_ChatML {
12 /**
13 * Keep a static dictionary (query -> price) so that if we see the same query
14 * again in another instance, we can immediately return the stored price
15 * instead of recomputing.
16 * @var array
17 */
18 private static $accuratePrices = [];
19
20 /**
21 * Web search citations captured during streaming (they arrive as delta.annotations).
22 * @var array
23 */
24 protected $inAnnotations = [];
25
26 public function __construct( $core, $env ) {
27 parent::__construct( $core, $env );
28 }
29
30 public function reset_stream() {
31 $this->inAnnotations = [];
32 return parent::reset_stream();
33 }
34
35 // Capture web search citations from the stream (they arrive as delta.annotations).
36 protected function stream_data_handler( $json ) {
37 if ( !empty( $json['choices'][0]['delta']['annotations'] ) ) {
38 $this->inAnnotations = array_merge( $this->inAnnotations, $json['choices'][0]['delta']['annotations'] );
39 }
40 return parent::stream_data_handler( $json );
41 }
42
43 /**
44 * The web plugin returns url_citation annotations. Most models already cite
45 * inline, so we only append a compact "Sources" footer for citations that
46 * aren't already linked in the answer. Annotations live on the message in
47 * non-streaming mode and are collected from the stream otherwise.
48 */
49 protected function finalize_choices( $choices, $responseData, $query ) {
50 $choices = parent::finalize_choices( $choices, $responseData, $query );
51
52 foreach ( $choices as &$choice ) {
53 if ( !isset( $choice['message']['content'] ) || !is_string( $choice['message']['content'] ) ) {
54 continue;
55 }
56 $annotations = $choice['message']['annotations'] ?? null;
57 if ( empty( $annotations ) && !empty( $this->inAnnotations ) ) {
58 $annotations = $this->inAnnotations;
59 }
60 if ( empty( $annotations ) ) {
61 continue;
62 }
63
64 $content = $choice['message']['content'];
65 $links = [];
66 $seen = [];
67 foreach ( $annotations as $annotation ) {
68 if ( ( $annotation['type'] ?? '' ) !== 'url_citation' ) {
69 continue;
70 }
71 $citation = $annotation['url_citation'] ?? [];
72 $url = $citation['url'] ?? '';
73 if ( empty( $url ) || isset( $seen[$url] ) || strpos( $content, $url ) !== false ) {
74 continue;
75 }
76 $seen[$url] = true;
77 $title = trim( $citation['title'] ?? '' );
78 if ( empty( $title ) ) {
79 $title = parse_url( $url, PHP_URL_HOST );
80 $title = $title ? str_replace( 'www.', '', $title ) : $url;
81 }
82 $links[] = '- [' . $title . '](' . $url . ')';
83 }
84
85 if ( !empty( $links ) ) {
86 $content .= "\n\n**" . __( 'Sources', 'ai-engine' ) . "**\n" . implode( "\n", $links );
87 $choice['message']['content'] = $content;
88 }
89 }
90
91 return $choices;
92 }
93
94 protected function set_environment() {
95 $env = $this->env;
96 $this->apiKey = $env['apikey'];
97 }
98
99 protected function build_url( $query, $endpoint = null ) {
100 $endpoint = apply_filters( 'mwai_openrouter_endpoint', 'https://openrouter.ai/api/v1', $this->env );
101 return parent::build_url( $query, $endpoint );
102 }
103
104 protected function build_headers( $query ) {
105 $site_url = apply_filters( 'mwai_openrouter_site_url', get_site_url(), $query );
106 $site_name = apply_filters( 'mwai_openrouter_site_name', get_bloginfo( 'name' ), $query );
107 if ( $query->apiKey ) {
108 $this->apiKey = $query->apiKey;
109 }
110 if ( empty( $this->apiKey ) ) {
111 throw new Exception( 'No API Key provided. Please visit the Settings. (OpenRouter Engine)' );
112 }
113 return [
114 'Content-Type' => 'application/json',
115 'Authorization' => 'Bearer ' . $this->apiKey,
116 'HTTP-Referer' => $site_url,
117 'X-Title' => $site_name,
118 'User-Agent' => 'AI Engine',
119 ];
120 }
121
122 protected function build_body( $query, $streamCallback = null, $extra = null ) {
123 $body = parent::build_body( $query, $streamCallback, $extra );
124 // Only add transforms and usage for chat completions, not embeddings
125 if ( !( $query instanceof Meow_MWAI_Query_Embed ) ) {
126 $body['transforms'] = ['middle-out'];
127 $body['usage'] = [ 'include' => true ];
128
129 // Web search: OpenRouter's "web" plugin works on any model (it runs the
130 // search itself and injects the results), so we only enable it on request.
131 if ( !empty( $query->tools ) && in_array( 'web_search', $query->tools, true ) ) {
132 $webPlugin = apply_filters( 'mwai_openrouter_web_plugin', [ 'id' => 'web' ], $query );
133 $body['plugins'] = array_merge( $body['plugins'] ?? [], [ $webPlugin ] );
134 }
135 }
136 else {
137 // Only OpenAI embedding models support the dimensions parameter
138 // Remove it for other providers to avoid errors
139 $model = $query->model ?? '';
140 if ( isset( $body['dimensions'] ) && strpos( $model, 'openai/' ) !== 0 ) {
141 unset( $body['dimensions'] );
142 }
143 }
144 return $body;
145 }
146
147 protected function get_service_name() {
148 return 'OpenRouter';
149 }
150
151 public function get_models() {
152 return $this->core->get_engine_models( 'openrouter' );
153 }
154
155 /**
156 * Requests usage data if streaming was used and the usage is incomplete.
157 */
158 public function handle_tokens_usage(
159 $reply,
160 $query,
161 $returned_model,
162 $returned_in_tokens,
163 $returned_out_tokens,
164 $returned_price = null
165 ) {
166 // If streaming is not enabled, we might already have all usage data
167 $everything_is_set = !is_null( $returned_model )
168 && !is_null( $returned_in_tokens )
169 && !is_null( $returned_out_tokens );
170
171 // Clean up the data
172 $returned_in_tokens = $returned_in_tokens ?? $reply->get_in_tokens( $query );
173 $returned_out_tokens = $returned_out_tokens ?? $reply->get_out_tokens();
174 $returned_price = $returned_price ?? $reply->get_price();
175
176 // Record the usage in the database
177 $usage = $this->core->record_tokens_usage(
178 $returned_model,
179 $returned_in_tokens,
180 $returned_out_tokens,
181 $returned_price
182 );
183
184 // Set the usage back on the reply
185 $reply->set_usage( $usage );
186
187 // Set accuracy based on data availability
188 if ( !is_null( $returned_price ) && !is_null( $returned_in_tokens ) && !is_null( $returned_out_tokens ) ) {
189 // OpenRouter returns price from API = full accuracy
190 $reply->set_usage_accuracy( 'full' );
191 }
192 elseif ( !is_null( $returned_in_tokens ) && !is_null( $returned_out_tokens ) ) {
193 // Tokens from API but price calculated = tokens accuracy
194 $reply->set_usage_accuracy( 'tokens' );
195 }
196 else {
197 // Everything estimated
198 $reply->set_usage_accuracy( 'estimated' );
199 }
200 }
201
202 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
203 $price = $reply->get_price();
204 return is_null( $price ) ? parent::get_price( $query, $reply ) : $price;
205 }
206
207 /**
208 * OpenRouter uses /chat/completions with modalities parameter for image generation,
209 * not the standard /images/generations endpoint.
210 */
211 public function run_image_query( $query, $streamCallback = null ) {
212 $body = [
213 'model' => $query->model,
214 'messages' => [
215 [
216 'role' => 'user',
217 'content' => $query->get_message()
218 ]
219 ],
220 'modalities' => [ 'text', 'image' ],
221 ];
222
223 // Add number of images if specified
224 if ( !empty( $query->maxResults ) && $query->maxResults > 1 ) {
225 $body['n'] = $query->maxResults;
226 }
227
228 // Add image config for Gemini models (aspect ratio support)
229 if ( !empty( $query->resolution ) && strpos( $query->model, 'google/' ) === 0 ) {
230 $body['image_config'] = [
231 'aspect_ratio' => $query->resolution
232 ];
233 }
234
235 $endpoint = apply_filters( 'mwai_openrouter_endpoint', 'https://openrouter.ai/api/v1', $this->env );
236 $url = trailingslashit( $endpoint ) . 'chat/completions';
237 $headers = $this->build_headers( $query );
238 $options = $this->build_options( $headers, $body );
239
240 try {
241 $res = $this->run_query( $url, $options );
242 $data = $res['data'];
243
244 if ( empty( $data ) || !isset( $data['choices'] ) ) {
245 throw new Exception( 'No image generated in response.' );
246 }
247
248 $reply = new Meow_MWAI_Reply( $query );
249 $reply->set_type( 'images' );
250 $images = [];
251
252 // Extract images from the response
253 foreach ( $data['choices'] as $choice ) {
254 $message = $choice['message'] ?? [];
255
256 // Check for images in the message (OpenRouter format)
257 // Each image is: { "type": "image_url", "image_url": { "url": "data:image/png;base64,..." } }
258 if ( isset( $message['images'] ) && is_array( $message['images'] ) ) {
259 foreach ( $message['images'] as $image ) {
260 if ( is_array( $image ) && isset( $image['image_url']['url'] ) ) {
261 $images[] = [ 'url' => $image['image_url']['url'] ];
262 }
263 elseif ( is_array( $image ) && isset( $image['image_url'] ) && is_string( $image['image_url'] ) ) {
264 $images[] = [ 'url' => $image['image_url'] ];
265 }
266 elseif ( is_string( $image ) ) {
267 // Direct base64 string
268 $images[] = [ 'url' => $image ];
269 }
270 }
271 }
272
273 // Also check content array for image parts
274 if ( isset( $message['content'] ) && is_array( $message['content'] ) ) {
275 foreach ( $message['content'] as $part ) {
276 if ( isset( $part['type'] ) && $part['type'] === 'image_url' ) {
277 if ( isset( $part['image_url']['url'] ) ) {
278 $images[] = [ 'url' => $part['image_url']['url'] ];
279 }
280 elseif ( is_string( $part['image_url'] ) ) {
281 $images[] = [ 'url' => $part['image_url'] ];
282 }
283 }
284 }
285 }
286 }
287
288 if ( empty( $images ) ) {
289 throw new Exception( 'No images found in the response.' );
290 }
291
292 // Record usage
293 $model = $query->model;
294 $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';
295
296 if ( isset( $data['usage'] ) ) {
297 $usage = $data['usage'];
298 $promptTokens = $usage['prompt_tokens'] ?? 0;
299 $completionTokens = $usage['completion_tokens'] ?? 0;
300 $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
301 $usage['queries'] = 1;
302 $usage['accuracy'] = 'tokens';
303 $reply->set_usage( $usage );
304 $reply->set_usage_accuracy( 'tokens' );
305 }
306 else {
307 $usage = $this->core->record_images_usage( $model, $resolution, count( $images ) );
308 $reply->set_usage( $usage );
309 $reply->set_usage_accuracy( 'estimated' );
310 }
311
312 $reply->set_choices( $images );
313
314 // Handle local download if enabled
315 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
316 foreach ( $reply->results as &$result ) {
317 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
318 'query_envId' => $query->envId,
319 'query_session' => $query->session,
320 'query_model' => $query->model,
321 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
322 $fileUrl = $this->core->files->get_url( $fileId );
323 $result = $fileUrl;
324 }
325 }
326
327 $reply->result = $reply->results[0];
328 return $reply;
329 }
330 catch ( Exception $e ) {
331 Meow_MWAI_Logging::error( 'OpenRouter: ' . $e->getMessage() );
332 throw new Exception( 'OpenRouter: ' . $e->getMessage() );
333 }
334 }
335
336 /**
337 * Retrieve the models from OpenRouter, adding tags/features accordingly.
338 */
339 public function retrieve_models() {
340
341 // 1. Get the list of models supporting "tools"
342 $toolsModels = $this->get_supported_models( 'tools' );
343
344 // 2. Retrieve the full list of chat models
345 $url = 'https://openrouter.ai/api/v1/models';
346 $response = wp_remote_get( $url );
347 if ( is_wp_error( $response ) ) {
348 throw new Exception( 'AI Engine: ' . $response->get_error_message() );
349 }
350 $body = json_decode( $response['body'], true );
351 if ( !isset( $body['data'] ) || !is_array( $body['data'] ) ) {
352 throw new Exception( 'AI Engine: Invalid response for the list of models.' );
353 }
354
355 $models = [];
356 foreach ( $body['data'] as $model ) {
357 $models[] = $this->build_model_entry( $model, $toolsModels );
358 }
359
360 // 3. Retrieve embedding models
361 $embeddingsUrl = 'https://openrouter.ai/api/v1/embeddings/models';
362 $embeddingsResponse = wp_remote_get( $embeddingsUrl );
363 if ( !is_wp_error( $embeddingsResponse ) ) {
364 $embeddingsBody = json_decode( $embeddingsResponse['body'], true );
365 if ( isset( $embeddingsBody['data'] ) && is_array( $embeddingsBody['data'] ) ) {
366 foreach ( $embeddingsBody['data'] as $model ) {
367 $models[] = $this->build_model_entry( $model, [], true );
368 }
369 }
370 }
371
372 return $models;
373 }
374
375 /**
376 * Build a model entry from OpenRouter API data.
377 */
378 private function build_model_entry( $model, $toolsModels = [], $isEmbedding = false ) {
379 // Basic defaults
380 $family = 'n/a';
381 $maxCompletionTokens = 4096;
382 $maxContextualTokens = 8096;
383 $priceIn = 0;
384 $priceOut = 0;
385
386 // Family from model ID (e.g. "openai/gpt-4/32k" -> "openai")
387 if ( isset( $model['id'] ) ) {
388 $parts = explode( '/', $model['id'] );
389 $family = $parts[0] ?? 'n/a';
390 }
391
392 // maxCompletionTokens
393 if ( isset( $model['top_provider']['max_completion_tokens'] ) ) {
394 $maxCompletionTokens = (int) $model['top_provider']['max_completion_tokens'];
395 }
396
397 // maxContextualTokens
398 if ( isset( $model['context_length'] ) ) {
399 $maxContextualTokens = (int) $model['context_length'];
400 }
401
402 // Pricing
403 if ( isset( $model['pricing']['prompt'] ) && $model['pricing']['prompt'] > 0 ) {
404 $priceIn = $this->truncate_float( floatval( $model['pricing']['prompt'] ) * 1000 );
405 }
406 if ( isset( $model['pricing']['completion'] ) && $model['pricing']['completion'] > 0 ) {
407 $priceOut = $this->truncate_float( floatval( $model['pricing']['completion'] ) * 1000 );
408 }
409
410 // Handle embedding models
411 if ( $isEmbedding ) {
412 $features = [ 'embeddings' ];
413 $tags = [ 'core', 'embedding' ];
414
415 // Try to extract dimensions from description
416 $dimensions = null;
417 if ( isset( $model['description'] ) && preg_match( '/(\d+)-dimensional/', $model['description'], $matches ) ) {
418 $dimensions = (int) $matches[1];
419 }
420
421 $entry = [
422 'model' => $model['id'] ?? '',
423 'name' => trim( $model['name'] ?? '' ),
424 'family' => $family,
425 'features' => $features,
426 'price' => [
427 'in' => $priceIn,
428 'out' => $priceOut,
429 ],
430 'type' => 'token',
431 'unit' => 1 / 1000,
432 'maxContextualTokens' => $maxContextualTokens,
433 'tags' => $tags,
434 ];
435
436 if ( $dimensions ) {
437 $entry['dimensions'] = $dimensions;
438 }
439
440 return $entry;
441 }
442
443 // Basic features and tags for chat models
444 $features = [ 'completion' ];
445 $tags = [ 'core', 'chat' ];
446
447 // If the name contains (beta), (alpha) or (preview), add 'preview' tag and remove from name
448 if ( preg_match( '/\((beta|alpha|preview)\)/i', $model['name'] ) ) {
449 $tags[] = 'preview';
450 $model['name'] = preg_replace( '/\((beta|alpha|preview)\)/i', '', $model['name'] );
451 }
452
453 // If model supports tools
454 if ( in_array( $model['id'], $toolsModels, true ) ) {
455 $tags[] = 'functions';
456 $features[] = 'functions';
457 }
458
459 // Check if the model supports "vision" (if "image" is in the left side of the arrow)
460 // e.g. "text+image->text" or "image->text"
461 $modality = $model['architecture']['modality'] ?? '';
462 $modality_lc = strtolower( $modality );
463 if (
464 strpos( $modality_lc, 'image->' ) !== false ||
465 strpos( $modality_lc, 'image+' ) !== false ||
466 strpos( $modality_lc, '+image->' ) !== false
467 ) {
468 // Means it can handle images as input, so we consider that "vision"
469 $tags[] = 'vision';
470 }
471
472 // Check if the model supports image generation (if "image" is in the output part after "->")
473 // e.g. "text->image" or "text+image->text+image" means it can generate images
474 $isImageGeneration = false;
475 if ( strpos( $modality_lc, '->' ) !== false ) {
476 $parts = explode( '->', $modality_lc );
477 $outputPart = $parts[1] ?? '';
478 $isImageGeneration = strpos( $outputPart, 'image' ) !== false;
479 }
480 if ( $isImageGeneration ) {
481 $features = [ 'text-to-image' ];
482 $tags = [ 'core', 'image' ];
483 }
484
485 $entry = [
486 'model' => $model['id'] ?? '',
487 'name' => trim( $model['name'] ?? '' ),
488 'family' => $family,
489 'features' => $features,
490 'price' => [
491 'in' => $priceIn,
492 'out' => $priceOut,
493 ],
494 'type' => 'token',
495 'unit' => 1 / 1000,
496 'maxCompletionTokens' => $maxCompletionTokens,
497 'maxContextualTokens' => $maxContextualTokens,
498 'tags' => $tags,
499 ];
500
501 // The "web" plugin grounds any text model, so expose Web Search on every
502 // chat model (not on image generation models).
503 if ( !$isImageGeneration ) {
504 $entry['tools'] = [ 'web_search' ];
505 }
506
507 // Add mode for image generation models
508 if ( $isImageGeneration ) {
509 $entry['mode'] = 'image';
510 }
511
512 return $entry;
513 }
514
515 /**
516 * Return an array of model IDs that support a certain feature (e.g. "tools").
517 */
518 private function get_supported_models( $feature ) {
519 // Make a request to get models supporting that feature
520 $url = 'https://openrouter.ai/api/v1/models?supported_parameters=' . urlencode( $feature );
521 $response = wp_remote_get( $url );
522 if ( is_wp_error( $response ) ) {
523 Meow_MWAI_Logging::error( "OpenRouter: Failed to retrieve models for '$feature': " . $response->get_error_message() );
524 return [];
525 }
526 $body = json_decode( $response['body'], true );
527 if ( !isset( $body['data'] ) || !is_array( $body['data'] ) ) {
528 Meow_MWAI_Logging::error( "OpenRouter: Invalid response for '$feature' models." );
529 return [];
530 }
531
532 $modelIDs = [];
533 foreach ( $body['data'] as $m ) {
534 if ( isset( $m['id'] ) ) {
535 $modelIDs[] = $m['id'];
536 }
537 }
538
539 return $modelIDs;
540 }
541
542 /**
543 * Utility function to truncate a float to a specific precision.
544 */
545 private function truncate_float( $number, $precision = 4 ) {
546 $factor = pow( 10, $precision );
547 return floor( $number * $factor ) / $factor;
548 }
549
550 /**
551 * Check the connection to OpenRouter by listing models.
552 * Uses the existing retrieve_models method for consistency.
553 */
554 public function connection_check() {
555 try {
556 // Use the existing retrieve_models method
557 $models = $this->retrieve_models();
558
559 if ( !is_array( $models ) ) {
560 throw new Exception( 'Invalid response format from OpenRouter' );
561 }
562
563 $modelCount = count( $models );
564 $availableModels = [];
565
566 // Get first 5 models for display
567 $displayModels = array_slice( $models, 0, 5 );
568 foreach ( $displayModels as $model ) {
569 if ( isset( $model['model'] ) ) {
570 $availableModels[] = $model['model'];
571 }
572 }
573
574 return [
575 'success' => true,
576 'service' => 'OpenRouter',
577 'message' => "Connection successful. Found {$modelCount} models.",
578 'details' => [
579 'endpoint' => 'https://openrouter.ai/api/v1/models',
580 'model_count' => $modelCount,
581 'sample_models' => $availableModels
582 ]
583 ];
584 }
585 catch ( Exception $e ) {
586 return [
587 'success' => false,
588 'service' => 'OpenRouter',
589 'error' => $e->getMessage(),
590 'details' => [
591 'endpoint' => 'https://openrouter.ai/api/v1/models'
592 ]
593 ];
594 }
595 }
596 }
597