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