diff --git a/src/js/_enqueues/wp/ai-client/builders/prompt-builder.js b/src/js/_enqueues/wp/ai-client/builders/prompt-builder.js new file mode 100644 index 0000000000000..0b0280e082b75 --- /dev/null +++ b/src/js/_enqueues/wp/ai-client/builders/prompt-builder.js @@ -0,0 +1,833 @@ +/** + * PromptBuilder for client-side AI prompting. + * + * @since 7.0.0 + * + * @package WordPress + * @subpackage AI + */ + +import apiFetch from '@wordpress/api-fetch'; +import { + Capability, + MessagePartChannel, + MessagePartType, + MessageRole, + Modality, +} from '../enums'; +import { File } from '../files/file'; +import { GenerativeAiResult } from '../results/generative-ai-result'; + +/** + * Fluent builder for constructing AI prompts. + * + * @since 7.0.0 + */ +export class PromptBuilder { + /** + * Constructor. + * + * @since 7.0.0 + * + * @param {string|Object|Array} [promptInput] Optional initial prompt content. + */ + constructor( promptInput ) { + this.messages = []; + this.modelConfig = {}; + this.providerId = undefined; + this.modelId = undefined; + this.modelPreferences = []; + this.requestOptions = undefined; + + if ( promptInput ) { + if ( this._isMessagesList( promptInput ) ) { + this.messages = promptInput; + } else { + this.messages.push( + this._parseMessage( promptInput, MessageRole.USER ) + ); + } + } + } + + /** + * Adds text to the current message. + * + * @since 7.0.0 + * + * @param {string} text The text to add. + * @return {PromptBuilder} this + */ + withText( text ) { + const part = { + channel: MessagePartChannel.CONTENT, + type: MessagePartType.TEXT, + text, + }; + this._appendPartToMessages( part ); + return this; + } + + /** + * Adds a file to the current message. + * + * @since 7.0.0 + * + * @param {Object} file The file object. + * @return {PromptBuilder} this + */ + withFile( file ) { + const part = { + channel: MessagePartChannel.CONTENT, + type: MessagePartType.FILE, + file, + }; + this._appendPartToMessages( part ); + return this; + } + + /** + * Adds a function response to the current message. + * + * @since 7.0.0 + * + * @param {Object} functionResponse The function response. + * @return {PromptBuilder} this + */ + withFunctionResponse( functionResponse ) { + const part = { + channel: MessagePartChannel.CONTENT, + type: MessagePartType.FUNCTION_RESPONSE, + functionResponse, + }; + this._appendPartToMessages( part ); + return this; + } + + /** + * Adds message parts to the current message. + * + * @since 7.0.0 + * + * @param {...Object} parts The message parts to add. + * @return {PromptBuilder} this + */ + withMessageParts( ...parts ) { + for ( const part of parts ) { + this._appendPartToMessages( part ); + } + return this; + } + + /** + * Adds history messages to the conversation. + * + * @since 7.0.0 + * + * @param {...Object} messages The messages to add. + * @return {PromptBuilder} this + */ + withHistory( ...messages ) { + this.messages.push( ...messages ); + return this; + } + + /** + * Sets the model to use. + * + * @since 7.0.0 + * + * @param {string} providerId The provider ID. + * @param {string} modelId The model ID. + * @return {PromptBuilder} this + */ + usingModel( providerId, modelId ) { + this.providerId = providerId; + this.modelId = modelId; + return this; + } + + /** + * Sets the model preferences. + * + * @since 7.0.0 + * + * @param {...(string|Array)} preferredModels The preferred models. + * @return {PromptBuilder} this + */ + usingModelPreference( ...preferredModels ) { + this.modelPreferences = preferredModels; + return this; + } + + /** + * Merges the provided model configuration. + * + * @since 7.0.0 + * + * @param {Object} config The model configuration to merge. + * @return {PromptBuilder} this + */ + usingModelConfig( config ) { + this.modelConfig = { ...this.modelConfig, ...config }; + return this; + } + + /** + * Sets the provider to use. + * + * @since 7.0.0 + * + * @param {string} providerId The provider ID. + * @return {PromptBuilder} this + */ + usingProvider( providerId ) { + this.providerId = providerId; + return this; + } + + /** + * Sets the system instruction. + * + * @since 7.0.0 + * + * @param {string} systemInstruction The system instruction. + * @return {PromptBuilder} this + */ + usingSystemInstruction( systemInstruction ) { + this.modelConfig.systemInstruction = systemInstruction; + return this; + } + + /** + * Sets the max tokens. + * + * @since 7.0.0 + * + * @param {number} maxTokens The max tokens. + * @return {PromptBuilder} this + */ + usingMaxTokens( maxTokens ) { + this.modelConfig.maxTokens = maxTokens; + return this; + } + + /** + * Sets the temperature. + * + * @since 7.0.0 + * + * @param {number} temperature The temperature. + * @return {PromptBuilder} this + */ + usingTemperature( temperature ) { + this.modelConfig.temperature = temperature; + return this; + } + + /** + * Sets the top P. + * + * @since 7.0.0 + * + * @param {number} topP The top P. + * @return {PromptBuilder} this + */ + usingTopP( topP ) { + this.modelConfig.topP = topP; + return this; + } + + /** + * Sets the top K. + * + * @since 7.0.0 + * + * @param {number} topK The top K. + * @return {PromptBuilder} this + */ + usingTopK( topK ) { + this.modelConfig.topK = topK; + return this; + } + + /** + * Sets the stop sequences. + * + * @since 7.0.0 + * + * @param {...string} stopSequences The stop sequences. + * @return {PromptBuilder} this + */ + usingStopSequences( ...stopSequences ) { + const current = this.modelConfig.stopSequences || []; + this.modelConfig.stopSequences = [ ...current, ...stopSequences ]; + return this; + } + + /** + * Sets the candidate count. + * + * @since 7.0.0 + * + * @param {number} candidateCount The candidate count. + * @return {PromptBuilder} this + */ + usingCandidateCount( candidateCount ) { + this.modelConfig.candidateCount = candidateCount; + return this; + } + + /** + * Sets the function declarations. + * + * @since 7.0.0 + * + * @param {...Object} functionDeclarations The function declarations. + * @return {PromptBuilder} this + */ + usingFunctionDeclarations( ...functionDeclarations ) { + const current = this.modelConfig.functionDeclarations || []; + this.modelConfig.functionDeclarations = [ + ...current, + ...functionDeclarations, + ]; + return this; + } + + /** + * Sets the presence penalty. + * + * @since 7.0.0 + * + * @param {number} presencePenalty The presence penalty. + * @return {PromptBuilder} this + */ + usingPresencePenalty( presencePenalty ) { + this.modelConfig.presencePenalty = presencePenalty; + return this; + } + + /** + * Sets the frequency penalty. + * + * @since 7.0.0 + * + * @param {number} frequencyPenalty The frequency penalty. + * @return {PromptBuilder} this + */ + usingFrequencyPenalty( frequencyPenalty ) { + this.modelConfig.frequencyPenalty = frequencyPenalty; + return this; + } + + /** + * Sets the web search configuration. + * + * @since 7.0.0 + * + * @param {Object} webSearch The web search configuration. + * @return {PromptBuilder} this + */ + usingWebSearch( webSearch ) { + this.modelConfig.webSearch = webSearch; + return this; + } + + /** + * Sets the request options. + * + * @since 7.0.0 + * + * @param {Object} requestOptions The request options. + * @return {PromptBuilder} this + */ + usingRequestOptions( requestOptions ) { + this.requestOptions = requestOptions; + return this; + } + + /** + * Sets the top logprobs. + * + * @since 7.0.0 + * + * @param {number} [topLogprobs] The top logprobs. + * @return {PromptBuilder} this + */ + usingTopLogprobs( topLogprobs ) { + if ( topLogprobs !== undefined ) { + this.modelConfig.topLogprobs = topLogprobs; + this.modelConfig.logprobs = true; + } else { + this.modelConfig.logprobs = true; + } + return this; + } + + /** + * Sets the output MIME type. + * + * @since 7.0.0 + * + * @param {string} mimeType The MIME type. + * @return {PromptBuilder} this + */ + asOutputMimeType( mimeType ) { + this.modelConfig.outputMimeType = mimeType; + return this; + } + + /** + * Sets the output schema. + * + * @since 7.0.0 + * + * @param {Object} schema The output schema. + * @return {PromptBuilder} this + */ + asOutputSchema( schema ) { + this.modelConfig.outputSchema = schema; + return this; + } + + /** + * Sets the output modalities. + * + * @since 7.0.0 + * + * @param {...string} modalities The output modalities. + * @return {PromptBuilder} this + */ + asOutputModalities( ...modalities ) { + this._includeOutputModalities( ...modalities ); + return this; + } + + /** + * Sets the output file type. + * + * @since 7.0.0 + * + * @param {string} fileType The output file type. + * @return {PromptBuilder} this + */ + asOutputFileType( fileType ) { + this.modelConfig.outputFileType = fileType; + return this; + } + + /** + * Configures the response as JSON. + * + * @since 7.0.0 + * + * @param {Object} [schema] Optional schema for the JSON response. + * @return {PromptBuilder} this + */ + asJsonResponse( schema ) { + this.asOutputMimeType( 'application/json' ); + if ( schema ) { + this.asOutputSchema( schema ); + } + return this; + } + + /** + * Checks if the current prompt is supported by the selected model. + * + * @since 7.0.0 + * + * @param {string} [capability] Optional capability to check support for. + * @return {Promise} True if supported. + */ + async isSupported( capability ) { + const response = await apiFetch( { + path: '/wp-ai/v1/is-supported', + method: 'POST', + data: { + messages: this.messages, + modelConfig: this.modelConfig, + providerId: this.providerId, + modelId: this.modelId, + modelPreferences: this.modelPreferences, + capability, + requestOptions: this.requestOptions, + }, + } ); + + return response.supported; + } + + /** + * Checks if the prompt is supported for text generation. + * + * @since 7.0.0 + * + * @return {Promise} True if text generation is supported. + */ + async isSupportedForTextGeneration() { + return this.isSupported( Capability.TEXT_GENERATION ); + } + + /** + * Checks if the prompt is supported for image generation. + * + * @since 7.0.0 + * + * @return {Promise} True if image generation is supported. + */ + async isSupportedForImageGeneration() { + return this.isSupported( Capability.IMAGE_GENERATION ); + } + + /** + * Checks if the prompt is supported for text to speech conversion. + * + * @since 7.0.0 + * + * @return {Promise} True if text to speech conversion is supported. + */ + async isSupportedForTextToSpeechConversion() { + return this.isSupported( Capability.TEXT_TO_SPEECH_CONVERSION ); + } + + /** + * Checks if the prompt is supported for video generation. + * + * @since 7.0.0 + * + * @return {Promise} True if video generation is supported. + */ + async isSupportedForVideoGeneration() { + return this.isSupported( Capability.VIDEO_GENERATION ); + } + + /** + * Checks if the prompt is supported for speech generation. + * + * @since 7.0.0 + * + * @return {Promise} True if speech generation is supported. + */ + async isSupportedForSpeechGeneration() { + return this.isSupported( Capability.SPEECH_GENERATION ); + } + + /** + * Checks if the prompt is supported for music generation. + * + * @since 7.0.0 + * + * @return {Promise} True if music generation is supported. + */ + async isSupportedForMusicGeneration() { + return this.isSupported( Capability.MUSIC_GENERATION ); + } + + /** + * Checks if the prompt is supported for embedding generation. + * + * @since 7.0.0 + * + * @return {Promise} True if embedding generation is supported. + */ + async isSupportedForEmbeddingGeneration() { + return this.isSupported( Capability.EMBEDDING_GENERATION ); + } + + /** + * Generates a result using the configured model and prompt. + * + * @since 7.0.0 + * + * @param {string} [capability] Optional capability to use. + * @return {Promise} The generation result. + */ + async generateResult( capability ) { + const result = await apiFetch( { + path: '/wp-ai/v1/generate', + method: 'POST', + data: { + messages: this.messages, + modelConfig: this.modelConfig, + providerId: this.providerId, + modelId: this.modelId, + modelPreferences: this.modelPreferences, + capability, + requestOptions: this.requestOptions, + }, + } ); + + return new GenerativeAiResult( result ); + } + + /** + * Generates a text result. + * + * @since 7.0.0 + * + * @return {Promise} The generation result. + */ + async generateTextResult() { + this._includeOutputModalities( Modality.TEXT ); + return this.generateResult( Capability.TEXT_GENERATION ); + } + + /** + * Generates an image result. + * + * @since 7.0.0 + * + * @return {Promise} The generation result. + */ + async generateImageResult() { + this._includeOutputModalities( Modality.IMAGE ); + return this.generateResult( Capability.IMAGE_GENERATION ); + } + + /** + * Generates a speech result. + * + * @since 7.0.0 + * + * @return {Promise} The generation result. + */ + async generateSpeechResult() { + this._includeOutputModalities( Modality.AUDIO ); + return this.generateResult( Capability.SPEECH_GENERATION ); + } + + /** + * Converts text to speech result. + * + * @since 7.0.0 + * + * @return {Promise} The generation result. + */ + async convertTextToSpeechResult() { + this._includeOutputModalities( Modality.AUDIO ); + return this.generateResult( Capability.TEXT_TO_SPEECH_CONVERSION ); + } + + /** + * Generates text. + * + * @since 7.0.0 + * + * @return {Promise} The generated text. + */ + async generateText() { + const result = await this.generateTextResult(); + return result.toText(); + } + + /** + * Generates multiple texts. + * + * @since 7.0.0 + * + * @param {number} [candidateCount] Optional candidate count. + * @return {Promise} The generated texts. + */ + async generateTexts( candidateCount ) { + if ( candidateCount ) { + this.usingCandidateCount( candidateCount ); + } + const result = await this.generateTextResult(); + return result.toTexts(); + } + + /** + * Generates an image. + * + * @since 7.0.0 + * + * @return {Promise} The generated image file. + */ + async generateImage() { + const result = await this.generateImageResult(); + return new File( result.toImageFile() ); + } + + /** + * Generates multiple images. + * + * @since 7.0.0 + * + * @param {number} [candidateCount] Optional candidate count. + * @return {Promise} The generated image files. + */ + async generateImages( candidateCount ) { + if ( candidateCount ) { + this.usingCandidateCount( candidateCount ); + } + const result = await this.generateImageResult(); + return result.toImageFiles().map( ( file ) => new File( file ) ); + } + + /** + * Converts text to speech. + * + * @since 7.0.0 + * + * @return {Promise} The generated speech file. + */ + async convertTextToSpeech() { + const result = await this.convertTextToSpeechResult(); + return new File( result.toAudioFile() ); + } + + /** + * Converts text to multiple speeches. + * + * @since 7.0.0 + * + * @param {number} [candidateCount] Optional candidate count. + * @return {Promise} The generated speech files. + */ + async convertTextToSpeeches( candidateCount ) { + if ( candidateCount ) { + this.usingCandidateCount( candidateCount ); + } + const result = await this.convertTextToSpeechResult(); + return result.toAudioFiles().map( ( file ) => new File( file ) ); + } + + /** + * Generates speech. + * + * @since 7.0.0 + * + * @return {Promise} The generated speech file. + */ + async generateSpeech() { + const result = await this.generateSpeechResult(); + return new File( result.toAudioFile() ); + } + + /** + * Generates multiple speeches. + * + * @since 7.0.0 + * + * @param {number} [candidateCount] Optional candidate count. + * @return {Promise} The generated speech files. + */ + async generateSpeeches( candidateCount ) { + if ( candidateCount ) { + this.usingCandidateCount( candidateCount ); + } + const result = await this.generateSpeechResult(); + return result.toAudioFiles().map( ( file ) => new File( file ) ); + } + + /** + * Appends a MessagePart to the messages array. + * + * @since 7.0.0 + * + * @param {Object} part The part to append. + */ + _appendPartToMessages( part ) { + const lastMessage = this.messages[ this.messages.length - 1 ]; + + if ( lastMessage && lastMessage.role === MessageRole.USER ) { + lastMessage.parts.push( part ); + return; + } + + this.messages.push( { + role: MessageRole.USER, + parts: [ part ], + } ); + } + + /** + * Parses input into a Message. + * + * @since 7.0.0 + * + * @param {string|Object|Array} input The input to parse. + * @param {string} defaultRole The default role. + * @return {Object} The parsed message. + */ + _parseMessage( input, defaultRole ) { + if ( input && input.role && input.parts ) { + return input; + } + + if ( input && input.type ) { + return { role: defaultRole, parts: [ input ] }; + } + + if ( typeof input === 'string' ) { + if ( input.trim() === '' ) { + throw new Error( + 'Cannot create a message from an empty string.' + ); + } + return { + role: defaultRole, + parts: [ + { + channel: MessagePartChannel.CONTENT, + type: MessagePartType.TEXT, + text: input, + }, + ], + }; + } + + if ( Array.isArray( input ) ) { + if ( input.length === 0 ) { + throw new Error( + 'Cannot create a message from an empty array.' + ); + } + const parts = []; + for ( const item of input ) { + if ( typeof item === 'string' ) { + parts.push( { + channel: MessagePartChannel.CONTENT, + type: MessagePartType.TEXT, + text: item, + } ); + } else { + parts.push( item ); + } + } + return { role: defaultRole, parts }; + } + + throw new Error( 'Invalid input for message.' ); + } + + /** + * Checks if the value is a list of Message objects. + * + * @since 7.0.0 + * + * @param {*} value The value to check. + * @return {boolean} True if the value is a list of Message objects. + */ + _isMessagesList( value ) { + if ( ! Array.isArray( value ) || value.length === 0 ) { + return false; + } + return value[ 0 ].role !== undefined; + } + + /** + * Includes output modalities if not already present. + * + * @since 7.0.0 + * + * @param {...string} modalities The modalities to include. + */ + _includeOutputModalities( ...modalities ) { + const current = this.modelConfig.outputModalities || []; + const merged = Array.from( new Set( [ ...current, ...modalities ] ) ); + this.modelConfig.outputModalities = merged; + } +} diff --git a/src/js/_enqueues/wp/ai-client/enums.js b/src/js/_enqueues/wp/ai-client/enums.js new file mode 100644 index 0000000000000..81c657b9cc113 --- /dev/null +++ b/src/js/_enqueues/wp/ai-client/enums.js @@ -0,0 +1,82 @@ +/** + * Constants for PHP AI Client SDK Enums. + * + * @since 7.0.0 + * + * @package WordPress + * @subpackage AI + */ + +export const FileType = { + INLINE: 'inline', + REMOTE: 'remote', +}; + +export const MediaOrientation = { + SQUARE: 'square', + LANDSCAPE: 'landscape', + PORTRAIT: 'portrait', +}; + +export const FinishReason = { + STOP: 'stop', + LENGTH: 'length', + CONTENT_FILTER: 'content_filter', + TOOL_CALLS: 'tool_calls', + ERROR: 'error', +}; + +export const OperationState = { + STARTING: 'starting', + PROCESSING: 'processing', + SUCCEEDED: 'succeeded', + FAILED: 'failed', + CANCELED: 'canceled', +}; + +export const ToolType = { + FUNCTION_DECLARATIONS: 'function_declarations', + WEB_SEARCH: 'web_search', +}; + +export const ProviderType = { + CLOUD: 'cloud', + SERVER: 'server', + CLIENT: 'client', +}; + +export const MessagePartType = { + TEXT: 'text', + FILE: 'file', + FUNCTION_CALL: 'function_call', + FUNCTION_RESPONSE: 'function_response', +}; + +export const MessagePartChannel = { + CONTENT: 'content', + THOUGHT: 'thought', +}; + +export const Modality = { + TEXT: 'text', + DOCUMENT: 'document', + IMAGE: 'image', + AUDIO: 'audio', + VIDEO: 'video', +}; + +export const MessageRole = { + USER: 'user', + MODEL: 'model', +}; + +export const Capability = { + TEXT_GENERATION: 'text_generation', + IMAGE_GENERATION: 'image_generation', + TEXT_TO_SPEECH_CONVERSION: 'text_to_speech_conversion', + SPEECH_GENERATION: 'speech_generation', + MUSIC_GENERATION: 'music_generation', + VIDEO_GENERATION: 'video_generation', + EMBEDDING_GENERATION: 'embedding_generation', + CHAT_HISTORY: 'chat_history', +}; diff --git a/src/js/_enqueues/wp/ai-client/files/file.js b/src/js/_enqueues/wp/ai-client/files/file.js new file mode 100644 index 0000000000000..0d16153d25f4f --- /dev/null +++ b/src/js/_enqueues/wp/ai-client/files/file.js @@ -0,0 +1,183 @@ +/** + * File wrapper for AI client files. + * + * @since 7.0.0 + * + * @package WordPress + * @subpackage AI + */ + +import { FileType } from '../enums'; + +/** + * Represents a file in the AI client. + * + * @since 7.0.0 + */ +export class File { + /** + * Constructor. + * + * @since 7.0.0 + * + * @param {Object} file The raw file object. + */ + constructor( file ) { + this._file = file; + } + + /** + * Gets the type of file storage. + * + * @since 7.0.0 + * + * @return {string} The file type. + */ + get fileType() { + return this._file.fileType; + } + + /** + * Gets the MIME type of the file. + * + * @since 7.0.0 + * + * @return {string} The MIME type. + */ + get mimeType() { + return this._file.mimeType; + } + + /** + * Gets the URL for remote files. + * + * @since 7.0.0 + * + * @return {string|undefined} The URL. + */ + get url() { + return this._file.url; + } + + /** + * Gets the base64 data for inline files. + * + * @since 7.0.0 + * + * @return {string|undefined} The base64 data. + */ + get base64Data() { + return this._file.base64Data; + } + + /** + * Checks if the file is an inline file. + * + * @since 7.0.0 + * + * @return {boolean} True if the file is inline. + */ + isInline() { + return this.fileType === FileType.INLINE; + } + + /** + * Checks if the file is a remote file. + * + * @since 7.0.0 + * + * @return {boolean} True if the file is remote. + */ + isRemote() { + return this.fileType === FileType.REMOTE; + } + + /** + * Gets the data as a data URI for inline files. + * + * @since 7.0.0 + * + * @return {string|undefined} The data URI. + */ + getDataUri() { + if ( ! this.base64Data ) { + return undefined; + } + + return `data:${ this.mimeType };base64,${ this.base64Data }`; + } + + /** + * Checks if the file is a video. + * + * @since 7.0.0 + * + * @return {boolean} True if the file is a video. + */ + isVideo() { + return this.mimeType.startsWith( 'video/' ); + } + + /** + * Checks if the file is an image. + * + * @since 7.0.0 + * + * @return {boolean} True if the file is an image. + */ + isImage() { + return this.mimeType.startsWith( 'image/' ); + } + + /** + * Checks if the file is audio. + * + * @since 7.0.0 + * + * @return {boolean} True if the file is audio. + */ + isAudio() { + return this.mimeType.startsWith( 'audio/' ); + } + + /** + * Checks if the file is text. + * + * @since 7.0.0 + * + * @return {boolean} True if the file is text. + */ + isText() { + return this.mimeType.startsWith( 'text/' ); + } + + /** + * Checks if the file is a document. + * + * @since 7.0.0 + * + * @return {boolean} True if the file is a document. + */ + isDocument() { + return ( + this.mimeType === 'application/pdf' || + this.mimeType.startsWith( 'application/msword' ) || + this.mimeType.startsWith( + 'application/vnd.openxmlformats-officedocument' + ) || + this.mimeType.startsWith( 'application/vnd.ms-' ) + ); + } + + /** + * Checks if the file is a specific MIME type. + * + * @since 7.0.0 + * + * @param {string} type The mime type to check. + * @return {boolean} True if the file is of the specified type. + */ + isMimeType( type ) { + return this.mimeType.startsWith( type + '/' ) || this.mimeType === type; + } +} diff --git a/src/js/_enqueues/wp/ai-client/index.js b/src/js/_enqueues/wp/ai-client/index.js new file mode 100644 index 0000000000000..fb2059aac1609 --- /dev/null +++ b/src/js/_enqueues/wp/ai-client/index.js @@ -0,0 +1,60 @@ +/** + * WordPress AI Client - Client-side API. + * + * @since 7.0.0 + * + * @output wp-includes/js/dist/ai-client.js + * + * @package WordPress + * @subpackage AI + */ + +import { PromptBuilder } from './builders/prompt-builder'; +import { + getProviders, + getProvider, + getProviderModels, + getProviderModel, +} from './providers/api'; +import { store } from './providers/store'; +import * as enums from './enums'; + +/** + * Creates a new prompt builder for fluent API usage. + * + * @since 7.0.0 + * + * @param {string|Object|Array} [promptInput] Optional initial prompt content. + * @return {PromptBuilder} The prompt builder instance. + */ +export function prompt( promptInput ) { + return new PromptBuilder( promptInput ); +} + +export { + getProviders, + getProvider, + getProviderModels, + getProviderModel, + store, + enums, +}; + +// Expose the API in the global `wp.aiClient` namespace for external use. +const AiClient = { + prompt, + getProviders, + getProvider, + getProviderModels, + getProviderModel, + store, + enums, +}; + +if ( + typeof window !== 'undefined' && + 'wp' in window && + typeof window.wp === 'object' +) { + window.wp.aiClient = AiClient; +} diff --git a/src/js/_enqueues/wp/ai-client/providers/api.js b/src/js/_enqueues/wp/ai-client/providers/api.js new file mode 100644 index 0000000000000..7e05e185b5378 --- /dev/null +++ b/src/js/_enqueues/wp/ai-client/providers/api.js @@ -0,0 +1,60 @@ +/** + * Provider API functions. + * + * @since 7.0.0 + * + * @package WordPress + * @subpackage AI + */ + +import { resolveSelect } from '@wordpress/data'; +import { store } from './store'; + +/** + * Gets all registered AI providers. + * + * @since 7.0.0 + * + * @return {Promise} Promise resolving to array of providers. + */ +export async function getProviders() { + return await resolveSelect( store ).getProviders(); +} + +/** + * Gets a specific provider by its ID. + * + * @since 7.0.0 + * + * @param {string} id Provider ID. + * @return {Promise} Promise resolving to provider object, or undefined if not found. + */ +export async function getProvider( id ) { + return await resolveSelect( store ).getProvider( id ); +} + +/** + * Gets all models for a specific provider. + * + * @since 7.0.0 + * + * @param {string} providerId Provider ID. + * @return {Promise} Promise resolving to array of models for the provider. + */ +export async function getProviderModels( providerId ) { + return await resolveSelect( store ).getProviderModels( providerId ); +} + +/** + * Gets a specific model by its ID for a provider. + * + * @since 7.0.0 + * + * @param {string} providerId Provider ID. + * @param {string} modelId Model ID. + * @return {Promise} Promise resolving to model object, or undefined if not found. + */ +export async function getProviderModel( providerId, modelId ) { + const models = await resolveSelect( store ).getProviderModels( providerId ); + return models.find( ( model ) => model.id === modelId ); +} diff --git a/src/js/_enqueues/wp/ai-client/providers/store/actions.js b/src/js/_enqueues/wp/ai-client/providers/store/actions.js new file mode 100644 index 0000000000000..a804b5d6f9303 --- /dev/null +++ b/src/js/_enqueues/wp/ai-client/providers/store/actions.js @@ -0,0 +1,43 @@ +/** + * Store action creators. + * + * @since 7.0.0 + * + * @package WordPress + * @subpackage AI + */ + +export const RECEIVE_PROVIDERS = 'RECEIVE_PROVIDERS'; +export const RECEIVE_PROVIDER_MODELS = 'RECEIVE_PROVIDER_MODELS'; + +/** + * Returns an action object used to receive providers into the store. + * + * @since 7.0.0 + * + * @param {Array} providers Array of providers to store. + * @return {Object} Action object. + */ +export function receiveProviders( providers ) { + return { + type: RECEIVE_PROVIDERS, + providers, + }; +} + +/** + * Returns an action object used to receive models for a specific provider into the store. + * + * @since 7.0.0 + * + * @param {string} providerId Provider ID. + * @param {Array} models Array of models to store for the provider. + * @return {Object} Action object. + */ +export function receiveProviderModels( providerId, models ) { + return { + type: RECEIVE_PROVIDER_MODELS, + providerId, + models, + }; +} diff --git a/src/js/_enqueues/wp/ai-client/providers/store/index.js b/src/js/_enqueues/wp/ai-client/providers/store/index.js new file mode 100644 index 0000000000000..a1a192c59d33e --- /dev/null +++ b/src/js/_enqueues/wp/ai-client/providers/store/index.js @@ -0,0 +1,24 @@ +/** + * Providers/Models data store. + * + * @since 7.0.0 + * + * @package WordPress + * @subpackage AI + */ + +import { createReduxStore, register } from '@wordpress/data'; +import reducer from './reducer'; +import * as actions from './actions'; +import * as selectors from './selectors'; +import * as resolvers from './resolvers'; +import { STORE_NAME } from './name'; + +export const store = createReduxStore( STORE_NAME, { + reducer, + actions, + selectors, + resolvers, +} ); + +register( store ); diff --git a/src/js/_enqueues/wp/ai-client/providers/store/name.js b/src/js/_enqueues/wp/ai-client/providers/store/name.js new file mode 100644 index 0000000000000..f27871b790af0 --- /dev/null +++ b/src/js/_enqueues/wp/ai-client/providers/store/name.js @@ -0,0 +1,10 @@ +/** + * Store name constant. + * + * @since 7.0.0 + * + * @package WordPress + * @subpackage AI + */ + +export const STORE_NAME = 'wp-ai-client/providers-models'; diff --git a/src/js/_enqueues/wp/ai-client/providers/store/reducer.js b/src/js/_enqueues/wp/ai-client/providers/store/reducer.js new file mode 100644 index 0000000000000..270f6791c1215 --- /dev/null +++ b/src/js/_enqueues/wp/ai-client/providers/store/reducer.js @@ -0,0 +1,69 @@ +/** + * Store reducer. + * + * @since 7.0.0 + * + * @package WordPress + * @subpackage AI + */ + +import { RECEIVE_PROVIDERS, RECEIVE_PROVIDER_MODELS } from './actions'; + +const DEFAULT_STATE = { + providers: [], + modelsByProvider: {}, + providerLookupMap: {}, + providerModelsLookupMap: {}, +}; + +/** + * Reducer managing the AI providers and models. + * + * @since 7.0.0 + * + * @param {Object} state Current state. + * @param {Object} action Action to handle. + * @return {Object} New state. + */ +export default function reducer( state = DEFAULT_STATE, action ) { + switch ( action.type ) { + case RECEIVE_PROVIDERS: { + const { providers } = action; + + const providerLookupMap = {}; + providers.forEach( ( provider, index ) => { + providerLookupMap[ provider.id ] = index; + } ); + + return { + ...state, + providers, + providerLookupMap, + }; + } + + case RECEIVE_PROVIDER_MODELS: { + const { providerId, models } = action; + + const providerModelsLookupMap = {}; + models.forEach( ( model, index ) => { + providerModelsLookupMap[ model.id ] = index; + } ); + + return { + ...state, + modelsByProvider: { + ...state.modelsByProvider, + [ providerId ]: models, + }, + providerModelsLookupMap: { + ...state.providerModelsLookupMap, + [ providerId ]: providerModelsLookupMap, + }, + }; + } + + default: + return state; + } +} diff --git a/src/js/_enqueues/wp/ai-client/providers/store/resolvers.js b/src/js/_enqueues/wp/ai-client/providers/store/resolvers.js new file mode 100644 index 0000000000000..26e2c0bd69f5b --- /dev/null +++ b/src/js/_enqueues/wp/ai-client/providers/store/resolvers.js @@ -0,0 +1,92 @@ +/** + * Store resolvers. + * + * @since 7.0.0 + * + * @package WordPress + * @subpackage AI + */ + +import apiFetch from '@wordpress/api-fetch'; +import { receiveProviders, receiveProviderModels } from './actions'; + +/** + * Resolver for getProviders selector. + * + * @since 7.0.0 + * + * @return {Function} Action function to resolve the selector. + */ +export function getProviders() { + return async ( { dispatch } ) => { + const providers = await apiFetch( { + path: '/wp-ai/v1/providers', + } ); + + dispatch( receiveProviders( providers || [] ) ); + }; +} + +/** + * Resolver for getProvider selector. + * + * Falls through to getProviders to ensure providers are loaded. + * + * @since 7.0.0 + * + * @return {Function} Action function to resolve the selector. + */ +export function getProvider() { + return ( { select } ) => { + select.getProviders(); + }; +} + +/** + * Resolver for getProviderModels selector. + * + * @since 7.0.0 + * + * @param {string} providerId Provider ID. + * @return {Function} Action function to resolve the selector. + */ +export function getProviderModels( providerId ) { + return async ( { dispatch } ) => { + let models = []; + try { + models = await apiFetch( { + path: `/wp-ai/v1/providers/${ providerId }/models`, + } ); + } catch ( error ) { + // If the provider is not configured, ignore the error and return an empty models array. + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'ai_provider_not_configured' + ) { + models = []; + } else { + throw error; + } + } + + dispatch( receiveProviderModels( providerId, models ) ); + }; +} + +/** + * Resolver for getProviderModel selector. + * + * Falls through to getProviderModels to ensure models are loaded. + * + * @since 7.0.0 + * + * @param {string} providerId Provider ID. + * @return {Function} Action function to resolve the selector. + */ +export function getProviderModel( providerId ) { + return ( { select } ) => { + select.getProviderModels( providerId ); + }; +} diff --git a/src/js/_enqueues/wp/ai-client/providers/store/selectors.js b/src/js/_enqueues/wp/ai-client/providers/store/selectors.js new file mode 100644 index 0000000000000..82fe7aea65ba2 --- /dev/null +++ b/src/js/_enqueues/wp/ai-client/providers/store/selectors.js @@ -0,0 +1,75 @@ +/** + * Store selectors. + * + * @since 7.0.0 + * + * @package WordPress + * @subpackage AI + */ + +const EMPTY_MODELS_ARRAY = []; + +/** + * Returns all registered AI providers. + * + * @since 7.0.0 + * + * @param {Object} state Store state. + * @return {Array} Array of providers. + */ +export const getProviders = ( state ) => { + return state.providers; +}; + +/** + * Returns a specific provider by its ID. + * + * @since 7.0.0 + * + * @param {Object} state Store state. + * @param {string} id Provider ID. + * @return {Object|undefined} Provider object, or undefined if not found. + */ +export function getProvider( state, id ) { + if ( ! ( id in state.providerLookupMap ) ) { + return undefined; + } + + const index = state.providerLookupMap[ id ]; + return state.providers[ index ]; +} + +/** + * Returns all models for a specific provider. + * + * @since 7.0.0 + * + * @param {Object} state Store state. + * @param {string} providerId Provider ID. + * @return {Array} Array of models for the provider. + */ +export const getProviderModels = ( state, providerId ) => { + return state.modelsByProvider[ providerId ] || EMPTY_MODELS_ARRAY; +}; + +/** + * Returns a specific model by its ID for a provider. + * + * @since 7.0.0 + * + * @param {Object} state Store state. + * @param {string} providerId Provider ID. + * @param {string} modelId Model ID. + * @return {Object|undefined} Model object, or undefined if not found. + */ +export function getProviderModel( state, providerId, modelId ) { + if ( + ! ( providerId in state.providerModelsLookupMap ) || + ! ( modelId in state.providerModelsLookupMap[ providerId ] ) + ) { + return undefined; + } + + const index = state.providerModelsLookupMap[ providerId ][ modelId ]; + return state.modelsByProvider[ providerId ][ index ]; +} diff --git a/src/js/_enqueues/wp/ai-client/results/generative-ai-result.js b/src/js/_enqueues/wp/ai-client/results/generative-ai-result.js new file mode 100644 index 0000000000000..6399eac8624b1 --- /dev/null +++ b/src/js/_enqueues/wp/ai-client/results/generative-ai-result.js @@ -0,0 +1,329 @@ +/** + * GenerativeAiResult wrapper class. + * + * @since 7.0.0 + * + * @package WordPress + * @subpackage AI + */ + +import { MessagePartChannel, MessagePartType } from '../enums'; + +/** + * Represents the result of a generative AI operation. + * + * @since 7.0.0 + */ +export class GenerativeAiResult { + /** + * Constructor. + * + * @since 7.0.0 + * + * @param {Object} result The raw result object. + */ + constructor( result ) { + if ( ! result.candidates || result.candidates.length === 0 ) { + throw new Error( 'At least one candidate must be provided' ); + } + this._result = result; + } + + /** + * Gets the unique identifier for this result. + * + * @since 7.0.0 + * + * @return {string} The ID. + */ + get id() { + return this._result.id; + } + + /** + * Gets the generated candidates. + * + * @since 7.0.0 + * + * @return {Array} The candidates. + */ + get candidates() { + return this._result.candidates; + } + + /** + * Gets the token usage statistics. + * + * @since 7.0.0 + * + * @return {Object} The token usage. + */ + get tokenUsage() { + return this._result.tokenUsage; + } + + /** + * Gets the provider metadata. + * + * @since 7.0.0 + * + * @return {Object} The provider metadata. + */ + get providerMetadata() { + return this._result.providerMetadata; + } + + /** + * Gets the model metadata. + * + * @since 7.0.0 + * + * @return {Object} The model metadata. + */ + get modelMetadata() { + return this._result.modelMetadata; + } + + /** + * Gets additional data. + * + * @since 7.0.0 + * + * @return {Object|undefined} The additional data. + */ + get additionalData() { + return this._result.additionalData; + } + + /** + * Gets the total number of candidates. + * + * @since 7.0.0 + * + * @return {number} The total number of candidates. + */ + getCandidateCount() { + return this._result.candidates.length; + } + + /** + * Checks if the result has multiple candidates. + * + * @since 7.0.0 + * + * @return {boolean} True if there are multiple candidates. + */ + hasMultipleCandidates() { + return this.getCandidateCount() > 1; + } + + /** + * Converts the first candidate to text. + * + * @since 7.0.0 + * + * @return {string} The text content. + */ + toText() { + const message = this._result.candidates[ 0 ].message; + for ( const part of message.parts ) { + if ( + part.channel === MessagePartChannel.CONTENT && + part.type === MessagePartType.TEXT + ) { + return part.text; + } + } + + throw new Error( 'No text content found in first candidate' ); + } + + /** + * Converts the first candidate to a file. + * + * @since 7.0.0 + * + * @return {Object} The file. + */ + toFile() { + const message = this._result.candidates[ 0 ].message; + for ( const part of message.parts ) { + if ( + part.channel === MessagePartChannel.CONTENT && + part.type === MessagePartType.FILE + ) { + return part.file; + } + } + + throw new Error( 'No file content found in first candidate' ); + } + + /** + * Converts the first candidate to an image file. + * + * @since 7.0.0 + * + * @return {Object} The image file. + */ + toImageFile() { + const file = this.toFile(); + + if ( ! file.mimeType.startsWith( 'image/' ) ) { + throw new Error( + `File is not an image. MIME type: ${ file.mimeType }` + ); + } + + return file; + } + + /** + * Converts the first candidate to an audio file. + * + * @since 7.0.0 + * + * @return {Object} The audio file. + */ + toAudioFile() { + const file = this.toFile(); + + if ( ! file.mimeType.startsWith( 'audio/' ) ) { + throw new Error( + `File is not an audio file. MIME type: ${ file.mimeType }` + ); + } + + return file; + } + + /** + * Converts the first candidate to a video file. + * + * @since 7.0.0 + * + * @return {Object} The video file. + */ + toVideoFile() { + const file = this.toFile(); + + if ( ! file.mimeType.startsWith( 'video/' ) ) { + throw new Error( + `File is not a video file. MIME type: ${ file.mimeType }` + ); + } + + return file; + } + + /** + * Converts the first candidate to a message. + * + * @since 7.0.0 + * + * @return {Object} The message. + */ + toMessage() { + return this._result.candidates[ 0 ].message; + } + + /** + * Converts all candidates to text. + * + * @since 7.0.0 + * + * @return {string[]} Array of text content. + */ + toTexts() { + const texts = []; + for ( const candidate of this._result.candidates ) { + const message = candidate.message; + for ( const part of message.parts ) { + if ( + part.channel === MessagePartChannel.CONTENT && + part.type === MessagePartType.TEXT + ) { + texts.push( part.text ); + break; + } + } + } + return texts; + } + + /** + * Converts all candidates to files. + * + * @since 7.0.0 + * + * @return {Object[]} Array of files. + */ + toFiles() { + const files = []; + for ( const candidate of this._result.candidates ) { + const message = candidate.message; + for ( const part of message.parts ) { + if ( + part.channel === MessagePartChannel.CONTENT && + part.type === MessagePartType.FILE + ) { + files.push( part.file ); + break; + } + } + } + return files; + } + + /** + * Converts all candidates to image files. + * + * @since 7.0.0 + * + * @return {Object[]} Array of image files. + */ + toImageFiles() { + return this.toFiles().filter( ( file ) => + file.mimeType.startsWith( 'image/' ) + ); + } + + /** + * Converts all candidates to audio files. + * + * @since 7.0.0 + * + * @return {Object[]} Array of audio files. + */ + toAudioFiles() { + return this.toFiles().filter( ( file ) => + file.mimeType.startsWith( 'audio/' ) + ); + } + + /** + * Converts all candidates to video files. + * + * @since 7.0.0 + * + * @return {Object[]} Array of video files. + */ + toVideoFiles() { + return this.toFiles().filter( ( file ) => + file.mimeType.startsWith( 'video/' ) + ); + } + + /** + * Converts all candidates to messages. + * + * @since 7.0.0 + * + * @return {Object[]} Array of messages. + */ + toMessages() { + return this._result.candidates.map( + ( candidate ) => candidate.message + ); + } +} diff --git a/src/wp-admin/menu.php b/src/wp-admin/menu.php index e544175d153b4..a682f715ac88a 100644 --- a/src/wp-admin/menu.php +++ b/src/wp-admin/menu.php @@ -410,6 +410,9 @@ function _add_plugin_file_editor_to_tools() { $submenu['options-general.php'][30] = array( __( 'Media' ), 'manage_options', 'options-media.php' ); $submenu['options-general.php'][40] = array( __( 'Permalinks' ), 'manage_options', 'options-permalink.php' ); $submenu['options-general.php'][45] = array( __( 'Privacy' ), 'manage_privacy_options', 'options-privacy.php' ); +if ( ! empty( $GLOBALS['wp_ai_client_credentials_manager']->get_all_cloud_providers_metadata() ) ) { + $submenu['options-general.php'][47] = array( __( 'AI Services' ), 'manage_options', 'options-ai.php' ); +} $_wp_last_utility_menu = 80; // The index of the last top-level menu in the utility menu group. diff --git a/src/wp-admin/options-ai.php b/src/wp-admin/options-ai.php new file mode 100644 index 0000000000000..df625f1737d02 --- /dev/null +++ b/src/wp-admin/options-ai.php @@ -0,0 +1,102 @@ +get_all_cloud_providers_metadata(); + +$settings_section = 'wp-ai-client-provider-credentials'; + +add_settings_section( + $settings_section, + '', + static function () { + ?> +

+ +

+ getId(); + $provider_name = $provider_metadata->getName(); + $provider_credentials_url = $provider_metadata->getCredentialsUrl(); + + $field_id = 'wp-ai-client-provider-api-key-' . $provider_id; + $field_args = array( + 'type' => 'password', + 'label_for' => $field_id, + 'id' => $field_id, + 'name' => WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS . '[' . $provider_id . ']', + ); + if ( $provider_credentials_url ) { + $field_args['description'] = sprintf( + /* translators: 1: AI provider name, 2: URL to the provider's API credentials page. */ + __( 'Create and manage your %1$s API keys in the %1$s account settings (opens in a new tab).' ), + $provider_name, + esc_url( $provider_credentials_url ) + ); + } + + add_settings_field( + $field_id, + $provider_name, + 'wp_ai_client_render_credential_field', + 'ai', + $settings_section, + $field_args + ); +} + +$ai_help = '

' . __( 'This screen allows you to configure API credentials for AI service providers. These credentials are used by AI-powered features throughout your site.' ) . '

'; +$ai_help .= '

' . __( 'You must click the Save Changes button at the bottom of the screen for new settings to take effect.' ) . '

'; + +get_current_screen()->add_help_tab( + array( + 'id' => 'overview', + 'title' => __( 'Overview' ), + 'content' => $ai_help, + ) +); + +get_current_screen()->set_help_sidebar( + '

' . __( 'For more information:' ) . '

' . + '

' . __( 'Support forums' ) . '

' +); + +require_once ABSPATH . 'wp-admin/admin-header.php'; + +?> + +
+

+ +
+ + + +
+ +
+ + diff --git a/src/wp-admin/options.php b/src/wp-admin/options.php index 57c22be86d367..e297cdc7845ac 100644 --- a/src/wp-admin/options.php +++ b/src/wp-admin/options.php @@ -159,6 +159,7 @@ $allowed_options['misc'] = array(); $allowed_options['options'] = array(); $allowed_options['privacy'] = array(); +$allowed_options['ai'] = array(); /** * Filters whether the post-by-email functionality is enabled. diff --git a/src/wp-includes/ai-client.php b/src/wp-includes/ai-client.php index 88a1fdf323f52..8d5a1db314617 100644 --- a/src/wp-includes/ai-client.php +++ b/src/wp-includes/ai-client.php @@ -32,3 +32,72 @@ function wp_ai_client_prompt( $prompt = null ) { return new WP_AI_Client_Prompt_Builder( AiClient::defaultRegistry(), $prompt ); } + +/** + * Renders a credential input field for the AI Services settings page. + * + * @since 7.0.0 + * @access private + * + * @param array $args { + * Field arguments set up during add_settings_field(). + * + * @type string $type Input type. Default 'text'. + * @type string $id Field ID attribute. + * @type string $name Field name attribute, may include array notation. + * @type string $description Optional. Field description HTML. + * } + */ +function wp_ai_client_render_credential_field( $args ) { + $type = isset( $args['type'] ) ? $args['type'] : 'text'; + $id = isset( $args['id'] ) ? $args['id'] : ''; + $name = isset( $args['name'] ) ? $args['name'] : ''; + $description = isset( $args['description'] ) ? $args['description'] : ''; + $description_id = $id . '_description'; + + if ( str_contains( $name, '[' ) ) { + $parts = explode( '[', $name, 2 ); + $option = get_option( $parts[0] ); + $subkey = trim( $parts[1], ']' ); + if ( is_array( $option ) && isset( $option[ $subkey ] ) && is_string( $option[ $subkey ] ) ) { + $value = $option[ $subkey ]; + } else { + $value = ''; + } + } else { + $option = get_option( $name ); + $value = is_string( $option ) ? $option : ''; + } + + ?> + + > + array( + 'class' => array(), + 'href' => array(), + 'target' => array(), + 'rel' => array(), + ), + 'strong' => array(), + 'em' => array(), + 'span' => array( + 'class' => array(), + ), + ); + ?> +

+ +

+ $allcaps An array of all the user's capabilities. + * @return array The filtered array of capabilities. + */ + public static function grant_prompt_ai_to_administrators( array $allcaps ): array { + if ( isset( $allcaps['manage_options'] ) && $allcaps['manage_options'] ) { + $allcaps[ self::PROMPT_AI ] = true; + } + return $allcaps; + } + + /** + * Grants the list_ai_providers and list_ai_models capabilities to administrators. + * + * This method is intended to be used as a filter callback for 'user_has_cap'. + * It will grant the 'list_ai_providers' and 'list_ai_models' capabilities to users + * who have the 'manage_options' capability. + * + * For customization, this filter callback can be removed and replaced with custom logic. + * + * @since 7.0.0 + * + * @param array $allcaps An array of all the user's capabilities. + * @return array The filtered array of capabilities. + */ + public static function grant_list_ai_providers_models_to_administrators( array $allcaps ): array { + if ( isset( $allcaps['manage_options'] ) && $allcaps['manage_options'] ) { + $allcaps[ self::LIST_AI_PROVIDERS ] = true; + $allcaps[ self::LIST_AI_MODELS ] = true; + } + return $allcaps; + } +} diff --git a/src/wp-includes/ai-client/adapters/class-wp-ai-client-credentials-manager.php b/src/wp-includes/ai-client/adapters/class-wp-ai-client-credentials-manager.php new file mode 100644 index 0000000000000..a3a7a4487b92f --- /dev/null +++ b/src/wp-includes/ai-client/adapters/class-wp-ai-client-credentials-manager.php @@ -0,0 +1,222 @@ +getRegisteredProviderIds(); + foreach ( $provider_ids as $provider_id ) { + // If the provider was already found via another client class, just add this client class name. + if ( isset( $wp_ai_client_providers_metadata[ $provider_id ] ) ) { + if ( ! is_array( $wp_ai_client_providers_metadata[ $provider_id ]['ai_client_classnames'] ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Invalid format for collected provider AI client class names.' ), + '7.0.0' + ); + continue; + } + $wp_ai_client_providers_metadata[ $provider_id ]['ai_client_classnames'][ AiClient::class ] = true; + continue; + } + + // Get the provider metadata and add it to the global. + $provider_class_name = $registry->getProviderClassName( $provider_id ); + $provider_metadata = $provider_class_name::metadata(); + + $wp_ai_client_providers_metadata[ $provider_id ] = array_merge( + $provider_metadata->toArray(), + array( + 'ai_client_classnames' => array( AiClient::class => true ), + ) + ); + } + } + + /** + * Returns the metadata for all registered providers across all instances of the PHP AI Client SDK. + * + * @since 7.0.0 + * + * @return array Array of provider metadata objects, + * keyed by provider ID. + */ + public function get_all_providers_metadata() { + global $wp_ai_client_providers_metadata; + + if ( ! isset( $wp_ai_client_providers_metadata ) ) { + $wp_ai_client_providers_metadata = array(); + } + + return array_map( + static function ( array $provider_metadata ) { + unset( $provider_metadata['ai_client_classnames'] ); + return \WordPress\AiClient\Providers\DTO\ProviderMetadata::fromArray( $provider_metadata ); + }, + $wp_ai_client_providers_metadata + ); + } + + /** + * Returns the metadata for all registered cloud providers across all instances of the PHP AI Client SDK. + * + * @since 7.0.0 + * + * @return array Array of cloud provider metadata objects, + * keyed by provider ID. + */ + public function get_all_cloud_providers_metadata() { + $all_providers = $this->get_all_providers_metadata(); + + return array_filter( + $all_providers, + static function ( $metadata ) { + return $metadata->getType()->isCloud(); + } + ); + } + + /** + * Registers the settings for storing the API credentials. + * + * The setting will only be registered once, even if called multiple times. + * + * @since 7.0.0 + */ + public function register_settings() { + // Avoid registering the setting multiple times. + $registered_settings = get_registered_settings(); + if ( isset( $registered_settings[ self::OPTION_PROVIDER_CREDENTIALS ] ) ) { + return; + } + + register_setting( + self::OPTION_GROUP, + self::OPTION_PROVIDER_CREDENTIALS, + array( + 'type' => 'object', + 'default' => array(), + 'sanitize_callback' => array( $this, 'sanitize_credentials' ), + ) + ); + } + + /** + * Sanitizes the provider credentials before saving. + * + * Filters out unknown providers and sanitizes each API key value. + * + * @since 7.0.0 + * + * @param mixed $credentials The raw credentials input. + * @return array Sanitized credentials array. + */ + public function sanitize_credentials( $credentials ) { + if ( ! is_array( $credentials ) ) { + return array(); + } + + // Assume that all cloud providers require an API key. + $providers_metadata_keyed_by_ids = $this->get_all_cloud_providers_metadata(); + + $credentials = array_intersect_key( $credentials, $providers_metadata_keyed_by_ids ); + foreach ( $credentials as $provider_id => $api_key ) { + if ( ! is_string( $api_key ) ) { + unset( $credentials[ $provider_id ] ); + continue; + } + $credentials[ $provider_id ] = sanitize_text_field( $api_key ); + } + return $credentials; + } + + /** + * Passes the stored API credentials to the PHP AI Client SDK. + * + * This method should be called on every request, before any API requests + * are made via the PHP AI Client SDK. + * + * @since 7.0.0 + */ + public function pass_credentials_to_client() { + $credentials = get_option( self::OPTION_PROVIDER_CREDENTIALS, array() ); + if ( ! is_array( $credentials ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Invalid format for stored provider credentials option.' ), + '7.0.0' + ); + return; + } + + $registry = AiClient::defaultRegistry(); + + foreach ( $credentials as $provider_id => $api_key ) { + if ( ! is_string( $api_key ) || '' === $api_key ) { + continue; + } + + if ( ! $registry->hasProvider( $provider_id ) ) { + continue; + } + + $registry->setProviderRequestAuthentication( + $provider_id, + new ApiKeyRequestAuthentication( $api_key ) + ); + } + } +} diff --git a/src/wp-includes/ai-client/adapters/class-wp-ai-client-json-schema-converter.php b/src/wp-includes/ai-client/adapters/class-wp-ai-client-json-schema-converter.php new file mode 100644 index 0000000000000..d221f40f5f0ec --- /dev/null +++ b/src/wp-includes/ai-client/adapters/class-wp-ai-client-json-schema-converter.php @@ -0,0 +1,75 @@ + $schema The standard JSON schema. + * @return array The WordPress compatible schema. + */ + public static function convert( array $schema ): array { + if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { + $required_props = isset( $schema['required'] ) && is_array( $schema['required'] ) + ? $schema['required'] + : array(); + + // Remove the required array from the parent object. + unset( $schema['required'] ); + + foreach ( $schema['properties'] as $prop_name => $prop_schema ) { + if ( ! is_array( $prop_schema ) ) { + continue; + } + + /** @var array $prop_schema */ + $schema['properties'][ $prop_name ] = self::convert( $prop_schema ); + + // Set required boolean if property is in required array. + if ( in_array( $prop_name, $required_props, true ) ) { + $schema['properties'][ $prop_name ]['required'] = true; + } + } + } + + if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) { + /** @var array $items */ + $items = $schema['items']; + + $schema['items'] = self::convert( $items ); + } + + // Handle oneOf, anyOf, allOf. + foreach ( array( 'oneOf', 'anyOf', 'allOf' ) as $combiner ) { + if ( isset( $schema[ $combiner ] ) && is_array( $schema[ $combiner ] ) ) { + foreach ( $schema[ $combiner ] as $index => $sub_schema ) { + if ( ! is_array( $sub_schema ) ) { + continue; + } + + /** @var array $sub_schema */ + $schema[ $combiner ][ $index ] = self::convert( $sub_schema ); + } + } + } + + return $schema; + } +} diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 301b846343ee2..2852a77929931 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -531,6 +531,7 @@ add_action( 'init', 'rest_api_init' ); add_action( 'rest_api_init', 'rest_api_default_filters', 10, 1 ); add_action( 'rest_api_init', 'register_initial_settings', 10 ); +add_action( 'admin_init', 'register_initial_settings' ); add_action( 'rest_api_init', 'create_initial_rest_routes', 99 ); add_action( 'parse_request', 'rest_api_loaded' ); @@ -746,6 +747,8 @@ add_filter( 'user_has_cap', 'wp_maybe_grant_install_languages_cap', 1 ); add_filter( 'user_has_cap', 'wp_maybe_grant_resume_extensions_caps', 1 ); add_filter( 'user_has_cap', 'wp_maybe_grant_site_health_caps', 1, 4 ); +add_filter( 'user_has_cap', array( 'WP_AI_Client_Capabilities', 'grant_prompt_ai_to_administrators' ) ); +add_filter( 'user_has_cap', array( 'WP_AI_Client_Capabilities', 'grant_list_ai_providers_models_to_administrators' ) ); // Block templates post type and rendering. add_filter( 'render_block_context', '_block_template_render_without_post_block_context' ); diff --git a/src/wp-includes/option.php b/src/wp-includes/option.php index 970ac39652195..23741b9d32d71 100644 --- a/src/wp-includes/option.php +++ b/src/wp-includes/option.php @@ -2969,6 +2969,8 @@ function register_initial_settings() { 'description' => __( 'Allow people to submit comments on new posts.' ), ) ); + + $GLOBALS['wp_ai_client_credentials_manager']->register_settings(); } /** diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index f144957286d7c..f7b0eef9e4f81 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -425,6 +425,12 @@ function create_initial_rest_routes() { $abilities_list_controller = new WP_REST_Abilities_V1_List_Controller(); $abilities_list_controller->register_routes(); + // AI Client. + $ai_generate_controller = new WP_REST_AI_V1_Generate_Controller(); + $ai_generate_controller->register_routes(); + $ai_providers_controller = new WP_REST_AI_V1_Providers_Controller(); + $ai_providers_controller->register_routes(); + // Icons. $icons_controller = new WP_REST_Icons_Controller(); $icons_controller->register_routes(); diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-ai-v1-generate-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-ai-v1-generate-controller.php new file mode 100644 index 0000000000000..58c6564881e7a --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-ai-v1-generate-controller.php @@ -0,0 +1,308 @@ +namespace = 'wp-ai/v1'; + $this->rest_base = 'generate'; + } + + /** + * Registers the routes for the objects of the controller. + * + * @since 7.0.0 + */ + public function register_routes() { + $generation_request_schema = $this->get_generation_request_schema(); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'process_generate_request' ), + 'permission_callback' => array( $this, 'permissions_check' ), + 'args' => $generation_request_schema['properties'], + ), + 'schema' => array( $this, 'get_generation_result_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/is-supported', + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'process_is_supported_request' ), + 'permission_callback' => array( $this, 'permissions_check' ), + 'args' => $generation_request_schema['properties'], + ), + 'schema' => array( $this, 'get_is_supported_schema' ), + ) + ); + } + + /** + * Checks if the user has permission to prompt AI models. + * + * @since 7.0.0 + * + * @return true|WP_Error True if authorized, WP_Error otherwise. + */ + public function permissions_check() { + if ( current_user_can( WP_AI_Client_Capabilities::PROMPT_AI ) ) { + return true; + } + + return new WP_Error( + 'rest_forbidden', + __( 'Sorry, you are not allowed to prompt AI models directly.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + /** + * Generates content using an AI model. + * + * @since 7.0.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function process_generate_request( WP_REST_Request $request ) { + $params = $request->get_json_params(); + + try { + $builder = $this->create_builder_from_params( $params ); + + $capability = null; + if ( ! empty( $params['capability'] ) ) { + $capability = CapabilityEnum::tryFrom( (string) $params['capability'] ); + } + + $result = $builder->generate_result( $capability ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return new WP_REST_Response( $result, 200 ); + } catch ( Exception $e ) { + return new WP_Error( 'ai_generate_error', $e->getMessage(), array( 'status' => 500 ) ); + } + } + + /** + * Checks if the prompt and its configuration is supported by any available AI models. + * + * @since 7.0.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function process_is_supported_request( WP_REST_Request $request ) { + $params = $request->get_json_params(); + + try { + $builder = $this->create_builder_from_params( $params ); + + // Check specific capability if provided. + if ( ! empty( $params['capability'] ) ) { + $capability = CapabilityEnum::tryFrom( (string) $params['capability'] ); + if ( ! $capability ) { + return new WP_Error( + 'ai_invalid_capability', + __( 'Invalid capability.' ), + array( 'status' => 400 ) + ); + } + + $supported = $builder->is_supported( $capability ); + return new WP_REST_Response( array( 'supported' => $supported ), 200 ); + } + + $supported = $builder->is_supported(); + return new WP_REST_Response( array( 'supported' => $supported ), 200 ); + } catch ( Exception $e ) { + return new WP_Error( 'ai_is_supported_error', $e->getMessage(), array( 'status' => 500 ) ); + } + } + + /** + * Retrieves the generation request schema. + * + * @since 7.0.0 + * + * @return array The request schema. + */ + public function get_generation_request_schema(): array { + return array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'ai_generation_request', + 'type' => 'object', + 'properties' => array( + 'messages' => array( + 'description' => __( 'The messages to generate content from.' ), + 'type' => 'array', + 'items' => WP_AI_Client_JSON_Schema_Converter::convert( Message::getJsonSchema() ), + 'required' => true, + 'minItems' => 1, + ), + 'modelConfig' => WP_AI_Client_JSON_Schema_Converter::convert( ModelConfig::getJsonSchema() ), + 'providerId' => array( + 'description' => __( 'The provider ID, to enforce using a model from that provider.' ), + 'type' => 'string', + ), + 'modelId' => array( + 'description' => __( 'The model ID, to enforce using that model. If given, a providerId must also be present.' ), + 'type' => 'string', + ), + 'modelPreferences' => array( + 'description' => __( 'List of preferred models.' ), + 'type' => 'array', + 'items' => array( + 'oneOf' => array( + array( + 'type' => 'string', + ), + array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + 'minItems' => 2, + 'maxItems' => 2, + ), + ), + ), + ), + 'capability' => array( + 'description' => __( 'The capability to use.' ), + 'type' => 'string', + 'enum' => CapabilityEnum::getValues(), + ), + 'requestOptions' => WP_AI_Client_JSON_Schema_Converter::convert( RequestOptions::getJsonSchema() ), + ), + ); + } + + /** + * Retrieves the generation result schema. + * + * @since 7.0.0 + * + * @return array The result schema. + */ + public function get_generation_result_schema(): array { + $schema = GenerativeAiResult::getJsonSchema(); + $schema['$schema'] = 'http://json-schema.org/draft-04/schema#'; + $schema['title'] = 'ai_generation_result'; + + return WP_AI_Client_JSON_Schema_Converter::convert( $schema ); + } + + /** + * Retrieves the supported check schema. + * + * @since 7.0.0 + * + * @return array The supported check schema. + */ + public function get_is_supported_schema(): array { + return array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'ai_is_supported_response', + 'type' => 'object', + 'properties' => array( + 'supported' => array( + 'description' => __( 'Whether the capability is supported.' ), + 'type' => 'boolean', + 'required' => true, + ), + ), + ); + } + + /** + * Creates a prompt builder from request parameters. + * + * @since 7.0.0 + * + * @param array $params The request parameters. + * @return WP_AI_Client_Prompt_Builder The prompt builder instance. + */ + private function create_builder_from_params( array $params ): WP_AI_Client_Prompt_Builder { + // Messages are required by schema. + $messages_data = $params['messages']; + + $messages = array_map( + function ( $message ) { + return Message::fromArray( $message ); + }, + $messages_data + ); + + $builder = wp_ai_client_prompt( array_values( $messages ) ); + + if ( ! empty( $params['modelConfig'] ) && is_array( $params['modelConfig'] ) ) { + $model_config_data = $params['modelConfig']; + $config = ModelConfig::fromArray( $model_config_data ); + $builder->using_model_config( $config ); + } + + // If both providerId and modelId are provided, this model must be used. + if ( ! empty( $params['providerId'] ) && ! empty( $params['modelId'] ) ) { + $provider_id = (string) $params['providerId']; + $model_id = (string) $params['modelId']; + + $provider_class_name = AiClient::defaultRegistry()->getProviderClassName( $provider_id ); + + $model = $provider_class_name::model( $model_id ); + + return $builder->using_model( $model ); + } + + if ( ! empty( $params['providerId'] ) ) { + $builder->using_provider( (string) $params['providerId'] ); + } + + if ( ! empty( $params['modelPreferences'] ) && is_array( $params['modelPreferences'] ) ) { + $builder->using_model_preference( ...$params['modelPreferences'] ); + } + + if ( ! empty( $params['requestOptions'] ) && is_array( $params['requestOptions'] ) ) { + $request_options = RequestOptions::fromArray( $params['requestOptions'] ); + $builder->using_request_options( $request_options ); + } + + return $builder; + } +} diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-ai-v1-providers-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-ai-v1-providers-controller.php new file mode 100644 index 0000000000000..03de97dfcc382 --- /dev/null +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-ai-v1-providers-controller.php @@ -0,0 +1,311 @@ +namespace = 'wp-ai/v1'; + $this->rest_base = 'providers'; + } + + /** + * Registers the routes for the objects of the controller. + * + * @since 7.0.0 + */ + public function register_routes() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'process_get_providers_request' ), + 'permission_callback' => array( $this, 'permissions_check_providers' ), + ), + 'schema' => array( $this, 'get_provider_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[^/]+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'process_get_provider_request' ), + 'permission_callback' => array( $this, 'permissions_check_providers' ), + ), + 'args' => array( + 'providerId' => array( + 'description' => __( 'The provider ID.' ), + 'type' => 'string', + ), + ), + 'schema' => array( $this, 'get_provider_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[^/]+)/models', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'process_get_models_request' ), + 'permission_callback' => array( $this, 'permissions_check_models' ), + ), + 'schema' => array( $this, 'get_model_schema' ), + ) + ); + + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[^/]+)/models/(?P[^/]+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'process_get_model_request' ), + 'permission_callback' => array( $this, 'permissions_check_models' ), + ), + 'args' => array( + 'providerId' => array( + 'description' => __( 'The provider ID.' ), + 'type' => 'string', + ), + 'modelId' => array( + 'description' => __( 'The model ID.' ), + 'type' => 'string', + ), + ), + 'schema' => array( $this, 'get_model_schema' ), + ) + ); + } + + /** + * Checks if the user has permission to list AI providers. + * + * @since 7.0.0 + * + * @return true|WP_Error True if authorized, WP_Error otherwise. + */ + public function permissions_check_providers() { + if ( current_user_can( WP_AI_Client_Capabilities::LIST_AI_PROVIDERS ) ) { + return true; + } + + return new WP_Error( + 'rest_forbidden', + __( 'Sorry, you are not allowed to list AI providers.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + /** + * Checks if the user has permission to list AI models. + * + * @since 7.0.0 + * + * @return true|WP_Error True if authorized, WP_Error otherwise. + */ + public function permissions_check_models() { + if ( current_user_can( WP_AI_Client_Capabilities::LIST_AI_MODELS ) ) { + return true; + } + + return new WP_Error( + 'rest_forbidden', + __( 'Sorry, you are not allowed to list AI models.' ), + array( 'status' => rest_authorization_required_code() ) + ); + } + + /** + * Retrieves a list of AI providers. + * + * @since 7.0.0 + * + * @return WP_REST_Response The response object. + */ + public function process_get_providers_request() { + $registry = AiClient::defaultRegistry(); + + $provider_ids = $registry->getRegisteredProviderIds(); + $provider_metadata_objects = array_map( + function ( $id ) use ( $registry ) { + $classname = $registry->getProviderClassName( $id ); + return $classname::metadata(); + }, + $provider_ids + ); + + return new WP_REST_Response( $provider_metadata_objects, 200 ); + } + + /** + * Retrieves a specific AI provider. + * + * @since 7.0.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function process_get_provider_request( WP_REST_Request $request ) { + $provider_id = $request['providerId']; + $registry = AiClient::defaultRegistry(); + + if ( ! $registry->hasProvider( $provider_id ) ) { + return new WP_Error( + 'rest_not_found', + __( 'AI provider not found.' ), + array( 'status' => 404 ) + ); + } + + $provider_classname = $registry->getProviderClassName( $provider_id ); + $provider_metadata = $provider_classname::metadata(); + + return new WP_REST_Response( $provider_metadata, 200 ); + } + + /** + * Retrieves a list of models for a specific provider. + * + * @since 7.0.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function process_get_models_request( WP_REST_Request $request ) { + $provider_id = $request['providerId']; + $registry = AiClient::defaultRegistry(); + + if ( ! $registry->hasProvider( $provider_id ) ) { + return new WP_Error( + 'rest_not_found', + __( 'AI provider not found.' ), + array( 'status' => 404 ) + ); + } + + $provider_classname = $registry->getProviderClassName( $provider_id ); + + try { + /** @var ProviderAvailabilityInterface $provider_availability */ + $provider_availability = $provider_classname::availability(); + if ( ! $provider_availability->isConfigured() ) { + return new WP_Error( + 'ai_provider_not_configured', + __( 'AI provider not configured - missing API credentials.' ), + array( 'status' => 400 ) + ); + } + + /** @var ModelMetadataDirectoryInterface $model_metadata_directory */ + $model_metadata_directory = $provider_classname::modelMetadataDirectory(); + $model_metadata_objects = $model_metadata_directory->listModelMetadata(); + + return new WP_REST_Response( $model_metadata_objects, 200 ); + } catch ( Exception $e ) { + return new WP_Error( + 'ai_list_models_error', + sprintf( + /* translators: %s: Error message. */ + __( 'Could not list models for provider - are the API credentials invalid? Error: %s' ), + $e->getMessage() + ), + array( 'status' => 500 ) + ); + } + } + + /** + * Retrieves a specific model for a specific provider. + * + * @since 7.0.0 + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function process_get_model_request( WP_REST_Request $request ) { + $provider_id = $request['providerId']; + $model_id = $request['modelId']; + + $sub_request = new WP_REST_Request( 'GET', '/wp-ai/v1/providers/' . $provider_id . '/models' ); + $sub_request->set_url_params( array( 'providerId' => $provider_id ) ); + + $get_models_response = $this->process_get_models_request( $sub_request ); + if ( is_wp_error( $get_models_response ) ) { + return $get_models_response; + } + + /** @var list $models_metadata_objects */ + $models_metadata_objects = $get_models_response->get_data(); + foreach ( $models_metadata_objects as $model_metadata ) { + if ( $model_metadata->getId() === $model_id ) { + return new WP_REST_Response( $model_metadata, 200 ); + } + } + + return new WP_Error( + 'rest_not_found', + __( 'AI model not found.' ), + array( 'status' => 404 ) + ); + } + + /** + * Retrieves the provider schema. + * + * @since 7.0.0 + * + * @return array The provider schema. + */ + public function get_provider_schema(): array { + $schema = ProviderMetadata::getJsonSchema(); + $schema['$schema'] = 'http://json-schema.org/draft-04/schema#'; + $schema['title'] = 'ai_provider'; + + return WP_AI_Client_JSON_Schema_Converter::convert( $schema ); + } + + /** + * Retrieves the model schema. + * + * @since 7.0.0 + * + * @return array The model schema. + */ + public function get_model_schema(): array { + $schema = ModelMetadata::getJsonSchema(); + $schema['$schema'] = 'http://json-schema.org/draft-04/schema#'; + $schema['title'] = 'ai_model'; + + return WP_AI_Client_JSON_Schema_Converter::convert( $schema ); + } +} diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 6d1b7dee5d0bf..a409c870d6663 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -328,6 +328,9 @@ function wp_default_packages_scripts( $scripts ) { $scripts->add_inline_script( $handle, $script, 'after' ); } } + + // AI Client script (built separately from Gutenberg packages). + $scripts->add( 'wp-ai-client', "/wp-includes/js/dist/ai-client{$suffix}.js", array( 'wp-api-fetch', 'wp-data' ), false, 1 ); } /** diff --git a/src/wp-settings.php b/src/wp-settings.php index 90741401e800c..24d0909fae408 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -291,6 +291,8 @@ require ABSPATH . WPINC . '/ai-client/adapters/class-wp-ai-client-cache.php'; require ABSPATH . WPINC . '/ai-client/adapters/class-wp-ai-client-discovery-strategy.php'; require ABSPATH . WPINC . '/ai-client/adapters/class-wp-ai-client-event-dispatcher.php'; +require ABSPATH . WPINC . '/ai-client/adapters/class-wp-ai-client-capabilities.php'; +require ABSPATH . WPINC . '/ai-client/adapters/class-wp-ai-client-json-schema-converter.php'; require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-ability-function-resolver.php'; require ABSPATH . WPINC . '/ai-client/class-wp-ai-client-prompt-builder.php'; require ABSPATH . WPINC . '/ai-client.php'; @@ -361,6 +363,8 @@ require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-categories-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php'; require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-ai-v1-generate-controller.php'; +require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-ai-v1-providers-controller.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-comment-meta-fields.php'; require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-post-meta-fields.php'; @@ -477,6 +481,8 @@ WP_AI_Client_Discovery_Strategy::init(); WordPress\AiClient\AiClient::setCache( new WP_AI_Client_Cache() ); WordPress\AiClient\AiClient::setEventDispatcher( new WP_AI_Client_Event_Dispatcher() ); +require ABSPATH . WPINC . '/ai-client/adapters/class-wp-ai-client-credentials-manager.php'; +$GLOBALS['wp_ai_client_credentials_manager'] = new WP_AI_Client_Credentials_Manager(); // Load multisite-specific files. if ( is_multisite() ) { @@ -772,6 +778,10 @@ */ do_action( 'init' ); +// WP AI Client - Collect providers and pass credentials after plugins have loaded. +$GLOBALS['wp_ai_client_credentials_manager']->collect_providers(); +$GLOBALS['wp_ai_client_credentials_manager']->pass_credentials_to_client(); + // Check site status. if ( is_multisite() ) { $file = ms_site_check(); diff --git a/tests/phpunit/tests/ai-client/wpAiClientCapabilities.php b/tests/phpunit/tests/ai-client/wpAiClientCapabilities.php new file mode 100644 index 0000000000000..7c3ad3fa1230a --- /dev/null +++ b/tests/phpunit/tests/ai-client/wpAiClientCapabilities.php @@ -0,0 +1,200 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + + self::$editor_user_id = self::factory()->user->create( + array( + 'role' => 'editor', + ) + ); + } + + /** + * Test that PROMPT_AI constant is defined. + * + * @ticket TBD + */ + public function test_prompt_ai_constant() { + $this->assertSame( 'prompt_ai', WP_AI_Client_Capabilities::PROMPT_AI ); + } + + /** + * Test that LIST_AI_PROVIDERS constant is defined. + * + * @ticket TBD + */ + public function test_list_ai_providers_constant() { + $this->assertSame( 'list_ai_providers', WP_AI_Client_Capabilities::LIST_AI_PROVIDERS ); + } + + /** + * Test that LIST_AI_MODELS constant is defined. + * + * @ticket TBD + */ + public function test_list_ai_models_constant() { + $this->assertSame( 'list_ai_models', WP_AI_Client_Capabilities::LIST_AI_MODELS ); + } + + /** + * Test that admin has prompt_ai capability. + * + * @ticket TBD + */ + public function test_admin_has_prompt_ai() { + wp_set_current_user( self::$admin_user_id ); + $this->assertTrue( current_user_can( WP_AI_Client_Capabilities::PROMPT_AI ) ); + } + + /** + * Test that admin has list_ai_providers capability. + * + * @ticket TBD + */ + public function test_admin_has_list_ai_providers() { + wp_set_current_user( self::$admin_user_id ); + $this->assertTrue( current_user_can( WP_AI_Client_Capabilities::LIST_AI_PROVIDERS ) ); + } + + /** + * Test that admin has list_ai_models capability. + * + * @ticket TBD + */ + public function test_admin_has_list_ai_models() { + wp_set_current_user( self::$admin_user_id ); + $this->assertTrue( current_user_can( WP_AI_Client_Capabilities::LIST_AI_MODELS ) ); + } + + /** + * Test that editor does NOT have prompt_ai capability. + * + * @ticket TBD + */ + public function test_editor_does_not_have_prompt_ai() { + wp_set_current_user( self::$editor_user_id ); + $this->assertFalse( current_user_can( WP_AI_Client_Capabilities::PROMPT_AI ) ); + } + + /** + * Test that editor does NOT have list_ai_providers capability. + * + * @ticket TBD + */ + public function test_editor_does_not_have_list_ai_providers() { + wp_set_current_user( self::$editor_user_id ); + $this->assertFalse( current_user_can( WP_AI_Client_Capabilities::LIST_AI_PROVIDERS ) ); + } + + /** + * Test that editor does NOT have list_ai_models capability. + * + * @ticket TBD + */ + public function test_editor_does_not_have_list_ai_models() { + wp_set_current_user( self::$editor_user_id ); + $this->assertFalse( current_user_can( WP_AI_Client_Capabilities::LIST_AI_MODELS ) ); + } + + /** + * Test grant_prompt_ai_to_administrators static method directly. + * + * @ticket TBD + */ + public function test_grant_prompt_ai_with_manage_options() { + $allcaps = array( 'manage_options' => true ); + $result = WP_AI_Client_Capabilities::grant_prompt_ai_to_administrators( $allcaps ); + $this->assertTrue( $result['prompt_ai'] ); + } + + /** + * Test grant_prompt_ai_to_administrators without manage_options. + * + * @ticket TBD + */ + public function test_grant_prompt_ai_without_manage_options() { + $allcaps = array( 'edit_posts' => true ); + $result = WP_AI_Client_Capabilities::grant_prompt_ai_to_administrators( $allcaps ); + $this->assertArrayNotHasKey( 'prompt_ai', $result ); + } + + /** + * Test grant_list_ai_providers_models_to_administrators static method directly. + * + * @ticket TBD + */ + public function test_grant_list_providers_models_with_manage_options() { + $allcaps = array( 'manage_options' => true ); + $result = WP_AI_Client_Capabilities::grant_list_ai_providers_models_to_administrators( $allcaps ); + $this->assertTrue( $result['list_ai_providers'] ); + $this->assertTrue( $result['list_ai_models'] ); + } + + /** + * Test grant_list_ai_providers_models_to_administrators without manage_options. + * + * @ticket TBD + */ + public function test_grant_list_providers_models_without_manage_options() { + $allcaps = array( 'edit_posts' => true ); + $result = WP_AI_Client_Capabilities::grant_list_ai_providers_models_to_administrators( $allcaps ); + $this->assertArrayNotHasKey( 'list_ai_providers', $result ); + $this->assertArrayNotHasKey( 'list_ai_models', $result ); + } + + /** + * Test that removing the filter removes the capability. + * + * @ticket TBD + */ + public function test_removing_filter_removes_capability() { + wp_set_current_user( self::$admin_user_id ); + + // Verify capability exists. + $this->assertTrue( current_user_can( WP_AI_Client_Capabilities::PROMPT_AI ) ); + + // Remove the filter. + remove_filter( 'user_has_cap', array( 'WP_AI_Client_Capabilities', 'grant_prompt_ai_to_administrators' ) ); + + // Clear cached capabilities. + wp_get_current_user()->allcaps = array(); + wp_get_current_user()->caps = array(); + wp_get_current_user()->get_role_caps(); + + $this->assertFalse( current_user_can( WP_AI_Client_Capabilities::PROMPT_AI ) ); + + // Re-add the filter for other tests. + add_filter( 'user_has_cap', array( 'WP_AI_Client_Capabilities', 'grant_prompt_ai_to_administrators' ) ); + } +} diff --git a/tests/phpunit/tests/ai-client/wpAiClientCredentialsManager.php b/tests/phpunit/tests/ai-client/wpAiClientCredentialsManager.php new file mode 100644 index 0000000000000..7dc00245b0702 --- /dev/null +++ b/tests/phpunit/tests/ai-client/wpAiClientCredentialsManager.php @@ -0,0 +1,372 @@ +saved_providers_metadata = $wp_ai_client_providers_metadata; + } + + /** + * Restores state after each test. + */ + public function tear_down() { + global $wp_ai_client_providers_metadata; + $wp_ai_client_providers_metadata = $this->saved_providers_metadata; + delete_option( WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS ); + parent::tear_down(); + } + + /** + * Test that collect_providers initializes the global as an array. + * + * @ticket TBD + */ + public function test_collect_providers_initializes_global() { + global $wp_ai_client_providers_metadata; + $wp_ai_client_providers_metadata = null; + + $manager = new WP_AI_Client_Credentials_Manager(); + $manager->collect_providers(); + + $this->assertIsArray( $wp_ai_client_providers_metadata ); + + // Each entry should have the expected structure. + foreach ( $wp_ai_client_providers_metadata as $provider_id => $metadata ) { + $this->assertIsString( $provider_id ); + $this->assertArrayHasKey( 'id', $metadata ); + $this->assertArrayHasKey( 'name', $metadata ); + $this->assertArrayHasKey( 'type', $metadata ); + $this->assertArrayHasKey( 'ai_client_classnames', $metadata ); + $this->assertIsArray( $metadata['ai_client_classnames'] ); + } + } + + /** + * Test that collect_providers does not duplicate entries when called multiple times. + * + * @ticket TBD + */ + public function test_collect_providers_deduplicates() { + global $wp_ai_client_providers_metadata; + $wp_ai_client_providers_metadata = null; + + $manager = new WP_AI_Client_Credentials_Manager(); + $manager->collect_providers(); + + $first_count = count( $wp_ai_client_providers_metadata ); + + // Calling again should not duplicate providers. + $manager->collect_providers(); + $this->assertCount( $first_count, $wp_ai_client_providers_metadata ); + } + + /** + * Test that collect_providers preserves existing entries in the global. + * + * @ticket TBD + */ + public function test_collect_providers_preserves_existing_entries() { + global $wp_ai_client_providers_metadata; + + // Seed the global with a fake provider entry not in the SDK registry. + $wp_ai_client_providers_metadata = array( + 'test-provider' => array( + 'id' => 'test-provider', + 'name' => 'Test Provider', + 'type' => 'cloud', + 'ai_client_classnames' => array( 'SomeOtherClient' => true ), + ), + ); + + $manager = new WP_AI_Client_Credentials_Manager(); + $manager->collect_providers(); + + // The test-provider entry should still exist (not removed by collect_providers). + $this->assertArrayHasKey( 'test-provider', $wp_ai_client_providers_metadata ); + $this->assertSame( 'Test Provider', $wp_ai_client_providers_metadata['test-provider']['name'] ); + } + + /** + * Test that get_all_providers_metadata returns ProviderMetadata objects. + * + * @ticket TBD + */ + public function test_get_all_providers_metadata_returns_provider_metadata_objects() { + $manager = new WP_AI_Client_Credentials_Manager(); + $providers = $manager->get_all_providers_metadata(); + + $this->assertIsArray( $providers ); + foreach ( $providers as $metadata ) { + $this->assertInstanceOf( WordPress\AiClient\Providers\DTO\ProviderMetadata::class, $metadata ); + } + } + + /** + * Test that get_all_cloud_providers_metadata only returns cloud providers. + * + * @ticket TBD + */ + public function test_get_all_cloud_providers_metadata_filters_to_cloud_only() { + $manager = new WP_AI_Client_Credentials_Manager(); + $cloud_providers = $manager->get_all_cloud_providers_metadata(); + + $this->assertIsArray( $cloud_providers ); + foreach ( $cloud_providers as $metadata ) { + $this->assertTrue( + $metadata->getType()->isCloud(), + sprintf( 'Provider "%s" should be a cloud provider.', $metadata->getId() ) + ); + } + } + + /** + * Test that register_settings creates the setting. + * + * @ticket TBD + */ + public function test_register_settings_creates_setting() { + $manager = new WP_AI_Client_Credentials_Manager(); + $manager->register_settings(); + + $registered = get_registered_settings(); + $this->assertArrayHasKey( + WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS, + $registered + ); + + // Clean up. + unregister_setting( 'ai', WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS ); + } + + /** + * Test that register_settings does not register twice. + * + * @ticket TBD + */ + public function test_register_settings_idempotent() { + $manager = new WP_AI_Client_Credentials_Manager(); + $manager->register_settings(); + $manager->register_settings(); + + $registered = get_registered_settings(); + $this->assertArrayHasKey( + WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS, + $registered + ); + + // Clean up. + unregister_setting( 'ai', WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS ); + } + + /** + * Test that sanitize_credentials filters out unknown providers. + * + * @ticket TBD + */ + public function test_sanitize_credentials_filters_unknown_providers() { + global $wp_ai_client_providers_metadata; + + // Seed a cloud provider in the global. + $wp_ai_client_providers_metadata = array( + 'test-cloud' => array( + 'id' => 'test-cloud', + 'name' => 'Test Cloud', + 'type' => 'cloud', + 'ai_client_classnames' => array( 'TestClient' => true ), + ), + ); + + $manager = new WP_AI_Client_Credentials_Manager(); + + $input = array( + 'test-cloud' => 'sk-valid-key', + 'nonexistent_provider' => 'sk-invalid-key', + ); + + $result = $manager->sanitize_credentials( $input ); + + $this->assertArrayHasKey( 'test-cloud', $result ); + $this->assertArrayNotHasKey( 'nonexistent_provider', $result ); + } + + /** + * Test that sanitize_credentials applies sanitize_text_field. + * + * @ticket TBD + */ + public function test_sanitize_credentials_sanitizes_values() { + global $wp_ai_client_providers_metadata; + + $wp_ai_client_providers_metadata = array( + 'test-cloud' => array( + 'id' => 'test-cloud', + 'name' => 'Test Cloud', + 'type' => 'cloud', + 'ai_client_classnames' => array( 'TestClient' => true ), + ), + ); + + $manager = new WP_AI_Client_Credentials_Manager(); + + $input = array( + 'test-cloud' => " sk-key-with-whitespace\t", + ); + + $result = $manager->sanitize_credentials( $input ); + + $this->assertSame( 'sk-key-with-whitespace', $result['test-cloud'] ); + } + + /** + * Test that sanitize_credentials returns empty array for non-array input. + * + * @ticket TBD + */ + public function test_sanitize_credentials_returns_empty_for_non_array() { + $manager = new WP_AI_Client_Credentials_Manager(); + + $this->assertSame( array(), $manager->sanitize_credentials( 'not-an-array' ) ); + $this->assertSame( array(), $manager->sanitize_credentials( null ) ); + } + + /** + * Test that sanitize_credentials removes non-string values. + * + * @ticket TBD + */ + public function test_sanitize_credentials_removes_non_string_values() { + global $wp_ai_client_providers_metadata; + + $wp_ai_client_providers_metadata = array( + 'test-cloud' => array( + 'id' => 'test-cloud', + 'name' => 'Test Cloud', + 'type' => 'cloud', + 'ai_client_classnames' => array( 'TestClient' => true ), + ), + ); + + $manager = new WP_AI_Client_Credentials_Manager(); + + $input = array( + 'test-cloud' => array( 'not', 'a', 'string' ), + ); + + $result = $manager->sanitize_credentials( $input ); + + $this->assertArrayNotHasKey( 'test-cloud', $result ); + } + + /** + * Test that sanitize_credentials filters out non-cloud providers. + * + * @ticket TBD + */ + public function test_sanitize_credentials_filters_non_cloud_providers() { + global $wp_ai_client_providers_metadata; + + $wp_ai_client_providers_metadata = array( + 'test-cloud' => array( + 'id' => 'test-cloud', + 'name' => 'Test Cloud', + 'type' => 'cloud', + 'ai_client_classnames' => array( 'TestClient' => true ), + ), + 'test-server' => array( + 'id' => 'test-server', + 'name' => 'Test Server', + 'type' => 'server', + 'ai_client_classnames' => array( 'TestClient' => true ), + ), + ); + + $manager = new WP_AI_Client_Credentials_Manager(); + + $input = array( + 'test-cloud' => 'sk-cloud-key', + 'test-server' => 'sk-server-key', + ); + + $result = $manager->sanitize_credentials( $input ); + + $this->assertArrayHasKey( 'test-cloud', $result ); + $this->assertArrayNotHasKey( 'test-server', $result ); + } + + /** + * Test that pass_credentials_to_client skips providers not in the registry. + * + * @ticket TBD + */ + public function test_pass_credentials_to_client_skips_unregistered_providers() { + $manager = new WP_AI_Client_Credentials_Manager(); + + // Set credentials for a provider that doesn't exist in the SDK registry. + update_option( + WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS, + array( 'nonexistent-provider' => 'sk-test-key' ) + ); + + // This should not throw any errors. + $manager->pass_credentials_to_client(); + + // Verify by checking the registry doesn't have the provider. + $registry = WordPress\AiClient\AiClient::defaultRegistry(); + $this->assertFalse( $registry->hasProvider( 'nonexistent-provider' ) ); + } + + /** + * Test that pass_credentials_to_client handles invalid option value gracefully. + * + * @ticket TBD + */ + public function test_pass_credentials_to_client_handles_invalid_option() { + $manager = new WP_AI_Client_Credentials_Manager(); + + // Set a non-array value for the option. + update_option( + WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS, + 'not-an-array' + ); + + // This should trigger _doing_it_wrong but not fatal. + $this->setExpectedIncorrectUsage( 'WP_AI_Client_Credentials_Manager::pass_credentials_to_client' ); + $manager->pass_credentials_to_client(); + } + + /** + * Test that pass_credentials_to_client skips empty API keys. + * + * @ticket TBD + */ + public function test_pass_credentials_to_client_skips_empty_keys() { + $manager = new WP_AI_Client_Credentials_Manager(); + + // Set credentials with empty values for a non-existent provider. + update_option( + WP_AI_Client_Credentials_Manager::OPTION_PROVIDER_CREDENTIALS, + array( 'some-provider' => '' ) + ); + + // Should not throw any errors - empty keys are silently skipped. + $manager->pass_credentials_to_client(); + $this->assertTrue( true ); + } +} diff --git a/tests/phpunit/tests/ai-client/wpAiClientJsonSchemaConverter.php b/tests/phpunit/tests/ai-client/wpAiClientJsonSchemaConverter.php new file mode 100644 index 0000000000000..3aafdfc6666fc --- /dev/null +++ b/tests/phpunit/tests/ai-client/wpAiClientJsonSchemaConverter.php @@ -0,0 +1,209 @@ + 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + ), + 'age' => array( + 'type' => 'integer', + ), + ), + 'required' => array( 'name' ), + ); + + $result = WP_AI_Client_JSON_Schema_Converter::convert( $schema ); + + $this->assertArrayNotHasKey( 'required', $result ); + $this->assertTrue( $result['properties']['name']['required'] ); + $this->assertArrayNotHasKey( 'required', $result['properties']['age'] ); + } + + /** + * Test schema without required array. + * + * @ticket TBD + */ + public function test_convert_without_required() { + $schema = array( + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + ), + ), + ); + + $result = WP_AI_Client_JSON_Schema_Converter::convert( $schema ); + + $this->assertArrayNotHasKey( 'required', $result['properties']['name'] ); + } + + /** + * Test nested sub-objects with required. + * + * @ticket TBD + */ + public function test_convert_nested_objects() { + $schema = array( + 'type' => 'object', + 'properties' => array( + 'address' => array( + 'type' => 'object', + 'properties' => array( + 'street' => array( + 'type' => 'string', + ), + 'city' => array( + 'type' => 'string', + ), + ), + 'required' => array( 'street', 'city' ), + ), + ), + 'required' => array( 'address' ), + ); + + $result = WP_AI_Client_JSON_Schema_Converter::convert( $schema ); + + $this->assertTrue( $result['properties']['address']['required'] ); + $this->assertTrue( $result['properties']['address']['properties']['street']['required'] ); + $this->assertTrue( $result['properties']['address']['properties']['city']['required'] ); + } + + /** + * Test array items with required. + * + * @ticket TBD + */ + public function test_convert_array_items() { + $schema = array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'string', + ), + 'name' => array( + 'type' => 'string', + ), + ), + 'required' => array( 'id' ), + ), + ); + + $result = WP_AI_Client_JSON_Schema_Converter::convert( $schema ); + + $this->assertTrue( $result['items']['properties']['id']['required'] ); + $this->assertArrayNotHasKey( 'required', $result['items']['properties']['name'] ); + } + + /** + * Test oneOf combiner. + * + * @ticket TBD + */ + public function test_convert_one_of() { + $schema = array( + 'oneOf' => array( + array( + 'type' => 'object', + 'properties' => array( + 'type' => array( + 'type' => 'string', + ), + ), + 'required' => array( 'type' ), + ), + array( + 'type' => 'string', + ), + ), + ); + + $result = WP_AI_Client_JSON_Schema_Converter::convert( $schema ); + + $this->assertTrue( $result['oneOf'][0]['properties']['type']['required'] ); + } + + /** + * Test anyOf combiner. + * + * @ticket TBD + */ + public function test_convert_any_of() { + $schema = array( + 'anyOf' => array( + array( + 'type' => 'object', + 'properties' => array( + 'name' => array( + 'type' => 'string', + ), + ), + 'required' => array( 'name' ), + ), + ), + ); + + $result = WP_AI_Client_JSON_Schema_Converter::convert( $schema ); + + $this->assertTrue( $result['anyOf'][0]['properties']['name']['required'] ); + } + + /** + * Test allOf combiner. + * + * @ticket TBD + */ + public function test_convert_all_of() { + $schema = array( + 'allOf' => array( + array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'string', + ), + ), + 'required' => array( 'id' ), + ), + ), + ); + + $result = WP_AI_Client_JSON_Schema_Converter::convert( $schema ); + + $this->assertTrue( $result['allOf'][0]['properties']['id']['required'] ); + } + + /** + * Test schema with no properties returns unchanged. + * + * @ticket TBD + */ + public function test_convert_no_properties() { + $schema = array( + 'type' => 'string', + 'description' => 'A simple string.', + ); + + $result = WP_AI_Client_JSON_Schema_Converter::convert( $schema ); + + $this->assertSame( $schema, $result ); + } +} diff --git a/tests/phpunit/tests/ai-client/wpRestAiGenerateController.php b/tests/phpunit/tests/ai-client/wpRestAiGenerateController.php new file mode 100644 index 0000000000000..21db0e0ffe138 --- /dev/null +++ b/tests/phpunit/tests/ai-client/wpRestAiGenerateController.php @@ -0,0 +1,242 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + + self::$subscriber_user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + ) + ); + } + + /** + * Set up before each test. + */ + public function set_up(): void { + parent::set_up(); + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + + do_action( 'rest_api_init' ); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Test that the generate route is registered. + * + * @ticket TBD + */ + public function test_generate_route_registered() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/wp-ai/v1/generate', $routes ); + } + + /** + * Test that the is-supported route is registered. + * + * @ticket TBD + */ + public function test_is_supported_route_registered() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/wp-ai/v1/is-supported', $routes ); + } + + /** + * Test permissions check for admin user. + * + * @ticket TBD + */ + public function test_permissions_check_admin() { + $controller = new WP_REST_AI_V1_Generate_Controller(); + + wp_set_current_user( self::$admin_user_id ); + $this->assertTrue( $controller->permissions_check() ); + } + + /** + * Test permissions check for subscriber. + * + * @ticket TBD + */ + public function test_permissions_check_subscriber() { + $controller = new WP_REST_AI_V1_Generate_Controller(); + + wp_set_current_user( self::$subscriber_user_id ); + $result = $controller->permissions_check(); + $this->assertWPError( $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + } + + /** + * Test permissions check for anonymous user. + * + * @ticket TBD + */ + public function test_permissions_check_anonymous() { + $controller = new WP_REST_AI_V1_Generate_Controller(); + + wp_set_current_user( 0 ); + $result = $controller->permissions_check(); + $this->assertWPError( $result ); + } + + /** + * Test generate endpoint returns 403 for non-authenticated users. + * + * @ticket TBD + */ + public function test_generate_endpoint_forbidden_anonymous() { + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'POST', '/wp-ai/v1/generate' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'messages' => array( + array( + 'role' => 'user', + 'parts' => array( + array( + 'channel' => 'content', + 'type' => 'text', + 'text' => 'Hello', + ), + ), + ), + ), + ) + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertSame( 401, $response->get_status() ); + } + + /** + * Test is-supported endpoint returns 403 for non-authenticated users. + * + * @ticket TBD + */ + public function test_is_supported_endpoint_forbidden_anonymous() { + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'POST', '/wp-ai/v1/is-supported' ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( + wp_json_encode( + array( + 'messages' => array( + array( + 'role' => 'user', + 'parts' => array( + array( + 'channel' => 'content', + 'type' => 'text', + 'text' => 'Hello', + ), + ), + ), + ), + ) + ) + ); + + $response = $this->server->dispatch( $request ); + $this->assertSame( 401, $response->get_status() ); + } + + /** + * Test the generation request schema has expected properties. + * + * @ticket TBD + */ + public function test_generation_request_schema() { + $controller = new WP_REST_AI_V1_Generate_Controller(); + $schema = $controller->get_generation_request_schema(); + + $this->assertSame( 'ai_generation_request', $schema['title'] ); + $this->assertArrayHasKey( 'messages', $schema['properties'] ); + $this->assertArrayHasKey( 'modelConfig', $schema['properties'] ); + $this->assertArrayHasKey( 'providerId', $schema['properties'] ); + $this->assertArrayHasKey( 'modelId', $schema['properties'] ); + $this->assertArrayHasKey( 'modelPreferences', $schema['properties'] ); + $this->assertArrayHasKey( 'capability', $schema['properties'] ); + $this->assertArrayHasKey( 'requestOptions', $schema['properties'] ); + } + + /** + * Test the is_supported schema. + * + * @ticket TBD + */ + public function test_is_supported_schema() { + $controller = new WP_REST_AI_V1_Generate_Controller(); + $schema = $controller->get_is_supported_schema(); + + $this->assertSame( 'ai_is_supported_response', $schema['title'] ); + $this->assertArrayHasKey( 'supported', $schema['properties'] ); + } + + /** + * Test the generation result schema. + * + * @ticket TBD + */ + public function test_generation_result_schema() { + $controller = new WP_REST_AI_V1_Generate_Controller(); + $schema = $controller->get_generation_result_schema(); + + $this->assertSame( 'ai_generation_result', $schema['title'] ); + } +} diff --git a/tests/phpunit/tests/ai-client/wpRestAiProvidersController.php b/tests/phpunit/tests/ai-client/wpRestAiProvidersController.php new file mode 100644 index 0000000000000..57cdf05a11090 --- /dev/null +++ b/tests/phpunit/tests/ai-client/wpRestAiProvidersController.php @@ -0,0 +1,262 @@ +user->create( + array( + 'role' => 'administrator', + ) + ); + + self::$subscriber_user_id = self::factory()->user->create( + array( + 'role' => 'subscriber', + ) + ); + } + + /** + * Set up before each test. + */ + public function set_up(): void { + parent::set_up(); + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + + do_action( 'rest_api_init' ); + } + + /** + * Tear down after each test. + */ + public function tear_down(): void { + global $wp_rest_server; + $wp_rest_server = null; + + parent::tear_down(); + } + + /** + * Test that the providers route is registered. + * + * @ticket TBD + */ + public function test_providers_route_registered() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/wp-ai/v1/providers', $routes ); + } + + /** + * Test that the single provider route is registered. + * + * @ticket TBD + */ + public function test_single_provider_route_registered() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/wp-ai/v1/providers/(?P[^/]+)', $routes ); + } + + /** + * Test that the provider models route is registered. + * + * @ticket TBD + */ + public function test_provider_models_route_registered() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/wp-ai/v1/providers/(?P[^/]+)/models', $routes ); + } + + /** + * Test that the single model route is registered. + * + * @ticket TBD + */ + public function test_single_model_route_registered() { + $routes = $this->server->get_routes(); + $this->assertArrayHasKey( '/wp-ai/v1/providers/(?P[^/]+)/models/(?P[^/]+)', $routes ); + } + + /** + * Test providers permissions check for admin. + * + * @ticket TBD + */ + public function test_providers_permissions_check_admin() { + $controller = new WP_REST_AI_V1_Providers_Controller(); + + wp_set_current_user( self::$admin_user_id ); + $this->assertTrue( $controller->permissions_check_providers() ); + } + + /** + * Test providers permissions check for subscriber. + * + * @ticket TBD + */ + public function test_providers_permissions_check_subscriber() { + $controller = new WP_REST_AI_V1_Providers_Controller(); + + wp_set_current_user( self::$subscriber_user_id ); + $result = $controller->permissions_check_providers(); + $this->assertWPError( $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + } + + /** + * Test models permissions check for admin. + * + * @ticket TBD + */ + public function test_models_permissions_check_admin() { + $controller = new WP_REST_AI_V1_Providers_Controller(); + + wp_set_current_user( self::$admin_user_id ); + $this->assertTrue( $controller->permissions_check_models() ); + } + + /** + * Test models permissions check for subscriber. + * + * @ticket TBD + */ + public function test_models_permissions_check_subscriber() { + $controller = new WP_REST_AI_V1_Providers_Controller(); + + wp_set_current_user( self::$subscriber_user_id ); + $result = $controller->permissions_check_models(); + $this->assertWPError( $result ); + $this->assertSame( 'rest_forbidden', $result->get_error_code() ); + } + + /** + * Test listing providers as admin. + * + * @ticket TBD + */ + public function test_get_providers_as_admin() { + wp_set_current_user( self::$admin_user_id ); + + $request = new WP_REST_Request( 'GET', '/wp-ai/v1/providers' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertIsArray( $response->get_data() ); + } + + /** + * Test listing providers as anonymous user. + * + * @ticket TBD + */ + public function test_get_providers_as_anonymous() { + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', '/wp-ai/v1/providers' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 401, $response->get_status() ); + } + + /** + * Test getting a non-existent provider. + * + * @ticket TBD + */ + public function test_get_nonexistent_provider() { + wp_set_current_user( self::$admin_user_id ); + + $request = new WP_REST_Request( 'GET', '/wp-ai/v1/providers/nonexistent' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 404, $response->get_status() ); + } + + /** + * Test getting models for a non-existent provider. + * + * @ticket TBD + */ + public function test_get_models_nonexistent_provider() { + wp_set_current_user( self::$admin_user_id ); + + $request = new WP_REST_Request( 'GET', '/wp-ai/v1/providers/nonexistent/models' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 404, $response->get_status() ); + } + + /** + * Test getting a single model for a non-existent provider. + * + * @ticket TBD + */ + public function test_get_model_nonexistent_provider() { + wp_set_current_user( self::$admin_user_id ); + + $request = new WP_REST_Request( 'GET', '/wp-ai/v1/providers/nonexistent/models/some-model' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 404, $response->get_status() ); + } + + /** + * Test the provider schema. + * + * @ticket TBD + */ + public function test_provider_schema() { + $controller = new WP_REST_AI_V1_Providers_Controller(); + $schema = $controller->get_provider_schema(); + + $this->assertSame( 'ai_provider', $schema['title'] ); + $this->assertArrayHasKey( 'properties', $schema ); + } + + /** + * Test the model schema. + * + * @ticket TBD + */ + public function test_model_schema() { + $controller = new WP_REST_AI_V1_Providers_Controller(); + $schema = $controller->get_model_schema(); + + $this->assertSame( 'ai_model', $schema['title'] ); + $this->assertArrayHasKey( 'properties', $schema ); + } +} diff --git a/tools/webpack/ai-client.js b/tools/webpack/ai-client.js new file mode 100644 index 0000000000000..2c9c0489ae710 --- /dev/null +++ b/tools/webpack/ai-client.js @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +const TerserPlugin = require( 'terser-webpack-plugin' ); + +/** + * Internal dependencies + */ +const { baseDir } = require( './shared' ); + +module.exports = function( env = { environment: 'production', watch: false, buildTarget: false } ) { + const entry = { + [ env.buildTarget + 'wp-includes/js/dist/ai-client.js' ]: [ './src/js/_enqueues/wp/ai-client/index.js' ], + [ env.buildTarget + 'wp-includes/js/dist/ai-client.min.js' ]: [ './src/js/_enqueues/wp/ai-client/index.js' ], + }; + + const aiClientConfig = { + target: 'browserslist', + mode: 'production', + cache: true, + entry, + output: { + path: baseDir, + filename: '[name]', + }, + externals: { + '@wordpress/api-fetch': 'wp.apiFetch', + '@wordpress/data': 'wp.data', + }, + optimization: { + minimize: true, + moduleIds: 'deterministic', + minimizer: [ + new TerserPlugin( { + include: /\.min\.js$/, + extractComments: false, + } ), + ], + }, + watch: env.watch, + }; + + return aiClientConfig; +}; diff --git a/webpack.config.js b/webpack.config.js index 29ebbd696b875..2c7cb64e8bc04 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,4 +1,5 @@ const mediaConfig = require( './tools/webpack/media' ); +const aiClientConfig = require( './tools/webpack/ai-client' ); const developmentConfig = require( './tools/webpack/development' ); module.exports = function ( @@ -18,6 +19,7 @@ module.exports = function ( // Note: developmentConfig returns an array of configs, so we spread it. const config = [ mediaConfig( env ), + aiClientConfig( env ), ...developmentConfig( env ), ];