PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.7.5
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.7.5
3.7.6 3.7.5 3.7.4 3.7.3 3.7.2 3.7.1 3.7.0 3.6.9 3.6.8 3.6.7 3.6.6 3.6.4 3.6.5 3.6.3 3.6.2 3.6.1 3.6.0 3.5.9 3.5.8 3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / classes / engines / anthropic.php
ai-engine / classes / engines Last commit date
anthropic.php 4 days ago chatml.php 4 days ago core.php 1 month ago custom.php 1 month ago factory.php 2 months ago google-interactions.php 1 month ago google.php 1 month ago mistral.php 2 months ago open-router.php 4 days ago openai.php 2 weeks ago ovh.php 1 month ago perplexity.php 8 months ago replicate.php 2 weeks ago xai.php 3 months ago
anthropic.php
1476 lines
1 <?php
2
3 class Meow_MWAI_Engines_Anthropic extends Meow_MWAI_Engines_ChatML {
4 // Streaming
5 protected $streamInTokens = null;
6 protected $streamOutTokens = null;
7 protected $streamBlocks;
8 protected $streamIsThinking = false;
9 protected $mcpServerNames = [];
10 protected $mcpTools = []; // Track MCP tools by ID
11 protected $mcpToolCount = 0;
12 protected $textStarted = false; // Track if text streaming has started
13 protected $requestSentEmitted = false; // Track if request sent event was emitted
14
15 public function __construct( $core, $env ) {
16 parent::__construct( $core, $env );
17 }
18
19 /**
20 * Prepare query by uploading files to Anthropic Files API.
21 *
22 * This method is called BEFORE streaming hooks are attached and BEFORE build_body().
23 * It uploads PDF files to Anthropic's Files API and converts them from 'refId' type
24 * to 'provider_file_id' type, which build_body() will then use to construct the API request.
25 *
26 * Flow:
27 * 1. prepare_query() uploads files to Anthropic Files API → gets file_id (e.g., file_abc123)
28 * 2. Replaces DroppedFile from type 'refId' to type 'provider_file_id'
29 * 3. build_body() reads provider_file_id and includes it in message content
30 *
31 * @param Meow_MWAI_Query_Base $query The query with potential file attachments
32 */
33 protected function prepare_query( $query ) {
34 // Get all attachments using the unified method
35 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
36
37 if ( empty( $attachments ) ) {
38 return;
39 }
40
41 // Check if code_interpreter is enabled
42 $hasCodeInterpreter = !empty( $query->tools ) && is_array( $query->tools ) && in_array( 'code_interpreter', $query->tools );
43
44 // MIME types supported by Code Execution tool (container_upload)
45 $codeExecutionMimes = [
46 'text/csv',
47 'application/vnd.ms-excel',
48 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
49 'application/msword',
50 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
51 'application/json',
52 'application/xml',
53 'text/xml',
54 'text/plain',
55 'text/markdown',
56 ];
57
58 // Process each attachment - upload files to Anthropic Files API
59 foreach ( $attachments as $index => $file ) {
60 $mimeType = $file->get_mimeType() ?? '';
61 $isPDF = strpos( $mimeType, 'application/pdf' ) === 0;
62 $isImage = $file->is_image();
63 $isCodeExecutionFile = $hasCodeInterpreter && in_array( $mimeType, $codeExecutionMimes );
64
65 // Skip files already uploaded (type = provider_file_id)
66 if ( $file->get_type() === 'provider_file_id' ) {
67 continue;
68 }
69
70 // Upload PDFs to Files API (always)
71 // Upload other documents to Files API only if code_interpreter is enabled
72 if ( $isPDF || $isCodeExecutionFile ) {
73 try {
74 // Get file data from WordPress uploads directory
75 $refId = $file->get_refId();
76 $data = $this->core->files->get_data( $refId );
77 $filename = $file->get_filename();
78
79 // Upload to Anthropic Files API
80 $uploadedFile = $this->upload_file( $filename, $data, $mimeType );
81 $fileId = $uploadedFile['id'] ?? null;
82
83 if ( $fileId ) {
84 // Store provider file_id in metadata for cleanup later
85 $localFileId = $this->core->files->get_id_from_refId( $refId );
86 if ( $localFileId ) {
87 $this->core->files->add_metadata( $localFileId, 'file_id', $fileId );
88 $this->core->files->add_metadata( $localFileId, 'provider', 'anthropic' );
89 }
90
91 // Replace the file object in attachedFiles array with provider_file_id type
92 if ( !empty( $query->attachedFiles ) && isset( $query->attachedFiles[$index] ) ) {
93 $query->attachedFiles[$index] = Meow_MWAI_Query_DroppedFile::from_provider_file_id(
94 $fileId,
95 'analysis',
96 $file->get_mimeType()
97 );
98 }
99 // Also update legacy attachedFile if this is the first file
100 if ( $index === 0 && !empty( $query->attachedFile ) ) {
101 $query->attachedFile = Meow_MWAI_Query_DroppedFile::from_provider_file_id(
102 $fileId,
103 'analysis',
104 $file->get_mimeType()
105 );
106 }
107
108 if ( $isCodeExecutionFile ) {
109 Meow_MWAI_Logging::log( "Anthropic: Uploaded file for code execution: {$filename} ({$mimeType}) -> {$fileId}" );
110 }
111 }
112 }
113 catch ( Exception $e ) {
114 error_log( '[AI Engine] Failed to upload file to Anthropic Files API: ' . $e->getMessage() );
115 // Keep the original file - build_messages() will fall back to base64 for PDFs
116 }
117 }
118 }
119 }
120
121 protected function isMCPTool( $toolName ) {
122 // Get all MCP tools from the filter
123 $mcpTools = apply_filters( 'mwai_mcp_tools', [] );
124
125 // Log available MCP tools for debugging
126 if ( empty( $mcpTools ) ) {
127 Meow_MWAI_Logging::log( 'Anthropic: No MCP tools available from filter' );
128 }
129
130 foreach ( $mcpTools as $tool ) {
131 if ( isset( $tool['name'] ) && $tool['name'] === $toolName ) {
132 Meow_MWAI_Logging::log( "Anthropic: Found MCP tool match: {$toolName}" );
133 return true;
134 }
135 }
136
137 // If we have MCP servers but tool not found, it might be an issue
138 if ( !empty( $this->mcpServerNames ) && !empty( $toolName ) ) {
139 Meow_MWAI_Logging::log( "Anthropic: Tool '{$toolName}' not found in MCP tools list" );
140 }
141
142 return false;
143 }
144
145 public function reset_stream() {
146 $this->streamContent = null;
147 $this->streamBuffer = null;
148 $this->streamFunctionCall = null;
149 $this->streamToolCalls = [];
150 $this->streamLastMessage = null;
151 $this->streamInTokens = null;
152 $this->streamOutTokens = null;
153 $this->streamIsThinking = false;
154 $this->mcpTools = []; // Reset MCP tools tracking
155 $this->textStarted = false; // Reset text started flag
156 $this->requestSentEmitted = false; // Reset request sent flag
157 $this->emittedFunctionResults = []; // Reset function result tracking
158
159 $this->streamBlocks = [
160 'role' => 'assistant',
161 'content' => []
162 ];
163
164 $this->inModel = null;
165 $this->inId = null;
166 }
167
168 protected function set_environment() {
169 $env = $this->env;
170 $this->apiKey = $env['apikey'];
171 }
172
173 protected function build_url( $query, $endpoint = null ) {
174 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
175 if ( $query instanceof Meow_MWAI_Query_Text || $query instanceof Meow_MWAI_Query_Feedback ) {
176 $url = trailingslashit( $endpoint ) . 'messages';
177 }
178 else {
179 throw new Exception( 'AI Engine: Unsupported query type.' );
180 }
181 return $url;
182 }
183
184 protected function build_headers( $query ) {
185 parent::build_headers( $query );
186 $headers = [
187 'Content-Type' => 'application/json',
188 'x-api-key' => $this->apiKey,
189 'anthropic-version' => '2023-06-01',
190 'anthropic-beta' => 'prompt-caching-2024-07-31, tools-2024-04-04, pdfs-2024-09-25, mcp-client-2025-04-04, files-api-2025-04-14, code-execution-2025-08-25',
191 'User-Agent' => 'AI Engine',
192 ];
193 return $headers;
194 }
195
196 public function final_checks( Meow_MWAI_Query_Base $query ) {
197 // We skip this completely.
198 // maxMessages is handed in build_messages().
199 }
200
201 /**
202 * Build messages array for Anthropic API request.
203 *
204 * This method constructs the 'messages' array that will be sent to Anthropic's API.
205 * It processes both conversation history and file attachments.
206 *
207 * CRITICAL: This method handles BOTH single file (attachedFile) and multi-file (attachedFiles).
208 * The attachedFiles array is the PRIMARY path for multi-file uploads.
209 *
210 * @param Meow_MWAI_Query_Text $query The query to build messages from
211 * @return array Messages formatted for Anthropic API
212 */
213 protected function build_messages( $query ) {
214 $messages = [];
215
216 // Add conversation history (previous messages)
217 foreach ( $query->messages as $message ) {
218 $messages[] = $message;
219 }
220
221 // Limit message history if maxMessages is set
222 if ( !empty( $query->maxMessages ) ) {
223 $messages = array_slice( $messages, -$query->maxMessages );
224 }
225
226 // Anthropic requires first message to have 'user' role
227 if ( !empty( $messages ) && $messages[0]['role'] !== 'user' ) {
228 array_shift( $messages );
229 }
230
231 // =====================================================================
232 // FILE UPLOAD: Process all attachments (unified approach)
233 // =====================================================================
234 // Uses getAttachments() which returns both attachedFiles and legacy attachedFile
235 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
236 if ( !empty( $attachments ) ) {
237 $message = $query->get_message();
238 if ( empty( $message ) ) {
239 $message = 'I uploaded files. Do not consider this message as part of the conversation.';
240 }
241
242 // Build content array: [text, document, document, ...] or [text, image, image, ...]
243 $content = [
244 [
245 'type' => 'text',
246 'text' => $message
247 ]
248 ];
249
250 // MIME types that require Code Execution tool (container_upload)
251 $codeExecutionMimes = [
252 'text/csv',
253 'application/vnd.ms-excel',
254 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
255 'application/msword',
256 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
257 'application/json',
258 'application/xml',
259 'text/xml',
260 'text/plain',
261 'text/markdown',
262 ];
263
264 // Process each file and add to content array
265 foreach ( $attachments as $file ) {
266 $mime = $file->get_mimeType();
267 $isPDF = $mime === 'application/pdf';
268 $isIMG = !$isPDF && $file->is_image();
269 $isProviderFile = $file->get_type() === 'provider_file_id';
270 $isCodeExecutionFile = in_array( $mime, $codeExecutionMimes, true );
271
272 // ===== CODE EXECUTION FILES (DOCX, XLSX, CSV, etc.) =====
273 // These are uploaded via Files API and used with container_upload block
274 if ( $isCodeExecutionFile && $isProviderFile ) {
275 $fileId = $file->get_refId();
276 $content[] = [
277 'type' => 'container_upload',
278 'file_id' => $fileId
279 ];
280 continue;
281 }
282
283 // ===== PDF FILES =====
284 if ( $isPDF ) {
285 $documentSource = null;
286 if ( $isProviderFile ) {
287 // File was uploaded in prepare_query() - use file_id reference
288 // This is the EXPECTED path after prepare_query() runs
289 $fileId = $file->get_refId();
290 $documentSource = [
291 'type' => 'file',
292 'file_id' => $fileId // e.g., file_011CTkNhtS6cU3CKcvTPCfvw
293 ];
294 }
295 else {
296 // Fallback: File not uploaded yet (shouldn't happen if prepare_query ran)
297 // This handles edge cases where prepare_query was skipped
298 try {
299 $refId = $file->get_refId();
300 $data = $this->core->files->get_data( $refId );
301 $filename = $file->get_filename();
302 $uploadedFile = $this->upload_file( $filename, $data, $mime );
303 $fileId = $uploadedFile['id'] ?? null;
304
305 if ( $fileId ) {
306 // Store provider file_id in metadata for cleanup later
307 $localFileId = $this->core->files->get_id_from_refId( $refId );
308 if ( $localFileId ) {
309 $this->core->files->add_metadata( $localFileId, 'file_id', $fileId );
310 $this->core->files->add_metadata( $localFileId, 'provider', 'anthropic' );
311 }
312
313 $documentSource = [
314 'type' => 'file',
315 'file_id' => $fileId
316 ];
317 }
318 else {
319 throw new Exception( 'Upload failed - no file_id returned' );
320 }
321 }
322 catch ( Exception $e ) {
323 error_log( '[AI Engine] Failed to upload PDF to Anthropic Files API: ' . $e->getMessage() . ', falling back to base64' );
324 // Last resort: base64 encoding (less efficient)
325 $data = $file->get_base64();
326 $documentSource = [
327 'type' => 'base64',
328 'media_type' => 'application/pdf',
329 'data' => $data
330 ];
331 }
332 }
333
334 // Add document to content array
335 $content[] = [
336 'type' => 'document',
337 'source' => $documentSource
338 ];
339 }
340 // ===== IMAGE FILES =====
341 else if ( $isIMG ) {
342 $imageSource = null;
343 if ( $isProviderFile ) {
344 // Use file_id reference (if uploaded to Files API)
345 $fileId = $file->get_refId();
346 $imageSource = [
347 'type' => 'file',
348 'file_id' => $fileId
349 ];
350 }
351 else {
352 // Use base64 encoding (standard for images)
353 $data = $file->get_base64();
354 $imageSource = [
355 'type' => 'base64',
356 'media_type' => $mime,
357 'data' => $data
358 ];
359 }
360
361 // Add image to content array
362 $content[] = [
363 'type' => 'image',
364 'source' => $imageSource
365 ];
366 }
367 }
368
369 // Add the complete message with all files to messages array
370 $messages[] = [
371 'role' => 'user',
372 'content' => $content // [text, document, document] or [text, image, image]
373 ];
374 }
375 else {
376 $messages[] = [ 'role' => 'user', 'content' => $query->get_message() ];
377 }
378
379 return $messages;
380 }
381
382 // Define a function to recursively replace empty arrays with empty stdClass objects
383 // To avoid errors with OpenAI's API
384 // Some Anthropic models (Opus 4.7+) deprecated the `temperature` parameter
385 // and reject requests that include it. Models opt out via the 'no-temperature' tag.
386 protected function model_supports_temperature( $model ) {
387 if ( empty( $model ) ) {
388 return true;
389 }
390 $info = $this->retrieve_model_info( $model );
391 return empty( $info['tags'] ) || !in_array( 'no-temperature', $info['tags'] );
392 }
393
394 private function replaceEmptyArrayWithObject( $item ) {
395 if ( is_array( $item ) ) {
396 if ( empty( $item ) ) {
397 return new stdClass(); // Replace empty array with empty object
398 }
399 foreach ( $item as $key => $value ) {
400 $item[$key] = $this->replaceEmptyArrayWithObject( $value ); // Recurse
401 }
402 }
403 return $item;
404 }
405
406 /**
407 * Anthropic's server-side web search. Claude runs the searches itself and returns
408 * cited results, so this only has to declare the tool alongside any client functions.
409 *
410 * Pinned to web_search_20250305 on purpose: from web_search_20260209 the tool defaults
411 * to running through code execution (allowed_callers), which 400s on models without
412 * programmatic tool calling and is unavailable on some clouds. The basic version works
413 * everywhere, which matters for a plugin that cannot know the user's model or host.
414 * https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool
415 */
416 private function maybe_add_web_search( &$body, $query ) {
417 if ( empty( $query->tools ) || !is_array( $query->tools ) ) {
418 return;
419 }
420 if ( !in_array( 'web_search', $query->tools, true ) ) {
421 return;
422 }
423 if ( !isset( $body['tools'] ) ) {
424 $body['tools'] = [];
425 }
426 $body['tools'][] = [
427 'type' => 'web_search_20250305',
428 'name' => 'web_search',
429 'max_uses' => 5,
430 ];
431 }
432
433 // Anthropic requires an integer max_tokens and rejects the request (400) when it exceeds
434 // the model's output limit, unlike OpenAI which caps it. A chatbot with Max Tokens set
435 // for a 128k model then breaks the moment it is switched to Haiku, so cap it here.
436 private function max_tokens_for( $query ) {
437 $maxTokens = !empty( $query->maxTokens ) ? (int) $query->maxTokens : 4096;
438 $info = $this->retrieve_model_info( $query->model );
439 if ( !empty( $info['maxCompletionTokens'] ) ) {
440 $maxTokens = min( $maxTokens, (int) $info['maxCompletionTokens'] );
441 }
442 return $maxTokens;
443 }
444
445 protected function build_body( $query, $streamCallback = null, $extra = null ) {
446 if ( $query instanceof Meow_MWAI_Query_Feedback ) {
447 $body = [
448 'model' => $query->model,
449 'max_tokens' => $this->max_tokens_for( $query ),
450 'stream' => !is_null( $streamCallback ),
451 'messages' => []
452 ];
453 // Null means "not set"; 0 is a valid temperature and must still be sent.
454 if ( isset( $query->temperature ) && $this->model_supports_temperature( $query->model ) ) {
455 $body['temperature'] = $query->temperature;
456 }
457
458 if ( !empty( $query->instructions ) ) {
459 $body['system'] = [
460 [
461 'type' => 'text',
462 'text' => $query->instructions,
463 'cache_control' => [ 'type' => 'ephemeral' ]
464 ]
465 ];
466 }
467
468 // Build the messages. Include the full prior conversation history, otherwise
469 // Claude loses all earlier context on any turn that triggers a function call.
470 // $query->messages holds ONLY the prior history: the current user message
471 // travels separately ($query->message), and the assistant's tool_call
472 // message is replayed by the blocks loop below. Never pop a trailing
473 // assistant entry here: it is genuine history (its removal made Claude
474 // re-answer the PREVIOUS question with the previous tool parameters).
475 $history = is_array( $query->messages ) ? $query->messages : [];
476 // Anthropic requires the first message to have the 'user' role.
477 if ( !empty( $history ) && ( $history[0]['role'] ?? '' ) !== 'user' ) {
478 array_shift( $history );
479 }
480 if ( empty( $history ) ) {
481 $body['messages'][] = [ 'role' => 'user', 'content' => $query->message ];
482 }
483 else {
484 foreach ( $history as $historyMessage ) {
485 $body['messages'][] = $historyMessage;
486 }
487 // The user message that triggered this turn is NOT part of the stored
488 // history (it travels separately as $query->message). Without it,
489 // Claude reads the PREVIOUS question as the current one and "corrects"
490 // the tool call with the previous parameters (e.g. re-searching the
491 // old keyword after a correct new-keyword search).
492 $lastHistory = end( $history );
493 $lastIsSameUserMessage = ( $lastHistory['role'] ?? '' ) === 'user' &&
494 is_string( $lastHistory['content'] ?? null ) &&
495 $lastHistory['content'] === $query->message;
496 if ( !empty( $query->message ) && !$lastIsSameUserMessage ) {
497 $body['messages'][] = [ 'role' => 'user', 'content' => $query->message ];
498 }
499 }
500
501 if ( !empty( $query->blocks ) ) {
502 foreach ( $query->blocks as $feedback_block ) {
503 $contentBlock = $feedback_block['rawMessage']['content'];
504
505 // Server-managed tool blocks (MCP, web search, etc.) are handled internally by
506 // Anthropic. When the response also contains regular tool_use (stop_reason: tool_use),
507 // the server-managed tools may not have completed. We must strip these blocks from
508 // the replayed assistant message since the API rejects them without matching result
509 // blocks (which only the server can provide).
510 if ( is_array( $contentBlock ) ) {
511 $serverManagedTypes = [
512 'mcp_tool_use', 'mcp_tool_result',
513 'server_tool_use', 'web_search_tool_result',
514 // Code execution (code_execution_20250825) runs entirely on
515 // Anthropic's side, so the `_tool_use` blocks must be stripped
516 // when we replay the assistant message — only the server can
517 // produce the matching `_tool_result` blocks.
518 'code_execution_tool_use', 'code_execution_tool_result',
519 'bash_code_execution_tool_use', 'bash_code_execution_tool_result',
520 'text_editor_code_execution_tool_use', 'text_editor_code_execution_tool_result',
521 ];
522 // Block types that are server-produced RESULTS (we keep them out
523 // of the "stripped tools" warning list because they're not a
524 // dev-side limitation — they're just paired results).
525 $serverResultTypes = [
526 'mcp_tool_result', 'web_search_tool_result',
527 'code_execution_tool_result',
528 'bash_code_execution_tool_result',
529 'text_editor_code_execution_tool_result',
530 ];
531 $strippedTools = [];
532 foreach ( $contentBlock as $item ) {
533 $type = $item['type'] ?? '';
534 if ( in_array( $type, $serverManagedTypes, true ) && !in_array( $type, $serverResultTypes, true ) ) {
535 $strippedTools[] = ( $item['name'] ?? $type ) . ' (' . ( $item['server_name'] ?? 'server' ) . ')';
536 }
537 }
538 if ( !empty( $strippedTools ) ) {
539 Meow_MWAI_Logging::warn( 'Anthropic: Server-managed tool call (' . implode( ', ', $strippedTools ) .
540 ') was interrupted by a function call. This is currently a limitation of the Anthropic API ' .
541 'when MCP/server tools and function calling are used together.' );
542 }
543 $contentBlock = array_values( array_filter( $contentBlock, function ( $item ) use ( $serverManagedTypes ) {
544 $type = $item['type'] ?? '';
545 return !in_array( $type, $serverManagedTypes );
546 } ) );
547 }
548
549 // Process each content item individually to ensure proper handling of multiple tool_use blocks
550 if ( is_array( $contentBlock ) ) {
551 foreach ( $contentBlock as &$contentItem ) {
552 if ( isset( $contentItem['type'] ) && $contentItem['type'] === 'tool_use' ) {
553 // Debug logging for tool_use blocks
554 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
555 error_log( 'AI Engine: Anthropic tool_use block - ID: ' . ( $contentItem['id'] ?? 'unknown' ) .
556 ', Name: ' . ( $contentItem['name'] ?? 'unknown' ) .
557 ', Input type: ' . gettype( $contentItem['input'] ?? null ) .
558 ', Input value: ' . json_encode( $contentItem['input'] ?? null ) );
559 }
560
561 // Ensure input is an object, not an array
562 if ( isset( $contentItem['input'] ) ) {
563 if ( empty( $contentItem['input'] ) || ( is_array( $contentItem['input'] ) && count( $contentItem['input'] ) === 0 ) ) {
564 $contentItem['input'] = new stdClass();
565 }
566 else {
567 // Apply replaceEmptyArrayWithObject only to the input field
568 $contentItem['input'] = $this->replaceEmptyArrayWithObject( $contentItem['input'] );
569 }
570 }
571 else {
572 $contentItem['input'] = new stdClass();
573 }
574
575 // Debug logging after conversion
576 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
577 error_log( 'AI Engine: After conversion - Input type: ' . gettype( $contentItem['input'] ) .
578 ', Input value: ' . json_encode( $contentItem['input'] ) );
579 }
580 }
581 }
582 unset( $contentItem );
583 }
584
585 // Final debug logging before adding the message
586 if ( $this->core->get_option( 'queries_debug_mode' ) && is_array( $contentBlock ) ) {
587 error_log( 'AI Engine: Final contentBlock being added to messages: ' . json_encode( $contentBlock ) );
588 }
589
590 $assistantMessageIndex = count( $body['messages'] );
591 $body['messages'][] = [
592 'role' => 'assistant',
593 'content' => $contentBlock
594 ];
595
596 // Collect all tool results for this message
597 $toolResults = [];
598
599 foreach ( $feedback_block['feedbacks'] as $feedback ) {
600 $feedbackValue = $feedback['reply']['value'];
601 if ( !is_string( $feedbackValue ) ) {
602 $feedbackValue = json_encode( $feedbackValue );
603 }
604
605 $toolResults[] = [
606 'type' => 'tool_result',
607 'tool_use_id' => $feedback['request']['toolId'],
608 'content' => [
609 [
610 'type' => 'text',
611 'text' => $feedbackValue
612 ]
613 ],
614 'is_error' => false // Cool, Anthropic supports errors!
615 ];
616
617 // Note: Function result events are now emitted centrally in core.php
618 // when the function is actually executed
619 }
620
621 // Add all tool results in a single user message
622 // Anthropic requires all tool_results for a message to be in one content array
623 if ( !empty( $toolResults ) ) {
624 $body['messages'][] = [
625 'role' => 'user',
626 'content' => $toolResults
627 ];
628 }
629 }
630 }
631
632 // TODO: This WAS COPIED FROM BELOW
633 // Support for functions
634 if ( !empty( $query->functions ) ) {
635 $model = $this->retrieve_model_info( $query->model );
636 if ( !empty( $model['tags'] ) && !in_array( 'functions', $model['tags'] ) ) {
637 Meow_MWAI_Logging::warn( 'The model "' . $query->model . '" doesn\'t support Function Calling.' );
638 }
639 else {
640 $body['tools'] = [];
641 // Dynamic function: they will interactively enhance the completion (tools).
642 foreach ( $query->functions as $function ) {
643 $body['tools'][] = $function->serializeForAnthropic();
644 }
645 // Static functions: they will be executed at the end of the completion.
646 //$body['function_call'] = $query->functionCall;
647 }
648 }
649
650 $this->maybe_add_web_search( $body, $query );
651
652 // To avoid errors with Anthropic's API, we need to replace empty arrays with empty objects
653 // Note: We've already handled tool_use inputs above, so no need to process them again
654 return $body;
655 }
656 else if ( $query instanceof Meow_MWAI_Query_Text ) {
657 $body = [
658 'model' => $query->model,
659 'stream' => !is_null( $streamCallback ),
660 ];
661
662 $body['max_tokens'] = $this->max_tokens_for( $query );
663
664 // Null means "not set"; 0 is a valid temperature and must still be sent.
665 if ( isset( $query->temperature ) && $this->model_supports_temperature( $query->model ) ) {
666 $body['temperature'] = $query->temperature;
667 }
668
669 if ( !empty( $query->stop ) ) {
670 $body['stop'] = $query->stop;
671 }
672
673 // Build system prompt with caching support.
674 // Instructions are cached (static per chatbot), context is not (varies per query).
675 $systemBlocks = [];
676 if ( !empty( $query->instructions ) ) {
677 $systemBlocks[] = [
678 'type' => 'text',
679 'text' => $query->instructions,
680 'cache_control' => [ 'type' => 'ephemeral' ]
681 ];
682 }
683 if ( !empty( $query->context ) ) {
684 $framedContext = $this->core->frame_context( $query->context );
685 $systemBlocks[] = [
686 'type' => 'text',
687 'text' => $framedContext
688 ];
689 }
690 if ( !empty( $systemBlocks ) ) {
691 $body['system'] = $systemBlocks;
692 }
693
694 // Support for functions
695 if ( !empty( $query->functions ) ) {
696 $model = $this->retrieve_model_info( $query->model );
697 if ( !empty( $model['tags'] ) && !in_array( 'functions', $model['tags'] ) ) {
698 Meow_MWAI_Logging::warn( 'The model "' . $query->model . '" doesn\'t support Function Calling.' );
699 }
700 else {
701 $body['tools'] = [];
702 // Dynamic function: they will interactively enhance the completion (tools).
703 foreach ( $query->functions as $function ) {
704 $body['tools'][] = $function->serializeForAnthropic();
705 }
706 // Static functions: they will be executed at the end of the completion.
707 //$body['function_call'] = $query->functionCall;
708 }
709 }
710
711 $body['messages'] = $this->build_messages( $query );
712
713 // Add MCP servers if available
714 if ( isset( $query->mcpServers ) && is_array( $query->mcpServers ) && !empty( $query->mcpServers ) ) {
715 $mcp_envs = $this->core->get_option( 'mcp_envs' );
716 $this->mcpServerNames = []; // Reset MCP server names
717
718 // Resolve all MCP servers from their IDs
719 $resolved_servers = [];
720 foreach ( $query->mcpServers as $mcpServer ) {
721 if ( isset( $mcpServer['id'] ) ) {
722 foreach ( $mcp_envs as $env ) {
723 if ( $env['id'] === $mcpServer['id'] ) {
724 $resolved_servers[] = $env;
725 break;
726 }
727 }
728 }
729 }
730
731 // Allow filtering the full list of MCP servers
732 $resolved_servers = apply_filters( 'mwai_ai_mcp_servers', $resolved_servers, $query );
733
734 // Build API-specific MCP config
735 $body['mcp_servers'] = [];
736 foreach ( $resolved_servers as $env ) {
737 $mcp_config = [
738 'type' => 'url',
739 'url' => $env['url'],
740 'name' => $env['name'],
741 'tool_configuration' => [
742 'enabled' => true
743 ]
744 ];
745
746 // Add authorization token if available
747 if ( !empty( $env['token'] ) ) {
748 $mcp_config['authorization_token'] = $env['token'];
749 }
750
751 $body['mcp_servers'][] = $mcp_config;
752 $this->mcpServerNames[] = $env['name']; // Track MCP server names
753 }
754 }
755
756 // Add code_execution tool if code_interpreter is enabled
757 if ( !empty( $query->tools ) && is_array( $query->tools ) && in_array( 'code_interpreter', $query->tools ) ) {
758 if ( !isset( $body['tools'] ) ) {
759 $body['tools'] = [];
760 }
761 $body['tools'][] = [
762 'type' => 'code_execution_20250825',
763 'name' => 'code_execution'
764 ];
765 // Anthropic recommends a system-prompt hint whenever code_execution is
766 // paired with user-defined tools, otherwise Claude often refuses to
767 // use the sandbox because it can't tell which environment to run in.
768 // https://platform.claude.com/docs/en/docs/agents-and-tools/tool-use/code-execution-tool
769 $code_exec_hint = 'You have the built-in code_execution tool, which runs Python and bash commands'
770 . ' in an Anthropic-hosted sandboxed container. Use it whenever computation, shell commands,'
771 . ' file processing, or data analysis would help. It is separate from any client-provided'
772 . ' functions; state is not shared between them.';
773 if ( !isset( $body['system'] ) ) {
774 $body['system'] = [];
775 }
776 $body['system'][] = [ 'type' => 'text', 'text' => $code_exec_hint ];
777 Meow_MWAI_Logging::log( 'Anthropic: Added code_execution tool to request' );
778 }
779
780 $this->maybe_add_web_search( $body, $query );
781
782 return $body;
783 }
784 else {
785 throw new Exception( 'AI Engine: Unsupported query type.' );
786 }
787 }
788
789 protected function stream_data_handler( $json ) {
790 $content = null;
791 $type = !empty( $json['type'] ) ? $json['type'] : null;
792 if ( is_null( $type ) ) {
793 return $content;
794 }
795
796 if ( $type === 'message_start' ) {
797 $usage = $json['message']['usage'];
798 $this->streamInTokens = $usage['input_tokens'];
799 $this->inModel = $json['message']['model'];
800 $this->inId = $json['message']['id'];
801
802 // Send MCP discovery event if MCP servers are configured
803 if ( $this->currentDebugMode && $this->streamCallback ) {
804 if ( !empty( $this->mcpServerNames ) ) {
805 $serverCount = count( $this->mcpServerNames );
806
807 // Get MCP tools count
808 $mcpTools = apply_filters( 'mwai_mcp_tools', [] );
809 $toolCount = count( $mcpTools );
810
811 $event = Meow_MWAI_Event::mcp_discovery( $serverCount, $toolCount )
812 ->set_metadata( 'servers', $this->mcpServerNames );
813 call_user_func( $this->streamCallback, $event );
814 }
815 }
816 }
817 else if ( $type === 'content_block_start' ) {
818 $this->streamBlocks['content'][] = $json['content_block'];
819
820 // Send "Generating response..." when we start a text block
821 if ( $this->currentDebugMode && $this->streamCallback ) {
822 $block = $json['content_block'];
823 if ( $block['type'] === 'text' && !$this->textStarted ) {
824 $this->textStarted = true;
825 $event = Meow_MWAI_Event::generating_response();
826 call_user_func( $this->streamCallback, $event );
827 }
828 else if ( $block['type'] === 'thinking' || $block['type'] === 'redacted_thinking' ) {
829 $event = Meow_MWAI_Event::thinking( 'Thinking...' );
830 call_user_func( $this->streamCallback, $event );
831 }
832 }
833 }
834 else if ( $type === 'content_block_delta' ) {
835 $index = $json['index'];
836 $block = $this->streamBlocks['content'][$index];
837 if ( $json['delta']['type'] === 'text_delta' ) {
838 $block['text'] .= $json['delta']['text'];
839 $isThinkingStart = strpos( $block['text'], '<thinking' ) === 0;
840 $isThinkingEnd = strpos( $block['text'], '</thinking>' ) === 0;
841
842 if ( $isThinkingStart ) {
843 $this->streamIsThinking = true;
844 // Send thinking start event
845 if ( $this->currentDebugMode && $this->streamCallback ) {
846 $event = Meow_MWAI_Event::thinking( 'Thinking...' );
847 call_user_func( $this->streamCallback, $event );
848 }
849 }
850 if ( $isThinkingEnd ) {
851 $this->streamIsThinking = false;
852 // Send thinking end event
853 if ( $this->currentDebugMode && $this->streamCallback ) {
854 $event = Meow_MWAI_Event::thinking( 'Thinking completed.' )
855 ->set_metadata( 'status', 'completed' );
856 call_user_func( $this->streamCallback, $event );
857 }
858 }
859 $content = $json['delta']['text'];
860 }
861 else if ( $json['delta']['type'] === 'thinking_delta' ) {
862 // Models with adaptive thinking (Sonnet 5+) stream thinking blocks. They must be
863 // accumulated in full (text + signature below), because the feedback query echoes
864 // the raw assistant message back and the API rejects incomplete thinking blocks.
865 $block['thinking'] = ( $block['thinking'] ?? '' ) . $json['delta']['thinking'];
866 }
867 else if ( $json['delta']['type'] === 'signature_delta' ) {
868 $block['signature'] = ( $block['signature'] ?? '' ) . $json['delta']['signature'];
869 }
870 else if ( $json['delta']['type'] === 'input_json_delta' ) {
871 // Somehow, the input is set as an array, but it should be a string since it's JSON.
872 $block['input'] = is_array( $block['input'] ) ? '' : $block['input'];
873 $block['input'] .= $json['delta']['partial_json'];
874
875 // Skip sending tool arguments event - too verbose
876 // if ( $this->currentDebugMode && $this->streamCallback && isset($block['type']) && $block['type'] === 'tool_use' ) {
877 // $event = ( new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['TOOL_ARGS'] ) )
878 // ->set_content( 'Streaming tool arguments...' )
879 // ->set_metadata( 'tool_name', $block['name'] ?? 'unknown' )
880 // ->set_metadata( 'partial_args', $json['delta']['partial_json'] );
881 // call_user_func( $this->streamCallback, $event );
882 // }
883 }
884 $this->streamBlocks['content'][$index] = $block;
885 }
886 // At the end of a block, let's look for any 'input' not yet decoded from JSON
887 else if ( $type === 'content_block_stop' ) {
888 $index = $json['index'];
889 $block = $this->streamBlocks['content'][$index];
890 if ( isset( $block['input'] ) && is_string( $block['input'] ) ) {
891 $block['input'] = json_decode( $block['input'], true );
892 }
893
894 // For tool_use blocks, ensure empty inputs are objects, not arrays
895 if ( $block['type'] === 'tool_use' && isset( $block['input'] ) ) {
896 if ( empty( $block['input'] ) || ( is_array( $block['input'] ) && count( $block['input'] ) === 0 ) ) {
897 $block['input'] = new stdClass();
898 }
899 }
900
901 $this->streamBlocks['content'][$index] = $block;
902
903 // Send event for content block completion
904 if ( $this->currentDebugMode && $this->streamCallback ) {
905 if ( $block['type'] === 'mcp_tool_use' ) {
906 // Store the tool name for later lookup when we get the result
907 $this->mcpTools[$block['id']] = $block['name'];
908
909 $event = Meow_MWAI_Event::mcp_calling( $block['name'], $block['id'], $block['input'] ?? [] )
910 ->set_metadata( 'server_name', $block['server_name'] ?? 'unknown' );
911 call_user_func( $this->streamCallback, $event );
912 }
913 else if ( $block['type'] === 'mcp_tool_result' ) {
914 // Look up the tool name from the tool_use_id
915 $tool_use_id = $block['tool_use_id'] ?? '';
916 $tool_name = isset( $this->mcpTools[$tool_use_id] ) ? $this->mcpTools[$tool_use_id] : 'unknown';
917
918 $event = Meow_MWAI_Event::mcp_result( $tool_name, $tool_use_id )
919 ->set_metadata( 'content', $block['content'] ?? '' );
920 call_user_func( $this->streamCallback, $event );
921 }
922 else if ( $block['type'] === 'tool_use' ) {
923 // Regular tool use (non-MCP)
924 $event = Meow_MWAI_Event::function_calling( $block['name'] ?? 'unknown', $block['input'] ?? [] )
925 ->set_metadata( 'tool_id', $block['id'] ?? '' );
926 call_user_func( $this->streamCallback, $event );
927 }
928 else if ( $block['type'] === 'thinking' || $block['type'] === 'redacted_thinking' ) {
929 $event = Meow_MWAI_Event::thinking( 'Thinking completed.' )
930 ->set_metadata( 'status', 'completed' );
931 call_user_func( $this->streamCallback, $event );
932 }
933 else if ( $block['type'] === 'text' ) {
934 // Don't send any event here - the text generation is handled by content deltas
935 // and completion is handled by message_stop
936 }
937 else if ( $block['type'] === 'ping' ) {
938 // https://docs.anthropic.com/en/docs/build-with-claude/streaming#ping-events
939 }
940 else {
941 Meow_MWAI_Logging::log( 'Anthropic: Unknown block type in content_block_stop: ' . $block['type'] );
942 }
943 }
944 }
945 else if ( $type === 'message_delta' ) {
946 $usage = $json['usage'];
947 $this->streamOutTokens = $usage['output_tokens'];
948 // Carry the stop_reason so create_choices() can detect a safety refusal.
949 if ( isset( $json['delta']['stop_reason'] ) ) {
950 $this->streamBlocks['stop_reason'] = $json['delta']['stop_reason'];
951 }
952 }
953 else if ( $type === 'error' ) {
954 $error = $json['error'];
955 $message = $error['message'];
956
957 // Send error event
958 if ( $this->currentDebugMode && $this->streamCallback ) {
959 $event = Meow_MWAI_Event::error( $message )
960 ->set_metadata( 'error_type', $error['type'] ?? 'unknown' );
961 call_user_func( $this->streamCallback, $event );
962 }
963
964 throw new Exception( $message );
965 }
966 else if ( $type === 'message_stop' ) {
967 // Skip sending completion event - too verbose
968 // if ( $this->currentDebugMode && $this->streamCallback ) {
969 // $event = Meow_MWAI_Event::stream_completed()
970 // ->set_metadata( 'total_tokens', ($this->streamInTokens ?? 0) + ($this->streamOutTokens ?? 0) );
971 // call_user_func( $this->streamCallback, $event );
972 // }
973 }
974 else if ( $type === 'ping' ) {
975 // Documented keepalive heartbeat, nothing to do.
976 // https://docs.anthropic.com/en/docs/build-with-claude/streaming#event-types
977 }
978 else if ( $type === 'keepalive' ) {
979 // Forward keepalive as SSE comment to keep browser connection alive during long MCP calls
980 echo ": keepalive\n\n";
981 if ( ob_get_level() > 0 ) {
982 ob_end_flush();
983 }
984 flush();
985 }
986 else {
987 Meow_MWAI_Logging::log( "Anthropic: Unknown stream data type: $type" );
988 }
989
990 // Avoid some endings
991 $endings = [ '<|im_end|>', '</s>' ];
992 if ( in_array( $content, $endings ) ) {
993 $content = null;
994 }
995
996 // If the stream is thinking, we don't want to return anything yet.
997 if ( $this->streamIsThinking ) {
998 $content = null;
999 }
1000
1001 return ( $content === '0' || !empty( $content ) ) ? $content : null;
1002 }
1003
1004 // This create the "choices" (even though, often, it is only one choice).
1005 // It is basically the reply, but one that is understood by the Meow_MWAI_Reply class.
1006 public function create_choices( $data ) {
1007 $returned_choices = [];
1008 $tool_calls = [];
1009 $text_content = '';
1010
1011 // First, collect all tool calls and text content
1012 foreach ( $data['content'] as $content ) {
1013 if ( $content['type'] === 'tool_use' ) {
1014 // Collect all tool calls
1015 $arguments = $content['input'] ?? new stdClass();
1016
1017 // Ensure arguments is properly formatted
1018 if ( empty( $arguments ) ) {
1019 $arguments = new stdClass();
1020 }
1021 else if ( is_array( $arguments ) && count( $arguments ) === 0 ) {
1022 $arguments = new stdClass();
1023 }
1024
1025 $tool_calls[] = [
1026 'id' => $content['id'],
1027 'type' => 'function',
1028 'function' => [
1029 'name' => $content['name'],
1030 'arguments' => $arguments,
1031 ]
1032 ];
1033 }
1034 else if ( $content['type'] === 'text' ) {
1035 $text_content .= $content['text'];
1036 }
1037 }
1038
1039 // Create a single choice with both tool calls and text content (like OpenAI does)
1040 $message = [];
1041
1042 if ( !empty( $text_content ) ) {
1043 $message['content'] = $text_content;
1044 }
1045
1046 if ( !empty( $tool_calls ) ) {
1047 $message['tool_calls'] = $tool_calls;
1048 }
1049
1050 // Only create a choice if there's content or tool calls
1051 if ( !empty( $message ) ) {
1052 $returned_choices[] = [
1053 'message' => $message
1054 ];
1055 }
1056
1057 // Safety refusal (Claude Fable 5 and newer): the request was declined by a
1058 // safety classifier, so there is no content. Surface a readable message
1059 // instead of letting the reply come back empty.
1060 if ( empty( $returned_choices ) && ( $data['stop_reason'] ?? '' ) === 'refusal' ) {
1061 $category = $data['stop_details']['category'] ?? null;
1062 if ( !empty( $category ) ) {
1063 Meow_MWAI_Logging::log( 'Anthropic: Request refused by safety classifier (category: ' . $category . ').' );
1064 }
1065 $returned_choices[] = [
1066 'message' => [
1067 'content' => __( 'This request was declined by the model\'s safety system. Please rephrase your message and try again.', 'ai-engine' )
1068 ]
1069 ];
1070 }
1071
1072 return $returned_choices;
1073 }
1074
1075 /**
1076 * Override reset to include Anthropic-specific state
1077 */
1078 protected function reset_request_state() {
1079 parent::reset_request_state();
1080
1081 // Reset Anthropic-specific state
1082 $this->mcpTools = [];
1083 $this->mcpToolCount = 0;
1084 // Note: mcpServerNames is configuration, not request state
1085 }
1086
1087 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
1088 // Reset request-specific state to prevent leakage between requests
1089 $this->reset_request_state();
1090
1091 $isStreaming = !is_null( $streamCallback );
1092
1093 // Initialize debug mode
1094 $this->init_debug_mode( $query );
1095
1096 // IMPORTANT: Prepare query BEFORE setting up streaming hooks
1097 // The streaming hook intercepts ALL wp_remote_* calls, so preparation must happen first
1098 $this->prepare_query( $query );
1099
1100 if ( $isStreaming ) {
1101 $this->streamCallback = $streamCallback;
1102 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
1103 }
1104
1105 $this->reset_stream();
1106 $data = null;
1107 $body = $this->build_body( $query, $streamCallback );
1108 $url = $this->build_url( $query );
1109 $headers = $this->build_headers( $query );
1110 $options = $this->build_options( $headers, $body );
1111
1112 // Emit "Request sent" event for feedback queries
1113 if ( $this->currentDebugMode && !empty( $streamCallback ) &&
1114 ( $query instanceof Meow_MWAI_Query_Feedback || $query instanceof Meow_MWAI_Query_AssistFeedback ) ) {
1115 $event = Meow_MWAI_Event::request_sent()
1116 ->set_metadata( 'is_feedback', true )
1117 ->set_metadata( 'feedback_count', count( $query->blocks ) );
1118 call_user_func( $streamCallback, $event );
1119 }
1120
1121 try {
1122 $reply = new Meow_MWAI_Reply( $query );
1123 $returned_id = null;
1124 $returned_model = null;
1125 $returned_choices = [];
1126
1127 // The MCP connector can pause a long turn (stop_reason: pause_turn);
1128 // the conversation must be sent back with the partial assistant content
1129 // so Claude continues where it left off. Capped to avoid infinite loops.
1130 $accumulated_content = [];
1131 $continuations = 0;
1132
1133 while ( true ) {
1134 $res = $this->run_query( $url, $options, $streamCallback );
1135
1136 // Streaming Mode
1137 if ( $isStreaming ) {
1138 $returned_id = $this->inId;
1139 $returned_model = $this->inModel ? $this->inModel : $query->model;
1140 if ( !is_null( $this->streamInTokens && !is_null( $this->streamOutTokens ) ) ) {
1141 $returned_in_tokens = ( $returned_in_tokens ?? 0 ) + (int) $this->streamInTokens;
1142 $returned_out_tokens = ( $returned_out_tokens ?? 0 ) + (int) $this->streamOutTokens;
1143 }
1144 $data = $this->streamBlocks;
1145 }
1146 // Standard Mode
1147 else {
1148 $data = $res['data'];
1149 $returned_id = $data['id'];
1150 $returned_model = $data['model'];
1151 $usage = $data['usage'];
1152 if ( !empty( $usage ) ) {
1153 $returned_in_tokens = ( $returned_in_tokens ?? 0 ) + (int) ( $usage['input_tokens'] ?? 0 );
1154 $returned_out_tokens = ( $returned_out_tokens ?? 0 ) + (int) ( $usage['output_tokens'] ?? 0 );
1155 }
1156 }
1157
1158 // Clean up tool_use/mcp_tool_use inputs in the raw data BEFORE it gets
1159 // stored or resent (Anthropic requires input to be an object, and an
1160 // empty {} decodes to [] in PHP).
1161 if ( isset( $data['content'] ) && is_array( $data['content'] ) ) {
1162 foreach ( $data['content'] as &$content ) {
1163 if ( in_array( $content['type'] ?? '', [ 'tool_use', 'mcp_tool_use' ], true ) ) {
1164 // Streamed blocks can end with input null (no args), an empty
1165 // array (decoded {}), or a JSON string (accumulated deltas).
1166 if ( isset( $content['input'] ) && is_string( $content['input'] ) && $content['input'] !== '' ) {
1167 $decoded = json_decode( $content['input'], true );
1168 $content['input'] = is_array( $decoded ) && count( $decoded ) ? $decoded : new stdClass();
1169 }
1170 if ( empty( $content['input'] ) ) {
1171 $content['input'] = new stdClass();
1172 }
1173 }
1174 }
1175 unset( $content );
1176 }
1177
1178 $accumulated_content = array_merge( $accumulated_content, $data['content'] ?? [] );
1179
1180 // A turn needs continuing when it is explicitly paused (pause_turn), or
1181 // when a streamed MCP turn is cut without any stop_reason while tool
1182 // calls are still unresolved (Anthropic drops the stream while its MCP
1183 // client executes slow tools). Resending the partial assistant content
1184 // either resumes the turn, or surfaces Anthropic's real MCP error.
1185 $turnContent = $data['content'] ?? [];
1186 $lastBlockType = count( $turnContent ) ? ( $turnContent[count( $turnContent ) - 1]['type'] ?? '' ) : '';
1187 $unresolvedMcp = empty( $data['stop_reason'] ) && $lastBlockType === 'mcp_tool_use';
1188 if ( ( ( $data['stop_reason'] ?? '' ) !== 'pause_turn' && !$unresolvedMcp ) || $continuations >= 5 ) {
1189 break;
1190 }
1191
1192 $continuations++;
1193 Meow_MWAI_Logging::log( 'Anthropic: Turn paused (' . ( $data['stop_reason'] ?? 'stream cut on MCP' ) . '), continuing (' . $continuations . ').' );
1194 $body['messages'][] = [ 'role' => 'assistant', 'content' => $data['content'] ];
1195 $options = $this->build_options( $headers, $body );
1196 if ( $isStreaming ) {
1197 $this->reset_stream();
1198 }
1199 }
1200
1201 $data['content'] = $accumulated_content;
1202 $returned_choices = $this->create_choices( $data );
1203
1204 $reply->set_choices( $returned_choices, $data );
1205 if ( !empty( $returned_id ) ) {
1206 $reply->set_id( $returned_id );
1207 }
1208
1209 // Handle tokens.
1210 $this->handle_tokens_usage(
1211 $reply,
1212 $query,
1213 $returned_model,
1214 $returned_in_tokens,
1215 $returned_out_tokens
1216 );
1217
1218 return $reply;
1219 }
1220 catch ( Exception $e ) {
1221 $error = $e->getMessage();
1222 $json = json_decode( $error, true );
1223 if ( json_last_error() === JSON_ERROR_NONE ) {
1224 if ( isset( $json['error'] ) && isset( $json['error']['message'] ) ) {
1225 $error = $json['error']['message'];
1226 }
1227 }
1228 Meow_MWAI_Logging::error( '(Anthropic) ' . $error );
1229 $service = $this->get_service_name();
1230 $message = "From $service: " . $error;
1231 throw new Exception( $message );
1232 }
1233 finally {
1234 if ( $isStreaming ) {
1235 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
1236 }
1237 }
1238 }
1239
1240 protected function build_options( $headers, $json = null, $forms = null, $method = 'POST' ) {
1241 $body = null;
1242 if ( !empty( $forms ) ) {
1243 $boundary = wp_generate_password( 24, false );
1244 $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary;
1245 $body = $this->build_form_body( $forms, $boundary );
1246 }
1247 else if ( !empty( $json ) ) {
1248 // For Anthropic, we need to ensure empty objects stay as objects, not arrays
1249 // JSON_FORCE_OBJECT would force everything to be an object, which we don't want
1250 // Instead, we've already converted empty arrays to stdClass in build_body
1251 $body = $this->safe_json_encode( $json, 'request body' );
1252
1253 // Debug logging to verify JSON encoding
1254 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1255 // Check if the body contains tool_use blocks with empty inputs
1256 if ( strpos( $body, '"tool_use"' ) !== false ) {
1257 error_log( 'AI Engine: Anthropic JSON body after encoding (first 1000 chars): ' . substr( $body, 0, 1000 ) );
1258
1259 // Check specifically for "input":[] which would be wrong
1260 if ( strpos( $body, '"input":[]' ) !== false ) {
1261 error_log( 'AI Engine: WARNING - Found "input":[] in JSON body, this should be "input":{} for Anthropic API' );
1262 }
1263 }
1264 }
1265 }
1266 $options = [
1267 'headers' => $headers,
1268 'method' => $method,
1269 'timeout' => MWAI_TIMEOUT,
1270 'body' => $body,
1271 'sslverify' => MWAI_SSL_VERIFY
1272 ];
1273 return $options;
1274 }
1275
1276 protected function get_service_name() {
1277 return 'Anthropic';
1278 }
1279
1280 public function get_models() {
1281 return apply_filters( 'mwai_anthropic_models', MWAI_ANTHROPIC_MODELS );
1282 }
1283
1284 public static function get_models_static() {
1285 return MWAI_ANTHROPIC_MODELS;
1286 }
1287
1288 public function handle_tokens_usage(
1289 $reply,
1290 $query,
1291 $returned_model,
1292 $returned_in_tokens,
1293 $returned_out_tokens,
1294 $returned_price = null
1295 ) {
1296 $returned_in_tokens = !is_null( $returned_in_tokens ) ?
1297 $returned_in_tokens : $reply->get_in_tokens( $query );
1298 $returned_out_tokens = !is_null( $returned_out_tokens ) ?
1299 $returned_out_tokens : $reply->get_out_tokens();
1300 if ( !empty( $reply->id ) ) {
1301 // Would be cool to retrieve the usage from the API, but it's not possible.
1302 }
1303 $usage = $this->core->record_tokens_usage( $returned_model, $returned_in_tokens, $returned_out_tokens );
1304 $reply->set_usage( $usage );
1305
1306 // Set accuracy based on data availability
1307 if ( !is_null( $returned_in_tokens ) && !is_null( $returned_out_tokens ) ) {
1308 // Anthropic provides token counts from API = tokens accuracy
1309 $reply->set_usage_accuracy( 'tokens' );
1310 }
1311 else {
1312 // Fallback to estimated
1313 $reply->set_usage_accuracy( 'estimated' );
1314 }
1315 }
1316
1317 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
1318 return parent::get_price( $query, $reply );
1319 }
1320
1321 /**
1322 * Check the connection to Anthropic by listing available models.
1323 */
1324 public function connection_check() {
1325 try {
1326 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
1327 $url = trailingslashit( $endpoint ) . 'models';
1328
1329 $response = wp_remote_get( $url, [
1330 'headers' => [
1331 'x-api-key' => $this->apiKey,
1332 'anthropic-version' => '2023-06-01',
1333 ],
1334 'timeout' => 30,
1335 ] );
1336
1337 if ( is_wp_error( $response ) ) {
1338 throw new Exception( $response->get_error_message() );
1339 }
1340
1341 $code = wp_remote_retrieve_response_code( $response );
1342 $body = json_decode( wp_remote_retrieve_body( $response ), true );
1343
1344 if ( $code === 401 || ( isset( $body['error']['type'] ) && $body['error']['type'] === 'authentication_error' ) ) {
1345 throw new Exception( 'Invalid API key' );
1346 }
1347
1348 if ( $code !== 200 ) {
1349 throw new Exception( 'Connection failed: ' . ( $body['error']['message'] ?? "HTTP $code" ) );
1350 }
1351
1352 $availableModels = [];
1353 if ( isset( $body['data'] ) && is_array( $body['data'] ) ) {
1354 foreach ( array_slice( $body['data'], 0, 10 ) as $model ) {
1355 $availableModels[] = $model['id'] ?? 'unknown';
1356 }
1357 }
1358
1359 return [
1360 'models' => $availableModels,
1361 'service' => 'Anthropic'
1362 ];
1363 }
1364 catch ( Exception $e ) {
1365 throw new Exception( 'Connection failed: ' . $e->getMessage() );
1366 }
1367 }
1368
1369 /**
1370 * Upload a file to Anthropic Files API
1371 *
1372 * @param string $filename The name of the file
1373 * @param string $data The file content (binary)
1374 * @param string $purpose For Anthropic, this is the MIME type (API difference from OpenAI)
1375 * @return array The response from the API containing file_id
1376 * @throws Exception If upload fails
1377 */
1378 public function upload_file( $filename, $data, $purpose = 'application/pdf' ) {
1379 global $wp_filter;
1380
1381 // For Anthropic, $purpose is actually the MIME type (different from OpenAI's API)
1382 $mimeType = $purpose;
1383
1384 // Build multipart form data
1385 $boundary = wp_generate_password( 24, false );
1386 $body = '';
1387 $body .= "--$boundary\r\n";
1388 $body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n";
1389 $body .= 'Content-Type: ' . $mimeType . "\r\n\r\n";
1390 $body .= $data . "\r\n";
1391 $body .= "--$boundary\r\n";
1392 $body .= "Content-Disposition: form-data; name=\"mime_type\"\r\n\r\n";
1393 $body .= $mimeType . "\r\n";
1394 $body .= "--$boundary--\r\n";
1395
1396 // Temporarily remove ALL http_api_curl hooks to prevent streaming hook interference
1397 // Save current hooks
1398 $saved_hooks = null;
1399 if ( isset( $wp_filter['http_api_curl'] ) ) {
1400 $saved_hooks = $wp_filter['http_api_curl'];
1401 unset( $wp_filter['http_api_curl'] );
1402 }
1403
1404 // Upload using WordPress HTTP API
1405 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
1406 $url = $endpoint . '/files';
1407 $response = wp_remote_post( $url, [
1408 'headers' => [
1409 'x-api-key' => $this->apiKey,
1410 'anthropic-version' => '2023-06-01',
1411 'anthropic-beta' => 'files-api-2025-04-14',
1412 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
1413 ],
1414 'body' => $body,
1415 'timeout' => 60
1416 ] );
1417
1418 // Restore hooks
1419 if ( $saved_hooks !== null ) {
1420 $wp_filter['http_api_curl'] = $saved_hooks;
1421 }
1422
1423 if ( is_wp_error( $response ) ) {
1424 throw new Exception( 'File upload failed: ' . $response->get_error_message() );
1425 }
1426
1427 $response_body = wp_remote_retrieve_body( $response );
1428 $result = json_decode( $response_body, true );
1429
1430 // Check for API errors
1431 if ( isset( $result['error'] ) ) {
1432 throw new Exception( 'Anthropic Files API error: ' . ( $result['error']['message'] ?? 'Unknown error' ) );
1433 }
1434
1435 return $result;
1436 }
1437
1438 /**
1439 * Delete a file from Anthropic Files API
1440 *
1441 * @param string $fileId The Anthropic file ID to delete
1442 * @return array The response from the API
1443 * @throws Exception If deletion fails
1444 */
1445 public function delete_file( $fileId ) {
1446 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
1447 $url = $endpoint . '/files/' . $fileId;
1448
1449 $response = wp_remote_request( $url, [
1450 'method' => 'DELETE',
1451 'headers' => [
1452 'x-api-key' => $this->apiKey,
1453 'anthropic-version' => '2023-06-01',
1454 'anthropic-beta' => 'files-api-2025-04-14',
1455 ],
1456 'timeout' => 30
1457 ] );
1458
1459 if ( is_wp_error( $response ) ) {
1460 throw new Exception( 'File deletion failed: ' . $response->get_error_message() );
1461 }
1462
1463 $response_code = wp_remote_retrieve_response_code( $response );
1464 $response_body = wp_remote_retrieve_body( $response );
1465 $result = json_decode( $response_body, true );
1466
1467 // Check for API errors
1468 if ( $response_code >= 400 ) {
1469 $error_message = isset( $result['error']['message'] ) ? $result['error']['message'] : 'Unknown error';
1470 throw new Exception( 'Anthropic Files API error: ' . $error_message );
1471 }
1472
1473 return $result;
1474 }
1475 }
1476