PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.7.5
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.7.5
3.7.6 3.7.5 3.7.4 3.7.3 3.7.2 3.7.1 3.7.0 3.6.9 3.6.8 3.6.7 3.6.6 3.6.4 3.6.5 3.6.3 3.6.2 3.6.1 3.6.0 3.5.9 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 2 days ago chatml.php 2 days ago core.php 1 month ago custom.php 1 month ago factory.php 2 months ago google-interactions.php 1 month ago google.php 1 month ago mistral.php 2 months ago open-router.php 2 days ago openai.php 2 weeks ago ovh.php 1 month ago perplexity.php 8 months ago replicate.php 2 weeks ago xai.php 3 months ago
open-router.php
602 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 // OpenRouter maps web_search onto its own web plugin below, so it is not "dropped" here.
123 protected function supported_builtin_tools(): array {
124 return [ 'web_search' ];
125 }
126
127 protected function build_body( $query, $streamCallback = null, $extra = null ) {
128 $body = parent::build_body( $query, $streamCallback, $extra );
129 // Only add transforms and usage for chat completions, not embeddings
130 if ( !( $query instanceof Meow_MWAI_Query_Embed ) ) {
131 $body['transforms'] = ['middle-out'];
132 $body['usage'] = [ 'include' => true ];
133
134 // Web search: OpenRouter's "web" plugin works on any model (it runs the
135 // search itself and injects the results), so we only enable it on request.
136 if ( !empty( $query->tools ) && in_array( 'web_search', $query->tools, true ) ) {
137 $webPlugin = apply_filters( 'mwai_openrouter_web_plugin', [ 'id' => 'web' ], $query );
138 $body['plugins'] = array_merge( $body['plugins'] ?? [], [ $webPlugin ] );
139 }
140 }
141 else {
142 // Only OpenAI embedding models support the dimensions parameter
143 // Remove it for other providers to avoid errors
144 $model = $query->model ?? '';
145 if ( isset( $body['dimensions'] ) && strpos( $model, 'openai/' ) !== 0 ) {
146 unset( $body['dimensions'] );
147 }
148 }
149 return $body;
150 }
151
152 protected function get_service_name() {
153 return 'OpenRouter';
154 }
155
156 public function get_models() {
157 return $this->core->get_engine_models( 'openrouter' );
158 }
159
160 /**
161 * Requests usage data if streaming was used and the usage is incomplete.
162 */
163 public function handle_tokens_usage(
164 $reply,
165 $query,
166 $returned_model,
167 $returned_in_tokens,
168 $returned_out_tokens,
169 $returned_price = null
170 ) {
171 // If streaming is not enabled, we might already have all usage data
172 $everything_is_set = !is_null( $returned_model )
173 && !is_null( $returned_in_tokens )
174 && !is_null( $returned_out_tokens );
175
176 // Clean up the data
177 $returned_in_tokens = $returned_in_tokens ?? $reply->get_in_tokens( $query );
178 $returned_out_tokens = $returned_out_tokens ?? $reply->get_out_tokens();
179 $returned_price = $returned_price ?? $reply->get_price();
180
181 // Record the usage in the database
182 $usage = $this->core->record_tokens_usage(
183 $returned_model,
184 $returned_in_tokens,
185 $returned_out_tokens,
186 $returned_price
187 );
188
189 // Set the usage back on the reply
190 $reply->set_usage( $usage );
191
192 // Set accuracy based on data availability
193 if ( !is_null( $returned_price ) && !is_null( $returned_in_tokens ) && !is_null( $returned_out_tokens ) ) {
194 // OpenRouter returns price from API = full accuracy
195 $reply->set_usage_accuracy( 'full' );
196 }
197 elseif ( !is_null( $returned_in_tokens ) && !is_null( $returned_out_tokens ) ) {
198 // Tokens from API but price calculated = tokens accuracy
199 $reply->set_usage_accuracy( 'tokens' );
200 }
201 else {
202 // Everything estimated
203 $reply->set_usage_accuracy( 'estimated' );
204 }
205 }
206
207 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
208 $price = $reply->get_price();
209 return is_null( $price ) ? parent::get_price( $query, $reply ) : $price;
210 }
211
212 /**
213 * OpenRouter uses /chat/completions with modalities parameter for image generation,
214 * not the standard /images/generations endpoint.
215 */
216 public function run_image_query( $query, $streamCallback = null ) {
217 $body = [
218 'model' => $query->model,
219 'messages' => [
220 [
221 'role' => 'user',
222 'content' => $query->get_message()
223 ]
224 ],
225 'modalities' => [ 'text', 'image' ],
226 ];
227
228 // Add number of images if specified
229 if ( !empty( $query->maxResults ) && $query->maxResults > 1 ) {
230 $body['n'] = $query->maxResults;
231 }
232
233 // Add image config for Gemini models (aspect ratio support)
234 if ( !empty( $query->resolution ) && strpos( $query->model, 'google/' ) === 0 ) {
235 $body['image_config'] = [
236 'aspect_ratio' => $query->resolution
237 ];
238 }
239
240 $endpoint = apply_filters( 'mwai_openrouter_endpoint', 'https://openrouter.ai/api/v1', $this->env );
241 $url = trailingslashit( $endpoint ) . 'chat/completions';
242 $headers = $this->build_headers( $query );
243 $options = $this->build_options( $headers, $body );
244
245 try {
246 $res = $this->run_query( $url, $options );
247 $data = $res['data'];
248
249 if ( empty( $data ) || !isset( $data['choices'] ) ) {
250 throw new Exception( 'No image generated in response.' );
251 }
252
253 $reply = new Meow_MWAI_Reply( $query );
254 $reply->set_type( 'images' );
255 $images = [];
256
257 // Extract images from the response
258 foreach ( $data['choices'] as $choice ) {
259 $message = $choice['message'] ?? [];
260
261 // Check for images in the message (OpenRouter format)
262 // Each image is: { "type": "image_url", "image_url": { "url": "data:image/png;base64,..." } }
263 if ( isset( $message['images'] ) && is_array( $message['images'] ) ) {
264 foreach ( $message['images'] as $image ) {
265 if ( is_array( $image ) && isset( $image['image_url']['url'] ) ) {
266 $images[] = [ 'url' => $image['image_url']['url'] ];
267 }
268 elseif ( is_array( $image ) && isset( $image['image_url'] ) && is_string( $image['image_url'] ) ) {
269 $images[] = [ 'url' => $image['image_url'] ];
270 }
271 elseif ( is_string( $image ) ) {
272 // Direct base64 string
273 $images[] = [ 'url' => $image ];
274 }
275 }
276 }
277
278 // Also check content array for image parts
279 if ( isset( $message['content'] ) && is_array( $message['content'] ) ) {
280 foreach ( $message['content'] as $part ) {
281 if ( isset( $part['type'] ) && $part['type'] === 'image_url' ) {
282 if ( isset( $part['image_url']['url'] ) ) {
283 $images[] = [ 'url' => $part['image_url']['url'] ];
284 }
285 elseif ( is_string( $part['image_url'] ) ) {
286 $images[] = [ 'url' => $part['image_url'] ];
287 }
288 }
289 }
290 }
291 }
292
293 if ( empty( $images ) ) {
294 throw new Exception( 'No images found in the response.' );
295 }
296
297 // Record usage
298 $model = $query->model;
299 $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';
300
301 if ( isset( $data['usage'] ) ) {
302 $usage = $data['usage'];
303 $promptTokens = $usage['prompt_tokens'] ?? 0;
304 $completionTokens = $usage['completion_tokens'] ?? 0;
305 $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
306 $usage['queries'] = 1;
307 $usage['accuracy'] = 'tokens';
308 $reply->set_usage( $usage );
309 $reply->set_usage_accuracy( 'tokens' );
310 }
311 else {
312 $usage = $this->core->record_images_usage( $model, $resolution, count( $images ) );
313 $reply->set_usage( $usage );
314 $reply->set_usage_accuracy( 'estimated' );
315 }
316
317 $reply->set_choices( $images );
318
319 // Handle local download if enabled
320 if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
321 foreach ( $reply->results as &$result ) {
322 $fileId = $this->core->files->upload_file( $result, null, 'generated', [
323 'query_envId' => $query->envId,
324 'query_session' => $query->session,
325 'query_model' => $query->model,
326 ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
327 $fileUrl = $this->core->files->get_url( $fileId );
328 $result = $fileUrl;
329 }
330 }
331
332 $reply->result = $reply->results[0];
333 return $reply;
334 }
335 catch ( Exception $e ) {
336 Meow_MWAI_Logging::error( 'OpenRouter: ' . $e->getMessage() );
337 throw new Exception( 'OpenRouter: ' . $e->getMessage() );
338 }
339 }
340
341 /**
342 * Retrieve the models from OpenRouter, adding tags/features accordingly.
343 */
344 public function retrieve_models() {
345
346 // 1. Get the list of models supporting "tools"
347 $toolsModels = $this->get_supported_models( 'tools' );
348
349 // 2. Retrieve the full list of chat models
350 $url = 'https://openrouter.ai/api/v1/models';
351 $response = wp_remote_get( $url );
352 if ( is_wp_error( $response ) ) {
353 throw new Exception( 'AI Engine: ' . $response->get_error_message() );
354 }
355 $body = json_decode( $response['body'], true );
356 if ( !isset( $body['data'] ) || !is_array( $body['data'] ) ) {
357 throw new Exception( 'AI Engine: Invalid response for the list of models.' );
358 }
359
360 $models = [];
361 foreach ( $body['data'] as $model ) {
362 $models[] = $this->build_model_entry( $model, $toolsModels );
363 }
364
365 // 3. Retrieve embedding models
366 $embeddingsUrl = 'https://openrouter.ai/api/v1/embeddings/models';
367 $embeddingsResponse = wp_remote_get( $embeddingsUrl );
368 if ( !is_wp_error( $embeddingsResponse ) ) {
369 $embeddingsBody = json_decode( $embeddingsResponse['body'], true );
370 if ( isset( $embeddingsBody['data'] ) && is_array( $embeddingsBody['data'] ) ) {
371 foreach ( $embeddingsBody['data'] as $model ) {
372 $models[] = $this->build_model_entry( $model, [], true );
373 }
374 }
375 }
376
377 return $models;
378 }
379
380 /**
381 * Build a model entry from OpenRouter API data.
382 */
383 private function build_model_entry( $model, $toolsModels = [], $isEmbedding = false ) {
384 // Basic defaults
385 $family = 'n/a';
386 $maxCompletionTokens = 4096;
387 $maxContextualTokens = 8096;
388 $priceIn = 0;
389 $priceOut = 0;
390
391 // Family from model ID (e.g. "openai/gpt-4/32k" -> "openai")
392 if ( isset( $model['id'] ) ) {
393 $parts = explode( '/', $model['id'] );
394 $family = $parts[0] ?? 'n/a';
395 }
396
397 // maxCompletionTokens
398 if ( isset( $model['top_provider']['max_completion_tokens'] ) ) {
399 $maxCompletionTokens = (int) $model['top_provider']['max_completion_tokens'];
400 }
401
402 // maxContextualTokens
403 if ( isset( $model['context_length'] ) ) {
404 $maxContextualTokens = (int) $model['context_length'];
405 }
406
407 // Pricing
408 if ( isset( $model['pricing']['prompt'] ) && $model['pricing']['prompt'] > 0 ) {
409 $priceIn = $this->truncate_float( floatval( $model['pricing']['prompt'] ) * 1000 );
410 }
411 if ( isset( $model['pricing']['completion'] ) && $model['pricing']['completion'] > 0 ) {
412 $priceOut = $this->truncate_float( floatval( $model['pricing']['completion'] ) * 1000 );
413 }
414
415 // Handle embedding models
416 if ( $isEmbedding ) {
417 $features = [ 'embeddings' ];
418 $tags = [ 'core', 'embedding' ];
419
420 // Try to extract dimensions from description
421 $dimensions = null;
422 if ( isset( $model['description'] ) && preg_match( '/(\d+)-dimensional/', $model['description'], $matches ) ) {
423 $dimensions = (int) $matches[1];
424 }
425
426 $entry = [
427 'model' => $model['id'] ?? '',
428 'name' => trim( $model['name'] ?? '' ),
429 'family' => $family,
430 'features' => $features,
431 'price' => [
432 'in' => $priceIn,
433 'out' => $priceOut,
434 ],
435 'type' => 'token',
436 'unit' => 1 / 1000,
437 'maxContextualTokens' => $maxContextualTokens,
438 'tags' => $tags,
439 ];
440
441 if ( $dimensions ) {
442 $entry['dimensions'] = $dimensions;
443 }
444
445 return $entry;
446 }
447
448 // Basic features and tags for chat models
449 $features = [ 'completion' ];
450 $tags = [ 'core', 'chat' ];
451
452 // If the name contains (beta), (alpha) or (preview), add 'preview' tag and remove from name
453 if ( preg_match( '/\((beta|alpha|preview)\)/i', $model['name'] ) ) {
454 $tags[] = 'preview';
455 $model['name'] = preg_replace( '/\((beta|alpha|preview)\)/i', '', $model['name'] );
456 }
457
458 // If model supports tools
459 if ( in_array( $model['id'], $toolsModels, true ) ) {
460 $tags[] = 'functions';
461 $features[] = 'functions';
462 }
463
464 // Check if the model supports "vision" (if "image" is in the left side of the arrow)
465 // e.g. "text+image->text" or "image->text"
466 $modality = $model['architecture']['modality'] ?? '';
467 $modality_lc = strtolower( $modality );
468 if (
469 strpos( $modality_lc, 'image->' ) !== false ||
470 strpos( $modality_lc, 'image+' ) !== false ||
471 strpos( $modality_lc, '+image->' ) !== false
472 ) {
473 // Means it can handle images as input, so we consider that "vision"
474 $tags[] = 'vision';
475 }
476
477 // Check if the model supports image generation (if "image" is in the output part after "->")
478 // e.g. "text->image" or "text+image->text+image" means it can generate images
479 $isImageGeneration = false;
480 if ( strpos( $modality_lc, '->' ) !== false ) {
481 $parts = explode( '->', $modality_lc );
482 $outputPart = $parts[1] ?? '';
483 $isImageGeneration = strpos( $outputPart, 'image' ) !== false;
484 }
485 if ( $isImageGeneration ) {
486 $features = [ 'text-to-image' ];
487 $tags = [ 'core', 'image' ];
488 }
489
490 $entry = [
491 'model' => $model['id'] ?? '',
492 'name' => trim( $model['name'] ?? '' ),
493 'family' => $family,
494 'features' => $features,
495 'price' => [
496 'in' => $priceIn,
497 'out' => $priceOut,
498 ],
499 'type' => 'token',
500 'unit' => 1 / 1000,
501 'maxCompletionTokens' => $maxCompletionTokens,
502 'maxContextualTokens' => $maxContextualTokens,
503 'tags' => $tags,
504 ];
505
506 // The "web" plugin grounds any text model, so expose Web Search on every
507 // chat model (not on image generation models).
508 if ( !$isImageGeneration ) {
509 $entry['tools'] = [ 'web_search' ];
510 }
511
512 // Add mode for image generation models
513 if ( $isImageGeneration ) {
514 $entry['mode'] = 'image';
515 }
516
517 return $entry;
518 }
519
520 /**
521 * Return an array of model IDs that support a certain feature (e.g. "tools").
522 */
523 private function get_supported_models( $feature ) {
524 // Make a request to get models supporting that feature
525 $url = 'https://openrouter.ai/api/v1/models?supported_parameters=' . urlencode( $feature );
526 $response = wp_remote_get( $url );
527 if ( is_wp_error( $response ) ) {
528 Meow_MWAI_Logging::error( "OpenRouter: Failed to retrieve models for '$feature': " . $response->get_error_message() );
529 return [];
530 }
531 $body = json_decode( $response['body'], true );
532 if ( !isset( $body['data'] ) || !is_array( $body['data'] ) ) {
533 Meow_MWAI_Logging::error( "OpenRouter: Invalid response for '$feature' models." );
534 return [];
535 }
536
537 $modelIDs = [];
538 foreach ( $body['data'] as $m ) {
539 if ( isset( $m['id'] ) ) {
540 $modelIDs[] = $m['id'];
541 }
542 }
543
544 return $modelIDs;
545 }
546
547 /**
548 * Utility function to truncate a float to a specific precision.
549 */
550 private function truncate_float( $number, $precision = 4 ) {
551 $factor = pow( 10, $precision );
552 return floor( $number * $factor ) / $factor;
553 }
554
555 /**
556 * Check the connection to OpenRouter by listing models.
557 * Uses the existing retrieve_models method for consistency.
558 */
559 public function connection_check() {
560 try {
561 // Use the existing retrieve_models method
562 $models = $this->retrieve_models();
563
564 if ( !is_array( $models ) ) {
565 throw new Exception( 'Invalid response format from OpenRouter' );
566 }
567
568 $modelCount = count( $models );
569 $availableModels = [];
570
571 // Get first 5 models for display
572 $displayModels = array_slice( $models, 0, 5 );
573 foreach ( $displayModels as $model ) {
574 if ( isset( $model['model'] ) ) {
575 $availableModels[] = $model['model'];
576 }
577 }
578
579 return [
580 'success' => true,
581 'service' => 'OpenRouter',
582 'message' => "Connection successful. Found {$modelCount} models.",
583 'details' => [
584 'endpoint' => 'https://openrouter.ai/api/v1/models',
585 'model_count' => $modelCount,
586 'sample_models' => $availableModels
587 ]
588 ];
589 }
590 catch ( Exception $e ) {
591 return [
592 'success' => false,
593 'service' => 'OpenRouter',
594 'error' => $e->getMessage(),
595 'details' => [
596 'endpoint' => 'https://openrouter.ai/api/v1/models'
597 ]
598 ];
599 }
600 }
601 }
602