'GET', 'callback' => array( static::class, 'list_connectors' ), 'permission_callback' => array( static::class, 'check_permission' ), ) ); \register_rest_route( 'agentic-admin/v1', '/connectors/chat/completions', array( 'methods' => 'POST', 'callback' => array( static::class, 'chat_completion' ), 'permission_callback' => array( static::class, 'check_permission' ), 'args' => array( 'connector_id' => array( 'required' => true, 'type' => 'string', ), 'model_id' => array( 'required' => false, 'type' => 'string', 'default' => '', ), 'messages' => array( 'required' => true, 'type' => 'array', ), ), ) ); } /** * Admin-only permission check. * * @return bool */ public static function check_permission(): bool { return \current_user_can( 'manage_options' ); } /** * GET /v1/connectors — list AI provider connectors. * * @return \WP_REST_Response */ public static function list_connectors(): \WP_REST_Response { $out = array( 'wp_supports_connectors' => function_exists( '\\wp_get_connectors' ), 'options_url' => \admin_url( 'options-connectors.php' ), 'connectors' => array(), ); if ( ! function_exists( '\\wp_get_connectors' ) ) { return new \WP_REST_Response( $out, 200 ); } $all = \wp_get_connectors(); $ai_registry = null; if ( class_exists( '\\WordPress\\AiClient\\AiClient' ) ) { try { $ai_registry = \WordPress\AiClient\AiClient::defaultRegistry(); } catch ( \Exception $e ) { $ai_registry = null; } } foreach ( $all as $id => $data ) { if ( ! isset( $data['type'] ) || 'ai_provider' !== $data['type'] ) { continue; } $is_connected = self::is_connector_configured( $id ); $models = array(); if ( $is_connected && null !== $ai_registry ) { try { $provider_class = $ai_registry->getProviderClassName( $id ); if ( $provider_class && method_exists( $provider_class, 'modelMetadataDirectory' ) ) { $directory = $provider_class::modelMetadataDirectory(); foreach ( $directory->listModelMetadata() as $meta ) { $models[] = array( 'id' => $meta->getId(), 'name' => $meta->getName(), ); } } } catch ( \Exception $e ) { $models = array(); } } $out['connectors'][] = array( 'id' => $id, 'name' => $data['name'] ?? $id, 'description' => $data['description'] ?? '', 'logo_url' => $data['logo_url'] ?? null, 'is_connected' => $is_connected, 'models' => $models, ); } return new \WP_REST_Response( $out, 200 ); } /** * Whether a connector's provider is configured in the AI Client. * * Uses the same check as the core Connectors screen * (hasProvider() && isProviderConfigured()), so the plugin never reads * API keys itself. isProviderConfigured() can briefly report a configured * provider as unconfigured, which made the connector list flap, so a * positive result is remembered for a few minutes. Only the boolean is * cached, never a credential. * * @param string $connector_id Connector / provider ID. * @return bool True when the provider is configured. */ private static function is_connector_configured( string $connector_id ): bool { if ( '' === $connector_id || ! class_exists( '\\WordPress\\AiClient\\AiClient' ) ) { return false; } $cache_key = 'agentic_admin_conn_ok_' . md5( $connector_id ); if ( get_transient( $cache_key ) ) { return true; } try { $registry = \WordPress\AiClient\AiClient::defaultRegistry(); $configured = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id ); } catch ( \Exception $e ) { $configured = false; } if ( $configured ) { set_transient( $cache_key, 1, 5 * MINUTE_IN_SECONDS ); } return $configured; } /** * POST /v1/connectors/chat/completions — proxy a chat completion through * the AI Client to a configured connector's provider. * * KNOWN LIMITATIONS (documented for honesty, will be addressed in follow-up): * - Non-streaming only. AI Client doesn't expose a streaming API for * text generation, so this returns the full assistant message at once. * - No tool/function calling support yet. * * Guards: enforces MAX_MESSAGES, MAX_TOTAL_CHARS, and a per-user * RATE_LIMIT_PER_MINUTE via a transient. These cap potential paid- * provider spend triggered through this endpoint. * * @param \WP_REST_Request $request REST request. * @return \WP_REST_Response|\WP_Error */ public static function chat_completion( \WP_REST_Request $request ) { if ( ! class_exists( '\\WordPress\\AiClient\\AiClient' ) ) { return new \WP_Error( 'agentic_admin_no_ai_client', 'WP AI Client is not available (requires WordPress 7.0+).', array( 'status' => 501 ) ); } $connector_id = (string) $request->get_param( 'connector_id' ); $model_id = (string) $request->get_param( 'model_id' ); $messages = (array) $request->get_param( 'messages' ); if ( '' === $connector_id || empty( $messages ) ) { return new \WP_Error( 'agentic_admin_bad_request', 'connector_id and messages are required.', array( 'status' => 400 ) ); } if ( count( $messages ) > self::MAX_MESSAGES ) { return new \WP_Error( 'agentic_admin_too_many_messages', sprintf( 'Too many messages: limit is %d per request.', self::MAX_MESSAGES ), array( 'status' => 413 ) ); } $total_chars = 0; foreach ( $messages as $msg ) { if ( is_array( $msg ) && isset( $msg['content'] ) ) { $total_chars += strlen( (string) $msg['content'] ); } } if ( $total_chars > self::MAX_TOTAL_CHARS ) { return new \WP_Error( 'agentic_admin_payload_too_large', sprintf( 'Combined message content exceeds %d characters.', self::MAX_TOTAL_CHARS ), array( 'status' => 413 ) ); } $rate_limit_error = self::check_rate_limit(); if ( null !== $rate_limit_error ) { return $rate_limit_error; } try { $registry = \WordPress\AiClient\AiClient::defaultRegistry(); if ( ! $registry->hasProvider( $connector_id ) ) { return new \WP_Error( 'agentic_admin_unknown_connector', sprintf( 'Connector "%s" is not registered with the AI Client.', $connector_id ), array( 'status' => 404 ) ); } if ( ! self::is_connector_configured( $connector_id ) ) { return new \WP_Error( 'agentic_admin_unconfigured_connector', sprintf( 'Connector "%s" is not configured (missing API key).', $connector_id ), array( 'status' => 400 ) ); } // Resolve a specific model if requested; fall back to AI Client // auto-discovery if none provided or resolution fails. $model = null; if ( '' !== $model_id ) { try { $provider = $registry->getProviderModel( $connector_id, $model_id ); $model = $provider; } catch ( \Exception $e ) { $model = null; } } $result = self::generate_with_structured_messages( $messages, $model ); $text = method_exists( $result, 'toText' ) ? $result->toText() : (string) $result; // Return an OpenAI-shaped non-streaming response so the existing // chat orchestrator code path can consume it without changes. $response = array( 'id' => 'chatcmpl-' . wp_generate_uuid4(), 'object' => 'chat.completion', 'created' => time(), 'model' => $connector_id, 'choices' => array( array( 'index' => 0, 'message' => array( 'role' => 'assistant', 'content' => $text, ), 'finish_reason' => 'stop', ), ), ); return new \WP_REST_Response( $response, 200 ); } catch ( \Exception $e ) { $message = $e->getMessage(); // WP 7.0's AI connector approval gate blocks a plugin from using a // connector until the site admin approves it. Turn that low-level // error into an actionable message pointing to the approval screen. if ( false !== stripos( $message, 'not been approved' ) ) { return new \WP_Error( 'agentic_admin_connector_not_approved', sprintf( /* translators: %s: connector name, e.g. "anthropic". */ __( 'Agentic Admin needs your approval to use the "%s" connector. Approve it under Tools → AI Connector Approval, then try again.', 'agentic-admin' ), $connector_id ), array( 'status' => 403, 'approval_url' => \admin_url( 'tools.php?page=ai-connector-approval' ), ) ); } return new \WP_Error( 'agentic_admin_connector_error', $message, array( 'status' => 500 ) ); } } /** * Generate a text result through the AI Client using structured * roles so the connector can preserve user / assistant turns and * pass any system instruction as a top-level model directive. * * Falls back to a single concatenated prompt only if the AI Client * structural builder isn't available at runtime. * * @param array> $messages OpenAI-style chat messages. * @param mixed|null $model Optional resolved AI Client model. * @return mixed The generated AI Client text result. */ private static function generate_with_structured_messages( array $messages, $model ) { $system_instructions = array(); $turns = array(); foreach ( $messages as $msg ) { if ( ! is_array( $msg ) || ! isset( $msg['content'] ) ) { continue; } $role = isset( $msg['role'] ) ? (string) $msg['role'] : 'user'; $content = (string) $msg['content']; if ( 'system' === $role ) { $system_instructions[] = $content; continue; } $turns[] = array( 'role' => $role, 'content' => $content, ); } // History must start with a user turn (most providers — Anthropic // in particular — reject leading assistant turns). Drop synthetic // assistant-led messages like the welcome card until the first // real user message. while ( ! empty( $turns ) && 'user' !== $turns[0]['role'] ) { array_shift( $turns ); } $structural_supported = class_exists( '\\WordPress\\AiClient\\AiClient' ) && class_exists( '\\WordPress\\AiClient\\Messages\\DTO\\Message' ) && class_exists( '\\WordPress\\AiClient\\Messages\\DTO\\MessagePart' ) && class_exists( '\\WordPress\\AiClient\\Messages\\Enums\\MessageRoleEnum' ); if ( ! $structural_supported || empty( $turns ) ) { // Final fallback: lossy concatenation. Only reached when the // AI Client builder classes are missing or the message list // had no user/assistant turns at all. $prompt = self::flatten_messages_lossy( array_merge( array_map( static function ( $s ) { return array( 'role' => 'system', 'content' => $s, ); }, $system_instructions ), $turns ) ); return null === $model ? \WordPress\AiClient\AiClient::generateTextResult( $prompt ) : \WordPress\AiClient\AiClient::generateTextResult( $prompt, $model ); } $role_enum = '\\WordPress\\AiClient\\Messages\\Enums\\MessageRoleEnum'; $messages_dto = array(); foreach ( $turns as $turn ) { $enum_role = 'assistant' === $turn['role'] ? $role_enum::model() : $role_enum::user(); $messages_dto[] = new \WordPress\AiClient\Messages\DTO\Message( $enum_role, array( new \WordPress\AiClient\Messages\DTO\MessagePart( $turn['content'] ) ) ); } // Use the latest turn as the active prompt and pass the rest as history, // matching PromptBuilder's expected shape. $latest = array_pop( $messages_dto ); $history = $messages_dto; $builder = \WordPress\AiClient\AiClient::prompt(); foreach ( $latest->getParts() as $part ) { $builder = $builder->withMessageParts( $part ); } if ( ! empty( $history ) ) { $builder = $builder->withHistory( ...$history ); } if ( ! empty( $system_instructions ) ) { $builder = $builder->usingSystemInstruction( implode( "\n\n", $system_instructions ) ); } if ( null !== $model ) { $builder = $builder->usingModel( $model ); } return $builder->generateTextResult(); } /** * Concatenate messages into a single prompt as a last-resort fallback * when the structural Message DTOs are unavailable. * * @param array $messages OpenAI-style chat messages. * @return string */ private static function flatten_messages_lossy( array $messages ): string { $lines = array(); foreach ( $messages as $msg ) { if ( ! is_array( $msg ) || ! isset( $msg['content'] ) ) { continue; } $role = isset( $msg['role'] ) ? ucfirst( (string) $msg['role'] ) : 'User'; $lines[] = $role . ': ' . (string) $msg['content']; } return implode( "\n\n", $lines ); } /** * Per-user rate limit for chat completions. * * Caps at RATE_LIMIT_PER_MINUTE requests per user per clock minute. * The counter is a row in the options table, incremented with a single * INSERT ... ON DUPLICATE KEY UPDATE statement. The database applies it * atomically, so concurrent requests can't read the same count and slip * past the limit. Returns a WP_Error 429 when the cap is exceeded, null * otherwise. * * @return \WP_Error|null */ private static function check_rate_limit(): ?\WP_Error { global $wpdb; $user_id = \get_current_user_id(); if ( ! $user_id ) { return null; } $prefix = 'agentic_admin_conn_rl_' . $user_id . '_'; $key = $prefix . (int) floor( time() / MINUTE_IN_SECONDS ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic counter; a cached value would defeat it. $wpdb->query( $wpdb->prepare( "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, '1', 'off') ON DUPLICATE KEY UPDATE option_value = option_value + 1", $key ) ); $count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $key ) ); // Drop this user's counters from earlier minutes. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND option_name <> %s", $wpdb->esc_like( $prefix ) . '%', $key ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching if ( $count > self::RATE_LIMIT_PER_MINUTE ) { return new \WP_Error( 'agentic_admin_rate_limited', sprintf( 'Connector chat completions are rate-limited to %d requests per minute.', self::RATE_LIMIT_PER_MINUTE ), array( 'status' => 429 ) ); } return null; } }