PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.5.6
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.5.6
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 2 weeks ago chatml.php 1 week ago core.php 1 month ago custom.php 1 month ago factory.php 1 week ago google-interactions.php 1 week ago google.php 1 week ago mistral.php 1 week ago open-router.php 3 weeks ago openai.php 3 weeks ago ovh.php 1 week ago perplexity.php 6 months ago replicate.php 5 months ago xai.php 1 month ago
anthropic.php
1363 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 protected function build_body( $query, $streamCallback = null, $extra = null ) {
407 if ( $query instanceof Meow_MWAI_Query_Feedback ) {
408 $body = [
409 'model' => $query->model,
410 'max_tokens' => $query->maxTokens,
411 'stream' => !is_null( $streamCallback ),
412 'messages' => []
413 ];
414 if ( !empty( $query->temperature ) && $this->model_supports_temperature( $query->model ) ) {
415 $body['temperature'] = $query->temperature;
416 }
417
418 if ( !empty( $query->instructions ) ) {
419 $body['system'] = [
420 [
421 'type' => 'text',
422 'text' => $query->instructions,
423 'cache_control' => [ 'type' => 'ephemeral' ]
424 ]
425 ];
426 }
427
428 // Build the messages
429 $body['messages'][] = [ 'role' => 'user', 'content' => $query->message ];
430
431 if ( !empty( $query->blocks ) ) {
432 foreach ( $query->blocks as $feedback_block ) {
433 $contentBlock = $feedback_block['rawMessage']['content'];
434
435 // Server-managed tool blocks (MCP, web search, etc.) are handled internally by
436 // Anthropic. When the response also contains regular tool_use (stop_reason: tool_use),
437 // the server-managed tools may not have completed. We must strip these blocks from
438 // the replayed assistant message since the API rejects them without matching result
439 // blocks (which only the server can provide).
440 if ( is_array( $contentBlock ) ) {
441 $serverManagedTypes = [
442 'mcp_tool_use', 'mcp_tool_result',
443 'server_tool_use', 'web_search_tool_result',
444 // Code execution (code_execution_20250825) runs entirely on
445 // Anthropic's side, so the `_tool_use` blocks must be stripped
446 // when we replay the assistant message — only the server can
447 // produce the matching `_tool_result` blocks.
448 'code_execution_tool_use', 'code_execution_tool_result',
449 'bash_code_execution_tool_use', 'bash_code_execution_tool_result',
450 'text_editor_code_execution_tool_use', 'text_editor_code_execution_tool_result',
451 ];
452 // Block types that are server-produced RESULTS (we keep them out
453 // of the "stripped tools" warning list because they're not a
454 // dev-side limitation — they're just paired results).
455 $serverResultTypes = [
456 'mcp_tool_result', 'web_search_tool_result',
457 'code_execution_tool_result',
458 'bash_code_execution_tool_result',
459 'text_editor_code_execution_tool_result',
460 ];
461 $strippedTools = [];
462 foreach ( $contentBlock as $item ) {
463 $type = $item['type'] ?? '';
464 if ( in_array( $type, $serverManagedTypes, true ) && !in_array( $type, $serverResultTypes, true ) ) {
465 $strippedTools[] = ( $item['name'] ?? $type ) . ' (' . ( $item['server_name'] ?? 'server' ) . ')';
466 }
467 }
468 if ( !empty( $strippedTools ) ) {
469 Meow_MWAI_Logging::warn( 'Anthropic: Server-managed tool call (' . implode( ', ', $strippedTools ) .
470 ') was interrupted by a function call. This is currently a limitation of the Anthropic API ' .
471 'when MCP/server tools and function calling are used together.' );
472 }
473 $contentBlock = array_values( array_filter( $contentBlock, function ( $item ) use ( $serverManagedTypes ) {
474 $type = $item['type'] ?? '';
475 return !in_array( $type, $serverManagedTypes );
476 } ) );
477 }
478
479 // Process each content item individually to ensure proper handling of multiple tool_use blocks
480 if ( is_array( $contentBlock ) ) {
481 foreach ( $contentBlock as &$contentItem ) {
482 if ( isset( $contentItem['type'] ) && $contentItem['type'] === 'tool_use' ) {
483 // Debug logging for tool_use blocks
484 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
485 error_log( 'AI Engine: Anthropic tool_use block - ID: ' . ( $contentItem['id'] ?? 'unknown' ) .
486 ', Name: ' . ( $contentItem['name'] ?? 'unknown' ) .
487 ', Input type: ' . gettype( $contentItem['input'] ?? null ) .
488 ', Input value: ' . json_encode( $contentItem['input'] ?? null ) );
489 }
490
491 // Ensure input is an object, not an array
492 if ( isset( $contentItem['input'] ) ) {
493 if ( empty( $contentItem['input'] ) || ( is_array( $contentItem['input'] ) && count( $contentItem['input'] ) === 0 ) ) {
494 $contentItem['input'] = new stdClass();
495 }
496 else {
497 // Apply replaceEmptyArrayWithObject only to the input field
498 $contentItem['input'] = $this->replaceEmptyArrayWithObject( $contentItem['input'] );
499 }
500 }
501 else {
502 $contentItem['input'] = new stdClass();
503 }
504
505 // Debug logging after conversion
506 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
507 error_log( 'AI Engine: After conversion - Input type: ' . gettype( $contentItem['input'] ) .
508 ', Input value: ' . json_encode( $contentItem['input'] ) );
509 }
510 }
511 }
512 unset( $contentItem );
513 }
514
515 // Final debug logging before adding the message
516 if ( $this->core->get_option( 'queries_debug_mode' ) && is_array( $contentBlock ) ) {
517 error_log( 'AI Engine: Final contentBlock being added to messages: ' . json_encode( $contentBlock ) );
518 }
519
520 $assistantMessageIndex = count( $body['messages'] );
521 $body['messages'][] = [
522 'role' => 'assistant',
523 'content' => $contentBlock
524 ];
525
526 // Collect all tool results for this message
527 $toolResults = [];
528
529 foreach ( $feedback_block['feedbacks'] as $feedback ) {
530 $feedbackValue = $feedback['reply']['value'];
531 if ( !is_string( $feedbackValue ) ) {
532 $feedbackValue = json_encode( $feedbackValue );
533 }
534
535 $toolResults[] = [
536 'type' => 'tool_result',
537 'tool_use_id' => $feedback['request']['toolId'],
538 'content' => [
539 [
540 'type' => 'text',
541 'text' => $feedbackValue
542 ]
543 ],
544 'is_error' => false // Cool, Anthropic supports errors!
545 ];
546
547 // Note: Function result events are now emitted centrally in core.php
548 // when the function is actually executed
549 }
550
551 // Add all tool results in a single user message
552 // Anthropic requires all tool_results for a message to be in one content array
553 if ( !empty( $toolResults ) ) {
554 $body['messages'][] = [
555 'role' => 'user',
556 'content' => $toolResults
557 ];
558 }
559 }
560 }
561
562 // TODO: This WAS COPIED FROM BELOW
563 // Support for functions
564 if ( !empty( $query->functions ) ) {
565 $model = $this->retrieve_model_info( $query->model );
566 if ( !empty( $model['tags'] ) && !in_array( 'functions', $model['tags'] ) ) {
567 Meow_MWAI_Logging::warn( 'The model "' . $query->model . '" doesn\'t support Function Calling.' );
568 }
569 else {
570 $body['tools'] = [];
571 // Dynamic function: they will interactively enhance the completion (tools).
572 foreach ( $query->functions as $function ) {
573 $body['tools'][] = $function->serializeForAnthropic();
574 }
575 // Static functions: they will be executed at the end of the completion.
576 //$body['function_call'] = $query->functionCall;
577 }
578 }
579
580 // To avoid errors with Anthropic's API, we need to replace empty arrays with empty objects
581 // Note: We've already handled tool_use inputs above, so no need to process them again
582 return $body;
583 }
584 else if ( $query instanceof Meow_MWAI_Query_Text ) {
585 $body = [
586 'model' => $query->model,
587 'stream' => !is_null( $streamCallback ),
588 ];
589
590 if ( !empty( $query->maxTokens ) ) {
591 $body['max_tokens'] = $query->maxTokens;
592 }
593 else {
594 // https://docs.anthropic.com/en/docs/about-claude/models#model-comparison-table
595 $body['max_tokens'] = 4096;
596 }
597
598 if ( !empty( $query->temperature ) && $this->model_supports_temperature( $query->model ) ) {
599 $body['temperature'] = $query->temperature;
600 }
601
602 if ( !empty( $query->stop ) ) {
603 $body['stop'] = $query->stop;
604 }
605
606 // Build system prompt with caching support.
607 // Instructions are cached (static per chatbot), context is not (varies per query).
608 $systemBlocks = [];
609 if ( !empty( $query->instructions ) ) {
610 $systemBlocks[] = [
611 'type' => 'text',
612 'text' => $query->instructions,
613 'cache_control' => [ 'type' => 'ephemeral' ]
614 ];
615 }
616 if ( !empty( $query->context ) ) {
617 $framedContext = $this->core->frame_context( $query->context );
618 $systemBlocks[] = [
619 'type' => 'text',
620 'text' => $framedContext
621 ];
622 }
623 if ( !empty( $systemBlocks ) ) {
624 $body['system'] = $systemBlocks;
625 }
626
627 // Support for functions
628 if ( !empty( $query->functions ) ) {
629 $model = $this->retrieve_model_info( $query->model );
630 if ( !empty( $model['tags'] ) && !in_array( 'functions', $model['tags'] ) ) {
631 Meow_MWAI_Logging::warn( 'The model "' . $query->model . '" doesn\'t support Function Calling.' );
632 }
633 else {
634 $body['tools'] = [];
635 // Dynamic function: they will interactively enhance the completion (tools).
636 foreach ( $query->functions as $function ) {
637 $body['tools'][] = $function->serializeForAnthropic();
638 }
639 // Static functions: they will be executed at the end of the completion.
640 //$body['function_call'] = $query->functionCall;
641 }
642 }
643
644 $body['messages'] = $this->build_messages( $query );
645
646 // Add MCP servers if available
647 if ( isset( $query->mcpServers ) && is_array( $query->mcpServers ) && !empty( $query->mcpServers ) ) {
648 $mcp_envs = $this->core->get_option( 'mcp_envs' );
649 $this->mcpServerNames = []; // Reset MCP server names
650
651 // Resolve all MCP servers from their IDs
652 $resolved_servers = [];
653 foreach ( $query->mcpServers as $mcpServer ) {
654 if ( isset( $mcpServer['id'] ) ) {
655 foreach ( $mcp_envs as $env ) {
656 if ( $env['id'] === $mcpServer['id'] ) {
657 $resolved_servers[] = $env;
658 break;
659 }
660 }
661 }
662 }
663
664 // Allow filtering the full list of MCP servers
665 $resolved_servers = apply_filters( 'mwai_ai_mcp_servers', $resolved_servers, $query );
666
667 // Build API-specific MCP config
668 $body['mcp_servers'] = [];
669 foreach ( $resolved_servers as $env ) {
670 $mcp_config = [
671 'type' => 'url',
672 'url' => $env['url'],
673 'name' => $env['name'],
674 'tool_configuration' => [
675 'enabled' => true
676 ]
677 ];
678
679 // Add authorization token if available
680 if ( !empty( $env['token'] ) ) {
681 $mcp_config['authorization_token'] = $env['token'];
682 }
683
684 $body['mcp_servers'][] = $mcp_config;
685 $this->mcpServerNames[] = $env['name']; // Track MCP server names
686 }
687 }
688
689 // Add code_execution tool if code_interpreter is enabled
690 if ( !empty( $query->tools ) && is_array( $query->tools ) && in_array( 'code_interpreter', $query->tools ) ) {
691 if ( !isset( $body['tools'] ) ) {
692 $body['tools'] = [];
693 }
694 $body['tools'][] = [
695 'type' => 'code_execution_20250825',
696 'name' => 'code_execution'
697 ];
698 // Anthropic recommends a system-prompt hint whenever code_execution is
699 // paired with user-defined tools, otherwise Claude often refuses to
700 // use the sandbox because it can't tell which environment to run in.
701 // https://platform.claude.com/docs/en/docs/agents-and-tools/tool-use/code-execution-tool
702 $code_exec_hint = 'You have the built-in code_execution tool, which runs Python and bash commands'
703 . ' in an Anthropic-hosted sandboxed container. Use it whenever computation, shell commands,'
704 . ' file processing, or data analysis would help. It is separate from any client-provided'
705 . ' functions; state is not shared between them.';
706 if ( !isset( $body['system'] ) ) {
707 $body['system'] = [];
708 }
709 $body['system'][] = [ 'type' => 'text', 'text' => $code_exec_hint ];
710 Meow_MWAI_Logging::log( 'Anthropic: Added code_execution tool to request' );
711 }
712
713 return $body;
714 }
715 else {
716 throw new Exception( 'AI Engine: Unsupported query type.' );
717 }
718 }
719
720 protected function stream_data_handler( $json ) {
721 $content = null;
722 $type = !empty( $json['type'] ) ? $json['type'] : null;
723 if ( is_null( $type ) ) {
724 return $content;
725 }
726
727 if ( $type === 'message_start' ) {
728 $usage = $json['message']['usage'];
729 $this->streamInTokens = $usage['input_tokens'];
730 $this->inModel = $json['message']['model'];
731 $this->inId = $json['message']['id'];
732
733 // Send MCP discovery event if MCP servers are configured
734 if ( $this->currentDebugMode && $this->streamCallback ) {
735 if ( !empty( $this->mcpServerNames ) ) {
736 $serverCount = count( $this->mcpServerNames );
737
738 // Get MCP tools count
739 $mcpTools = apply_filters( 'mwai_mcp_tools', [] );
740 $toolCount = count( $mcpTools );
741
742 $event = Meow_MWAI_Event::mcp_discovery( $serverCount, $toolCount )
743 ->set_metadata( 'servers', $this->mcpServerNames );
744 call_user_func( $this->streamCallback, $event );
745 }
746 }
747 }
748 else if ( $type === 'content_block_start' ) {
749 $this->streamBlocks['content'][] = $json['content_block'];
750
751 // Send "Generating response..." when we start a text block
752 if ( $this->currentDebugMode && $this->streamCallback ) {
753 $block = $json['content_block'];
754 if ( $block['type'] === 'text' && !$this->textStarted ) {
755 $this->textStarted = true;
756 $event = Meow_MWAI_Event::generating_response();
757 call_user_func( $this->streamCallback, $event );
758 }
759 }
760 }
761 else if ( $type === 'content_block_delta' ) {
762 $index = $json['index'];
763 $block = $this->streamBlocks['content'][$index];
764 if ( $json['delta']['type'] === 'text_delta' ) {
765 $block['text'] .= $json['delta']['text'];
766 $isThinkingStart = strpos( $block['text'], '<thinking' ) === 0;
767 $isThinkingEnd = strpos( $block['text'], '</thinking>' ) === 0;
768
769 if ( $isThinkingStart ) {
770 $this->streamIsThinking = true;
771 // Send thinking start event
772 if ( $this->currentDebugMode && $this->streamCallback ) {
773 $event = Meow_MWAI_Event::thinking( 'Thinking...' );
774 call_user_func( $this->streamCallback, $event );
775 }
776 }
777 if ( $isThinkingEnd ) {
778 $this->streamIsThinking = false;
779 // Send thinking end event
780 if ( $this->currentDebugMode && $this->streamCallback ) {
781 $event = Meow_MWAI_Event::thinking( 'Thinking completed.' )
782 ->set_metadata( 'status', 'completed' );
783 call_user_func( $this->streamCallback, $event );
784 }
785 }
786 $content = $json['delta']['text'];
787 }
788 else if ( $json['delta']['type'] === 'input_json_delta' ) {
789 // Somehow, the input is set as an array, but it should be a string since it's JSON.
790 $block['input'] = is_array( $block['input'] ) ? '' : $block['input'];
791 $block['input'] .= $json['delta']['partial_json'];
792
793 // Skip sending tool arguments event - too verbose
794 // if ( $this->currentDebugMode && $this->streamCallback && isset($block['type']) && $block['type'] === 'tool_use' ) {
795 // $event = ( new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['TOOL_ARGS'] ) )
796 // ->set_content( 'Streaming tool arguments...' )
797 // ->set_metadata( 'tool_name', $block['name'] ?? 'unknown' )
798 // ->set_metadata( 'partial_args', $json['delta']['partial_json'] );
799 // call_user_func( $this->streamCallback, $event );
800 // }
801 }
802 $this->streamBlocks['content'][$index] = $block;
803 }
804 // At the end of a block, let's look for any 'input' not yet decoded from JSON
805 else if ( $type === 'content_block_stop' ) {
806 $index = $json['index'];
807 $block = $this->streamBlocks['content'][$index];
808 if ( isset( $block['input'] ) && is_string( $block['input'] ) ) {
809 $block['input'] = json_decode( $block['input'], true );
810 }
811
812 // For tool_use blocks, ensure empty inputs are objects, not arrays
813 if ( $block['type'] === 'tool_use' && isset( $block['input'] ) ) {
814 if ( empty( $block['input'] ) || ( is_array( $block['input'] ) && count( $block['input'] ) === 0 ) ) {
815 $block['input'] = new stdClass();
816 }
817 }
818
819 $this->streamBlocks['content'][$index] = $block;
820
821 // Send event for content block completion
822 if ( $this->currentDebugMode && $this->streamCallback ) {
823 if ( $block['type'] === 'mcp_tool_use' ) {
824 // Store the tool name for later lookup when we get the result
825 $this->mcpTools[$block['id']] = $block['name'];
826
827 $event = Meow_MWAI_Event::mcp_calling( $block['name'], $block['id'], $block['input'] ?? [] )
828 ->set_metadata( 'server_name', $block['server_name'] ?? 'unknown' );
829 call_user_func( $this->streamCallback, $event );
830 }
831 else if ( $block['type'] === 'mcp_tool_result' ) {
832 // Look up the tool name from the tool_use_id
833 $tool_use_id = $block['tool_use_id'] ?? '';
834 $tool_name = isset( $this->mcpTools[$tool_use_id] ) ? $this->mcpTools[$tool_use_id] : 'unknown';
835
836 $event = Meow_MWAI_Event::mcp_result( $tool_name, $tool_use_id )
837 ->set_metadata( 'content', $block['content'] ?? '' );
838 call_user_func( $this->streamCallback, $event );
839 }
840 else if ( $block['type'] === 'tool_use' ) {
841 // Regular tool use (non-MCP)
842 $event = Meow_MWAI_Event::function_calling( $block['name'] ?? 'unknown', $block['input'] ?? [] )
843 ->set_metadata( 'tool_id', $block['id'] ?? '' );
844 call_user_func( $this->streamCallback, $event );
845 }
846 else if ( $block['type'] === 'text' ) {
847 // Don't send any event here - the text generation is handled by content deltas
848 // and completion is handled by message_stop
849 }
850 else if ( $block['type'] === 'ping' ) {
851 // https://docs.anthropic.com/en/docs/build-with-claude/streaming#ping-events
852 }
853 else {
854 Meow_MWAI_Logging::log( 'Anthropic: Unknown block type in content_block_stop: ' . $block['type'] );
855 }
856 }
857 }
858 else if ( $type === 'message_delta' ) {
859 $usage = $json['usage'];
860 $this->streamOutTokens = $usage['output_tokens'];
861 // Carry the stop_reason so create_choices() can detect a safety refusal.
862 if ( isset( $json['delta']['stop_reason'] ) ) {
863 $this->streamBlocks['stop_reason'] = $json['delta']['stop_reason'];
864 }
865 }
866 else if ( $type === 'error' ) {
867 $error = $json['error'];
868 $message = $error['message'];
869
870 // Send error event
871 if ( $this->currentDebugMode && $this->streamCallback ) {
872 $event = Meow_MWAI_Event::error( $message )
873 ->set_metadata( 'error_type', $error['type'] ?? 'unknown' );
874 call_user_func( $this->streamCallback, $event );
875 }
876
877 throw new Exception( $message );
878 }
879 else if ( $type === 'message_stop' ) {
880 // Skip sending completion event - too verbose
881 // if ( $this->currentDebugMode && $this->streamCallback ) {
882 // $event = Meow_MWAI_Event::stream_completed()
883 // ->set_metadata( 'total_tokens', ($this->streamInTokens ?? 0) + ($this->streamOutTokens ?? 0) );
884 // call_user_func( $this->streamCallback, $event );
885 // }
886 }
887 else if ( $type === 'ping' ) {
888 // Documented keepalive heartbeat, nothing to do.
889 // https://docs.anthropic.com/en/docs/build-with-claude/streaming#event-types
890 }
891 else if ( $type === 'keepalive' ) {
892 // Forward keepalive as SSE comment to keep browser connection alive during long MCP calls
893 echo ": keepalive\n\n";
894 if ( ob_get_level() > 0 ) {
895 ob_end_flush();
896 }
897 flush();
898 }
899 else {
900 Meow_MWAI_Logging::log( "Anthropic: Unknown stream data type: $type" );
901 }
902
903 // Avoid some endings
904 $endings = [ '<|im_end|>', '</s>' ];
905 if ( in_array( $content, $endings ) ) {
906 $content = null;
907 }
908
909 // If the stream is thinking, we don't want to return anything yet.
910 if ( $this->streamIsThinking ) {
911 $content = null;
912 }
913
914 return ( $content === '0' || !empty( $content ) ) ? $content : null;
915 }
916
917 // This create the "choices" (even though, often, it is only one choice).
918 // It is basically the reply, but one that is understood by the Meow_MWAI_Reply class.
919 public function create_choices( $data ) {
920 $returned_choices = [];
921 $tool_calls = [];
922 $text_content = '';
923
924 // First, collect all tool calls and text content
925 foreach ( $data['content'] as $content ) {
926 if ( $content['type'] === 'tool_use' ) {
927 // Collect all tool calls
928 $arguments = $content['input'] ?? new stdClass();
929
930 // Ensure arguments is properly formatted
931 if ( empty( $arguments ) ) {
932 $arguments = new stdClass();
933 }
934 else if ( is_array( $arguments ) && count( $arguments ) === 0 ) {
935 $arguments = new stdClass();
936 }
937
938 $tool_calls[] = [
939 'id' => $content['id'],
940 'type' => 'function',
941 'function' => [
942 'name' => $content['name'],
943 'arguments' => $arguments,
944 ]
945 ];
946 }
947 else if ( $content['type'] === 'text' ) {
948 $text_content .= $content['text'];
949 }
950 }
951
952 // Create a single choice with both tool calls and text content (like OpenAI does)
953 $message = [];
954
955 if ( !empty( $text_content ) ) {
956 $message['content'] = $text_content;
957 }
958
959 if ( !empty( $tool_calls ) ) {
960 $message['tool_calls'] = $tool_calls;
961 }
962
963 // Only create a choice if there's content or tool calls
964 if ( !empty( $message ) ) {
965 $returned_choices[] = [
966 'message' => $message
967 ];
968 }
969
970 // Safety refusal (Claude Fable 5 and newer): the request was declined by a
971 // safety classifier, so there is no content. Surface a readable message
972 // instead of letting the reply come back empty.
973 if ( empty( $returned_choices ) && ( $data['stop_reason'] ?? '' ) === 'refusal' ) {
974 $category = $data['stop_details']['category'] ?? null;
975 if ( !empty( $category ) ) {
976 Meow_MWAI_Logging::log( 'Anthropic: Request refused by safety classifier (category: ' . $category . ').' );
977 }
978 $returned_choices[] = [
979 'message' => [
980 'content' => __( 'This request was declined by the model\'s safety system. Please rephrase your message and try again.', 'ai-engine' )
981 ]
982 ];
983 }
984
985 return $returned_choices;
986 }
987
988 /**
989 * Override reset to include Anthropic-specific state
990 */
991 protected function reset_request_state() {
992 parent::reset_request_state();
993
994 // Reset Anthropic-specific state
995 $this->mcpTools = [];
996 $this->mcpToolCount = 0;
997 // Note: mcpServerNames is configuration, not request state
998 }
999
1000 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
1001 // Reset request-specific state to prevent leakage between requests
1002 $this->reset_request_state();
1003
1004 $isStreaming = !is_null( $streamCallback );
1005
1006 // Initialize debug mode
1007 $this->init_debug_mode( $query );
1008
1009 // IMPORTANT: Prepare query BEFORE setting up streaming hooks
1010 // The streaming hook intercepts ALL wp_remote_* calls, so preparation must happen first
1011 $this->prepare_query( $query );
1012
1013 if ( $isStreaming ) {
1014 $this->streamCallback = $streamCallback;
1015 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
1016 }
1017
1018 $this->reset_stream();
1019 $data = null;
1020 $body = $this->build_body( $query, $streamCallback );
1021 $url = $this->build_url( $query );
1022 $headers = $this->build_headers( $query );
1023 $options = $this->build_options( $headers, $body );
1024
1025 // Emit "Request sent" event for feedback queries
1026 if ( $this->currentDebugMode && !empty( $streamCallback ) &&
1027 ( $query instanceof Meow_MWAI_Query_Feedback || $query instanceof Meow_MWAI_Query_AssistFeedback ) ) {
1028 $event = Meow_MWAI_Event::request_sent()
1029 ->set_metadata( 'is_feedback', true )
1030 ->set_metadata( 'feedback_count', count( $query->blocks ) );
1031 call_user_func( $streamCallback, $event );
1032 }
1033
1034 try {
1035 $res = $this->run_query( $url, $options, $streamCallback );
1036 $reply = new Meow_MWAI_Reply( $query );
1037 $returned_id = null;
1038 $returned_model = null;
1039 $returned_choices = [];
1040
1041 // Streaming Mode
1042 if ( $isStreaming ) {
1043 $returned_id = $this->inId;
1044 $returned_model = $this->inModel ? $this->inModel : $query->model;
1045 if ( !is_null( $this->streamInTokens && !is_null( $this->streamOutTokens ) ) ) {
1046 $returned_in_tokens = $this->streamInTokens;
1047 $returned_out_tokens = $this->streamOutTokens;
1048 }
1049 $data = $this->streamBlocks;
1050
1051 // Clean up streaming data as well
1052 if ( isset( $data['content'] ) && is_array( $data['content'] ) ) {
1053 foreach ( $data['content'] as &$content ) {
1054 if ( $content['type'] === 'tool_use' && isset( $content['input'] ) ) {
1055 if ( empty( $content['input'] ) || ( is_array( $content['input'] ) && count( $content['input'] ) === 0 ) ) {
1056 $content['input'] = new stdClass();
1057 }
1058 }
1059 }
1060 unset( $content );
1061 }
1062
1063 $returned_choices = $this->create_choices( $this->streamBlocks );
1064 }
1065 // Standard Mode
1066 else {
1067 $data = $res['data'];
1068
1069 // Clean up tool_use inputs in the raw data BEFORE it gets stored
1070 if ( isset( $data['content'] ) && is_array( $data['content'] ) ) {
1071 foreach ( $data['content'] as &$content ) {
1072 if ( $content['type'] === 'tool_use' && isset( $content['input'] ) ) {
1073 if ( empty( $content['input'] ) || ( is_array( $content['input'] ) && count( $content['input'] ) === 0 ) ) {
1074 $content['input'] = new stdClass();
1075 }
1076 }
1077 }
1078 unset( $content );
1079 }
1080
1081 $returned_id = $data['id'];
1082 $returned_model = $data['model'];
1083 $usage = $data['usage'];
1084 if ( !empty( $usage ) ) {
1085 $returned_in_tokens = isset( $usage['input_tokens'] ) ? $usage['input_tokens'] : null;
1086 $returned_out_tokens = isset( $usage['output_tokens'] ) ? $usage['output_tokens'] : null;
1087 }
1088 $returned_choices = $this->create_choices( $data );
1089 }
1090
1091 $reply->set_choices( $returned_choices, $data );
1092 if ( !empty( $returned_id ) ) {
1093 $reply->set_id( $returned_id );
1094 }
1095
1096 // Handle tokens.
1097 $this->handle_tokens_usage(
1098 $reply,
1099 $query,
1100 $returned_model,
1101 $returned_in_tokens,
1102 $returned_out_tokens
1103 );
1104
1105 return $reply;
1106 }
1107 catch ( Exception $e ) {
1108 $error = $e->getMessage();
1109 $json = json_decode( $error, true );
1110 if ( json_last_error() === JSON_ERROR_NONE ) {
1111 if ( isset( $json['error'] ) && isset( $json['error']['message'] ) ) {
1112 $error = $json['error']['message'];
1113 }
1114 }
1115 Meow_MWAI_Logging::error( '(Anthropic) ' . $error );
1116 $service = $this->get_service_name();
1117 $message = "From $service: " . $error;
1118 throw new Exception( $message );
1119 }
1120 finally {
1121 if ( $isStreaming ) {
1122 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
1123 }
1124 }
1125 }
1126
1127 protected function build_options( $headers, $json = null, $forms = null, $method = 'POST' ) {
1128 $body = null;
1129 if ( !empty( $forms ) ) {
1130 $boundary = wp_generate_password( 24, false );
1131 $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary;
1132 $body = $this->build_form_body( $forms, $boundary );
1133 }
1134 else if ( !empty( $json ) ) {
1135 // For Anthropic, we need to ensure empty objects stay as objects, not arrays
1136 // JSON_FORCE_OBJECT would force everything to be an object, which we don't want
1137 // Instead, we've already converted empty arrays to stdClass in build_body
1138 $body = $this->safe_json_encode( $json, 'request body' );
1139
1140 // Debug logging to verify JSON encoding
1141 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1142 // Check if the body contains tool_use blocks with empty inputs
1143 if ( strpos( $body, '"tool_use"' ) !== false ) {
1144 error_log( 'AI Engine: Anthropic JSON body after encoding (first 1000 chars): ' . substr( $body, 0, 1000 ) );
1145
1146 // Check specifically for "input":[] which would be wrong
1147 if ( strpos( $body, '"input":[]' ) !== false ) {
1148 error_log( 'AI Engine: WARNING - Found "input":[] in JSON body, this should be "input":{} for Anthropic API' );
1149 }
1150 }
1151 }
1152 }
1153 $options = [
1154 'headers' => $headers,
1155 'method' => $method,
1156 'timeout' => MWAI_TIMEOUT,
1157 'body' => $body,
1158 'sslverify' => MWAI_SSL_VERIFY
1159 ];
1160 return $options;
1161 }
1162
1163 protected function get_service_name() {
1164 return 'Anthropic';
1165 }
1166
1167 public function get_models() {
1168 return apply_filters( 'mwai_anthropic_models', MWAI_ANTHROPIC_MODELS );
1169 }
1170
1171 public static function get_models_static() {
1172 return MWAI_ANTHROPIC_MODELS;
1173 }
1174
1175 public function handle_tokens_usage(
1176 $reply,
1177 $query,
1178 $returned_model,
1179 $returned_in_tokens,
1180 $returned_out_tokens,
1181 $returned_price = null
1182 ) {
1183 $returned_in_tokens = !is_null( $returned_in_tokens ) ?
1184 $returned_in_tokens : $reply->get_in_tokens( $query );
1185 $returned_out_tokens = !is_null( $returned_out_tokens ) ?
1186 $returned_out_tokens : $reply->get_out_tokens();
1187 if ( !empty( $reply->id ) ) {
1188 // Would be cool to retrieve the usage from the API, but it's not possible.
1189 }
1190 $usage = $this->core->record_tokens_usage( $returned_model, $returned_in_tokens, $returned_out_tokens );
1191 $reply->set_usage( $usage );
1192
1193 // Set accuracy based on data availability
1194 if ( !is_null( $returned_in_tokens ) && !is_null( $returned_out_tokens ) ) {
1195 // Anthropic provides token counts from API = tokens accuracy
1196 $reply->set_usage_accuracy( 'tokens' );
1197 }
1198 else {
1199 // Fallback to estimated
1200 $reply->set_usage_accuracy( 'estimated' );
1201 }
1202 }
1203
1204 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
1205 return parent::get_price( $query, $reply );
1206 }
1207
1208 /**
1209 * Check the connection to Anthropic by listing available models.
1210 */
1211 public function connection_check() {
1212 try {
1213 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
1214 $url = trailingslashit( $endpoint ) . 'models';
1215
1216 $response = wp_remote_get( $url, [
1217 'headers' => [
1218 'x-api-key' => $this->apiKey,
1219 'anthropic-version' => '2023-06-01',
1220 ],
1221 'timeout' => 30,
1222 ] );
1223
1224 if ( is_wp_error( $response ) ) {
1225 throw new Exception( $response->get_error_message() );
1226 }
1227
1228 $code = wp_remote_retrieve_response_code( $response );
1229 $body = json_decode( wp_remote_retrieve_body( $response ), true );
1230
1231 if ( $code === 401 || ( isset( $body['error']['type'] ) && $body['error']['type'] === 'authentication_error' ) ) {
1232 throw new Exception( 'Invalid API key' );
1233 }
1234
1235 if ( $code !== 200 ) {
1236 throw new Exception( 'Connection failed: ' . ( $body['error']['message'] ?? "HTTP $code" ) );
1237 }
1238
1239 $availableModels = [];
1240 if ( isset( $body['data'] ) && is_array( $body['data'] ) ) {
1241 foreach ( array_slice( $body['data'], 0, 10 ) as $model ) {
1242 $availableModels[] = $model['id'] ?? 'unknown';
1243 }
1244 }
1245
1246 return [
1247 'models' => $availableModels,
1248 'service' => 'Anthropic'
1249 ];
1250 }
1251 catch ( Exception $e ) {
1252 throw new Exception( 'Connection failed: ' . $e->getMessage() );
1253 }
1254 }
1255
1256 /**
1257 * Upload a file to Anthropic Files API
1258 *
1259 * @param string $filename The name of the file
1260 * @param string $data The file content (binary)
1261 * @param string $purpose For Anthropic, this is the MIME type (API difference from OpenAI)
1262 * @return array The response from the API containing file_id
1263 * @throws Exception If upload fails
1264 */
1265 public function upload_file( $filename, $data, $purpose = 'application/pdf' ) {
1266 global $wp_filter;
1267
1268 // For Anthropic, $purpose is actually the MIME type (different from OpenAI's API)
1269 $mimeType = $purpose;
1270
1271 // Build multipart form data
1272 $boundary = wp_generate_password( 24, false );
1273 $body = '';
1274 $body .= "--$boundary\r\n";
1275 $body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n";
1276 $body .= 'Content-Type: ' . $mimeType . "\r\n\r\n";
1277 $body .= $data . "\r\n";
1278 $body .= "--$boundary\r\n";
1279 $body .= "Content-Disposition: form-data; name=\"mime_type\"\r\n\r\n";
1280 $body .= $mimeType . "\r\n";
1281 $body .= "--$boundary--\r\n";
1282
1283 // Temporarily remove ALL http_api_curl hooks to prevent streaming hook interference
1284 // Save current hooks
1285 $saved_hooks = null;
1286 if ( isset( $wp_filter['http_api_curl'] ) ) {
1287 $saved_hooks = $wp_filter['http_api_curl'];
1288 unset( $wp_filter['http_api_curl'] );
1289 }
1290
1291 // Upload using WordPress HTTP API
1292 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
1293 $url = $endpoint . '/files';
1294 $response = wp_remote_post( $url, [
1295 'headers' => [
1296 'x-api-key' => $this->apiKey,
1297 'anthropic-version' => '2023-06-01',
1298 'anthropic-beta' => 'files-api-2025-04-14',
1299 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
1300 ],
1301 'body' => $body,
1302 'timeout' => 60
1303 ] );
1304
1305 // Restore hooks
1306 if ( $saved_hooks !== null ) {
1307 $wp_filter['http_api_curl'] = $saved_hooks;
1308 }
1309
1310 if ( is_wp_error( $response ) ) {
1311 throw new Exception( 'File upload failed: ' . $response->get_error_message() );
1312 }
1313
1314 $response_body = wp_remote_retrieve_body( $response );
1315 $result = json_decode( $response_body, true );
1316
1317 // Check for API errors
1318 if ( isset( $result['error'] ) ) {
1319 throw new Exception( 'Anthropic Files API error: ' . ( $result['error']['message'] ?? 'Unknown error' ) );
1320 }
1321
1322 return $result;
1323 }
1324
1325 /**
1326 * Delete a file from Anthropic Files API
1327 *
1328 * @param string $fileId The Anthropic file ID to delete
1329 * @return array The response from the API
1330 * @throws Exception If deletion fails
1331 */
1332 public function delete_file( $fileId ) {
1333 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
1334 $url = $endpoint . '/files/' . $fileId;
1335
1336 $response = wp_remote_request( $url, [
1337 'method' => 'DELETE',
1338 'headers' => [
1339 'x-api-key' => $this->apiKey,
1340 'anthropic-version' => '2023-06-01',
1341 'anthropic-beta' => 'files-api-2025-04-14',
1342 ],
1343 'timeout' => 30
1344 ] );
1345
1346 if ( is_wp_error( $response ) ) {
1347 throw new Exception( 'File deletion failed: ' . $response->get_error_message() );
1348 }
1349
1350 $response_code = wp_remote_retrieve_response_code( $response );
1351 $response_body = wp_remote_retrieve_body( $response );
1352 $result = json_decode( $response_body, true );
1353
1354 // Check for API errors
1355 if ( $response_code >= 400 ) {
1356 $error_message = isset( $result['error']['message'] ) ? $result['error']['message'] : 'Unknown error';
1357 throw new Exception( 'Anthropic Files API error: ' . $error_message );
1358 }
1359
1360 return $result;
1361 }
1362 }
1363