PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.5.7
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.5.7
3.7.4 3.7.3 3.7.2 3.7.1 3.7.0 3.6.9 3.6.8 3.6.7 3.6.6 3.6.4 3.6.5 3.6.3 3.6.2 3.6.1 3.6.0 3.5.9 3.5.8 3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / classes / engines / anthropic.php
ai-engine / classes / engines Last commit date
anthropic.php 2 months ago chatml.php 2 months ago core.php 2 months ago custom.php 3 months ago factory.php 2 months ago google-interactions.php 2 months ago google.php 2 months ago mistral.php 2 months ago open-router.php 2 months ago openai.php 2 months ago ovh.php 2 months ago perplexity.php 8 months ago replicate.php 7 months ago xai.php 3 months ago
anthropic.php
1365 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
431 $body['messages'][] = [ 'role' => 'user', 'content' => $query->message ];
432
433 if ( !empty( $query->blocks ) ) {
434 foreach ( $query->blocks as $feedback_block ) {
435 $contentBlock = $feedback_block['rawMessage']['content'];
436
437 // Server-managed tool blocks (MCP, web search, etc.) are handled internally by
438 // Anthropic. When the response also contains regular tool_use (stop_reason: tool_use),
439 // the server-managed tools may not have completed. We must strip these blocks from
440 // the replayed assistant message since the API rejects them without matching result
441 // blocks (which only the server can provide).
442 if ( is_array( $contentBlock ) ) {
443 $serverManagedTypes = [
444 'mcp_tool_use', 'mcp_tool_result',
445 'server_tool_use', 'web_search_tool_result',
446 // Code execution (code_execution_20250825) runs entirely on
447 // Anthropic's side, so the `_tool_use` blocks must be stripped
448 // when we replay the assistant message — only the server can
449 // produce the matching `_tool_result` blocks.
450 'code_execution_tool_use', 'code_execution_tool_result',
451 'bash_code_execution_tool_use', 'bash_code_execution_tool_result',
452 'text_editor_code_execution_tool_use', 'text_editor_code_execution_tool_result',
453 ];
454 // Block types that are server-produced RESULTS (we keep them out
455 // of the "stripped tools" warning list because they're not a
456 // dev-side limitation — they're just paired results).
457 $serverResultTypes = [
458 'mcp_tool_result', 'web_search_tool_result',
459 'code_execution_tool_result',
460 'bash_code_execution_tool_result',
461 'text_editor_code_execution_tool_result',
462 ];
463 $strippedTools = [];
464 foreach ( $contentBlock as $item ) {
465 $type = $item['type'] ?? '';
466 if ( in_array( $type, $serverManagedTypes, true ) && !in_array( $type, $serverResultTypes, true ) ) {
467 $strippedTools[] = ( $item['name'] ?? $type ) . ' (' . ( $item['server_name'] ?? 'server' ) . ')';
468 }
469 }
470 if ( !empty( $strippedTools ) ) {
471 Meow_MWAI_Logging::warn( 'Anthropic: Server-managed tool call (' . implode( ', ', $strippedTools ) .
472 ') was interrupted by a function call. This is currently a limitation of the Anthropic API ' .
473 'when MCP/server tools and function calling are used together.' );
474 }
475 $contentBlock = array_values( array_filter( $contentBlock, function ( $item ) use ( $serverManagedTypes ) {
476 $type = $item['type'] ?? '';
477 return !in_array( $type, $serverManagedTypes );
478 } ) );
479 }
480
481 // Process each content item individually to ensure proper handling of multiple tool_use blocks
482 if ( is_array( $contentBlock ) ) {
483 foreach ( $contentBlock as &$contentItem ) {
484 if ( isset( $contentItem['type'] ) && $contentItem['type'] === 'tool_use' ) {
485 // Debug logging for tool_use blocks
486 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
487 error_log( 'AI Engine: Anthropic tool_use block - ID: ' . ( $contentItem['id'] ?? 'unknown' ) .
488 ', Name: ' . ( $contentItem['name'] ?? 'unknown' ) .
489 ', Input type: ' . gettype( $contentItem['input'] ?? null ) .
490 ', Input value: ' . json_encode( $contentItem['input'] ?? null ) );
491 }
492
493 // Ensure input is an object, not an array
494 if ( isset( $contentItem['input'] ) ) {
495 if ( empty( $contentItem['input'] ) || ( is_array( $contentItem['input'] ) && count( $contentItem['input'] ) === 0 ) ) {
496 $contentItem['input'] = new stdClass();
497 }
498 else {
499 // Apply replaceEmptyArrayWithObject only to the input field
500 $contentItem['input'] = $this->replaceEmptyArrayWithObject( $contentItem['input'] );
501 }
502 }
503 else {
504 $contentItem['input'] = new stdClass();
505 }
506
507 // Debug logging after conversion
508 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
509 error_log( 'AI Engine: After conversion - Input type: ' . gettype( $contentItem['input'] ) .
510 ', Input value: ' . json_encode( $contentItem['input'] ) );
511 }
512 }
513 }
514 unset( $contentItem );
515 }
516
517 // Final debug logging before adding the message
518 if ( $this->core->get_option( 'queries_debug_mode' ) && is_array( $contentBlock ) ) {
519 error_log( 'AI Engine: Final contentBlock being added to messages: ' . json_encode( $contentBlock ) );
520 }
521
522 $assistantMessageIndex = count( $body['messages'] );
523 $body['messages'][] = [
524 'role' => 'assistant',
525 'content' => $contentBlock
526 ];
527
528 // Collect all tool results for this message
529 $toolResults = [];
530
531 foreach ( $feedback_block['feedbacks'] as $feedback ) {
532 $feedbackValue = $feedback['reply']['value'];
533 if ( !is_string( $feedbackValue ) ) {
534 $feedbackValue = json_encode( $feedbackValue );
535 }
536
537 $toolResults[] = [
538 'type' => 'tool_result',
539 'tool_use_id' => $feedback['request']['toolId'],
540 'content' => [
541 [
542 'type' => 'text',
543 'text' => $feedbackValue
544 ]
545 ],
546 'is_error' => false // Cool, Anthropic supports errors!
547 ];
548
549 // Note: Function result events are now emitted centrally in core.php
550 // when the function is actually executed
551 }
552
553 // Add all tool results in a single user message
554 // Anthropic requires all tool_results for a message to be in one content array
555 if ( !empty( $toolResults ) ) {
556 $body['messages'][] = [
557 'role' => 'user',
558 'content' => $toolResults
559 ];
560 }
561 }
562 }
563
564 // TODO: This WAS COPIED FROM BELOW
565 // Support for functions
566 if ( !empty( $query->functions ) ) {
567 $model = $this->retrieve_model_info( $query->model );
568 if ( !empty( $model['tags'] ) && !in_array( 'functions', $model['tags'] ) ) {
569 Meow_MWAI_Logging::warn( 'The model "' . $query->model . '" doesn\'t support Function Calling.' );
570 }
571 else {
572 $body['tools'] = [];
573 // Dynamic function: they will interactively enhance the completion (tools).
574 foreach ( $query->functions as $function ) {
575 $body['tools'][] = $function->serializeForAnthropic();
576 }
577 // Static functions: they will be executed at the end of the completion.
578 //$body['function_call'] = $query->functionCall;
579 }
580 }
581
582 // To avoid errors with Anthropic's API, we need to replace empty arrays with empty objects
583 // Note: We've already handled tool_use inputs above, so no need to process them again
584 return $body;
585 }
586 else if ( $query instanceof Meow_MWAI_Query_Text ) {
587 $body = [
588 'model' => $query->model,
589 'stream' => !is_null( $streamCallback ),
590 ];
591
592 if ( !empty( $query->maxTokens ) ) {
593 $body['max_tokens'] = $query->maxTokens;
594 }
595 else {
596 // https://docs.anthropic.com/en/docs/about-claude/models#model-comparison-table
597 $body['max_tokens'] = 4096;
598 }
599
600 if ( !empty( $query->temperature ) && $this->model_supports_temperature( $query->model ) ) {
601 $body['temperature'] = $query->temperature;
602 }
603
604 if ( !empty( $query->stop ) ) {
605 $body['stop'] = $query->stop;
606 }
607
608 // Build system prompt with caching support.
609 // Instructions are cached (static per chatbot), context is not (varies per query).
610 $systemBlocks = [];
611 if ( !empty( $query->instructions ) ) {
612 $systemBlocks[] = [
613 'type' => 'text',
614 'text' => $query->instructions,
615 'cache_control' => [ 'type' => 'ephemeral' ]
616 ];
617 }
618 if ( !empty( $query->context ) ) {
619 $framedContext = $this->core->frame_context( $query->context );
620 $systemBlocks[] = [
621 'type' => 'text',
622 'text' => $framedContext
623 ];
624 }
625 if ( !empty( $systemBlocks ) ) {
626 $body['system'] = $systemBlocks;
627 }
628
629 // Support for functions
630 if ( !empty( $query->functions ) ) {
631 $model = $this->retrieve_model_info( $query->model );
632 if ( !empty( $model['tags'] ) && !in_array( 'functions', $model['tags'] ) ) {
633 Meow_MWAI_Logging::warn( 'The model "' . $query->model . '" doesn\'t support Function Calling.' );
634 }
635 else {
636 $body['tools'] = [];
637 // Dynamic function: they will interactively enhance the completion (tools).
638 foreach ( $query->functions as $function ) {
639 $body['tools'][] = $function->serializeForAnthropic();
640 }
641 // Static functions: they will be executed at the end of the completion.
642 //$body['function_call'] = $query->functionCall;
643 }
644 }
645
646 $body['messages'] = $this->build_messages( $query );
647
648 // Add MCP servers if available
649 if ( isset( $query->mcpServers ) && is_array( $query->mcpServers ) && !empty( $query->mcpServers ) ) {
650 $mcp_envs = $this->core->get_option( 'mcp_envs' );
651 $this->mcpServerNames = []; // Reset MCP server names
652
653 // Resolve all MCP servers from their IDs
654 $resolved_servers = [];
655 foreach ( $query->mcpServers as $mcpServer ) {
656 if ( isset( $mcpServer['id'] ) ) {
657 foreach ( $mcp_envs as $env ) {
658 if ( $env['id'] === $mcpServer['id'] ) {
659 $resolved_servers[] = $env;
660 break;
661 }
662 }
663 }
664 }
665
666 // Allow filtering the full list of MCP servers
667 $resolved_servers = apply_filters( 'mwai_ai_mcp_servers', $resolved_servers, $query );
668
669 // Build API-specific MCP config
670 $body['mcp_servers'] = [];
671 foreach ( $resolved_servers as $env ) {
672 $mcp_config = [
673 'type' => 'url',
674 'url' => $env['url'],
675 'name' => $env['name'],
676 'tool_configuration' => [
677 'enabled' => true
678 ]
679 ];
680
681 // Add authorization token if available
682 if ( !empty( $env['token'] ) ) {
683 $mcp_config['authorization_token'] = $env['token'];
684 }
685
686 $body['mcp_servers'][] = $mcp_config;
687 $this->mcpServerNames[] = $env['name']; // Track MCP server names
688 }
689 }
690
691 // Add code_execution tool if code_interpreter is enabled
692 if ( !empty( $query->tools ) && is_array( $query->tools ) && in_array( 'code_interpreter', $query->tools ) ) {
693 if ( !isset( $body['tools'] ) ) {
694 $body['tools'] = [];
695 }
696 $body['tools'][] = [
697 'type' => 'code_execution_20250825',
698 'name' => 'code_execution'
699 ];
700 // Anthropic recommends a system-prompt hint whenever code_execution is
701 // paired with user-defined tools, otherwise Claude often refuses to
702 // use the sandbox because it can't tell which environment to run in.
703 // https://platform.claude.com/docs/en/docs/agents-and-tools/tool-use/code-execution-tool
704 $code_exec_hint = 'You have the built-in code_execution tool, which runs Python and bash commands'
705 . ' in an Anthropic-hosted sandboxed container. Use it whenever computation, shell commands,'
706 . ' file processing, or data analysis would help. It is separate from any client-provided'
707 . ' functions; state is not shared between them.';
708 if ( !isset( $body['system'] ) ) {
709 $body['system'] = [];
710 }
711 $body['system'][] = [ 'type' => 'text', 'text' => $code_exec_hint ];
712 Meow_MWAI_Logging::log( 'Anthropic: Added code_execution tool to request' );
713 }
714
715 return $body;
716 }
717 else {
718 throw new Exception( 'AI Engine: Unsupported query type.' );
719 }
720 }
721
722 protected function stream_data_handler( $json ) {
723 $content = null;
724 $type = !empty( $json['type'] ) ? $json['type'] : null;
725 if ( is_null( $type ) ) {
726 return $content;
727 }
728
729 if ( $type === 'message_start' ) {
730 $usage = $json['message']['usage'];
731 $this->streamInTokens = $usage['input_tokens'];
732 $this->inModel = $json['message']['model'];
733 $this->inId = $json['message']['id'];
734
735 // Send MCP discovery event if MCP servers are configured
736 if ( $this->currentDebugMode && $this->streamCallback ) {
737 if ( !empty( $this->mcpServerNames ) ) {
738 $serverCount = count( $this->mcpServerNames );
739
740 // Get MCP tools count
741 $mcpTools = apply_filters( 'mwai_mcp_tools', [] );
742 $toolCount = count( $mcpTools );
743
744 $event = Meow_MWAI_Event::mcp_discovery( $serverCount, $toolCount )
745 ->set_metadata( 'servers', $this->mcpServerNames );
746 call_user_func( $this->streamCallback, $event );
747 }
748 }
749 }
750 else if ( $type === 'content_block_start' ) {
751 $this->streamBlocks['content'][] = $json['content_block'];
752
753 // Send "Generating response..." when we start a text block
754 if ( $this->currentDebugMode && $this->streamCallback ) {
755 $block = $json['content_block'];
756 if ( $block['type'] === 'text' && !$this->textStarted ) {
757 $this->textStarted = true;
758 $event = Meow_MWAI_Event::generating_response();
759 call_user_func( $this->streamCallback, $event );
760 }
761 }
762 }
763 else if ( $type === 'content_block_delta' ) {
764 $index = $json['index'];
765 $block = $this->streamBlocks['content'][$index];
766 if ( $json['delta']['type'] === 'text_delta' ) {
767 $block['text'] .= $json['delta']['text'];
768 $isThinkingStart = strpos( $block['text'], '<thinking' ) === 0;
769 $isThinkingEnd = strpos( $block['text'], '</thinking>' ) === 0;
770
771 if ( $isThinkingStart ) {
772 $this->streamIsThinking = true;
773 // Send thinking start event
774 if ( $this->currentDebugMode && $this->streamCallback ) {
775 $event = Meow_MWAI_Event::thinking( 'Thinking...' );
776 call_user_func( $this->streamCallback, $event );
777 }
778 }
779 if ( $isThinkingEnd ) {
780 $this->streamIsThinking = false;
781 // Send thinking end event
782 if ( $this->currentDebugMode && $this->streamCallback ) {
783 $event = Meow_MWAI_Event::thinking( 'Thinking completed.' )
784 ->set_metadata( 'status', 'completed' );
785 call_user_func( $this->streamCallback, $event );
786 }
787 }
788 $content = $json['delta']['text'];
789 }
790 else if ( $json['delta']['type'] === 'input_json_delta' ) {
791 // Somehow, the input is set as an array, but it should be a string since it's JSON.
792 $block['input'] = is_array( $block['input'] ) ? '' : $block['input'];
793 $block['input'] .= $json['delta']['partial_json'];
794
795 // Skip sending tool arguments event - too verbose
796 // if ( $this->currentDebugMode && $this->streamCallback && isset($block['type']) && $block['type'] === 'tool_use' ) {
797 // $event = ( new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['TOOL_ARGS'] ) )
798 // ->set_content( 'Streaming tool arguments...' )
799 // ->set_metadata( 'tool_name', $block['name'] ?? 'unknown' )
800 // ->set_metadata( 'partial_args', $json['delta']['partial_json'] );
801 // call_user_func( $this->streamCallback, $event );
802 // }
803 }
804 $this->streamBlocks['content'][$index] = $block;
805 }
806 // At the end of a block, let's look for any 'input' not yet decoded from JSON
807 else if ( $type === 'content_block_stop' ) {
808 $index = $json['index'];
809 $block = $this->streamBlocks['content'][$index];
810 if ( isset( $block['input'] ) && is_string( $block['input'] ) ) {
811 $block['input'] = json_decode( $block['input'], true );
812 }
813
814 // For tool_use blocks, ensure empty inputs are objects, not arrays
815 if ( $block['type'] === 'tool_use' && isset( $block['input'] ) ) {
816 if ( empty( $block['input'] ) || ( is_array( $block['input'] ) && count( $block['input'] ) === 0 ) ) {
817 $block['input'] = new stdClass();
818 }
819 }
820
821 $this->streamBlocks['content'][$index] = $block;
822
823 // Send event for content block completion
824 if ( $this->currentDebugMode && $this->streamCallback ) {
825 if ( $block['type'] === 'mcp_tool_use' ) {
826 // Store the tool name for later lookup when we get the result
827 $this->mcpTools[$block['id']] = $block['name'];
828
829 $event = Meow_MWAI_Event::mcp_calling( $block['name'], $block['id'], $block['input'] ?? [] )
830 ->set_metadata( 'server_name', $block['server_name'] ?? 'unknown' );
831 call_user_func( $this->streamCallback, $event );
832 }
833 else if ( $block['type'] === 'mcp_tool_result' ) {
834 // Look up the tool name from the tool_use_id
835 $tool_use_id = $block['tool_use_id'] ?? '';
836 $tool_name = isset( $this->mcpTools[$tool_use_id] ) ? $this->mcpTools[$tool_use_id] : 'unknown';
837
838 $event = Meow_MWAI_Event::mcp_result( $tool_name, $tool_use_id )
839 ->set_metadata( 'content', $block['content'] ?? '' );
840 call_user_func( $this->streamCallback, $event );
841 }
842 else if ( $block['type'] === 'tool_use' ) {
843 // Regular tool use (non-MCP)
844 $event = Meow_MWAI_Event::function_calling( $block['name'] ?? 'unknown', $block['input'] ?? [] )
845 ->set_metadata( 'tool_id', $block['id'] ?? '' );
846 call_user_func( $this->streamCallback, $event );
847 }
848 else if ( $block['type'] === 'text' ) {
849 // Don't send any event here - the text generation is handled by content deltas
850 // and completion is handled by message_stop
851 }
852 else if ( $block['type'] === 'ping' ) {
853 // https://docs.anthropic.com/en/docs/build-with-claude/streaming#ping-events
854 }
855 else {
856 Meow_MWAI_Logging::log( 'Anthropic: Unknown block type in content_block_stop: ' . $block['type'] );
857 }
858 }
859 }
860 else if ( $type === 'message_delta' ) {
861 $usage = $json['usage'];
862 $this->streamOutTokens = $usage['output_tokens'];
863 // Carry the stop_reason so create_choices() can detect a safety refusal.
864 if ( isset( $json['delta']['stop_reason'] ) ) {
865 $this->streamBlocks['stop_reason'] = $json['delta']['stop_reason'];
866 }
867 }
868 else if ( $type === 'error' ) {
869 $error = $json['error'];
870 $message = $error['message'];
871
872 // Send error event
873 if ( $this->currentDebugMode && $this->streamCallback ) {
874 $event = Meow_MWAI_Event::error( $message )
875 ->set_metadata( 'error_type', $error['type'] ?? 'unknown' );
876 call_user_func( $this->streamCallback, $event );
877 }
878
879 throw new Exception( $message );
880 }
881 else if ( $type === 'message_stop' ) {
882 // Skip sending completion event - too verbose
883 // if ( $this->currentDebugMode && $this->streamCallback ) {
884 // $event = Meow_MWAI_Event::stream_completed()
885 // ->set_metadata( 'total_tokens', ($this->streamInTokens ?? 0) + ($this->streamOutTokens ?? 0) );
886 // call_user_func( $this->streamCallback, $event );
887 // }
888 }
889 else if ( $type === 'ping' ) {
890 // Documented keepalive heartbeat, nothing to do.
891 // https://docs.anthropic.com/en/docs/build-with-claude/streaming#event-types
892 }
893 else if ( $type === 'keepalive' ) {
894 // Forward keepalive as SSE comment to keep browser connection alive during long MCP calls
895 echo ": keepalive\n\n";
896 if ( ob_get_level() > 0 ) {
897 ob_end_flush();
898 }
899 flush();
900 }
901 else {
902 Meow_MWAI_Logging::log( "Anthropic: Unknown stream data type: $type" );
903 }
904
905 // Avoid some endings
906 $endings = [ '<|im_end|>', '</s>' ];
907 if ( in_array( $content, $endings ) ) {
908 $content = null;
909 }
910
911 // If the stream is thinking, we don't want to return anything yet.
912 if ( $this->streamIsThinking ) {
913 $content = null;
914 }
915
916 return ( $content === '0' || !empty( $content ) ) ? $content : null;
917 }
918
919 // This create the "choices" (even though, often, it is only one choice).
920 // It is basically the reply, but one that is understood by the Meow_MWAI_Reply class.
921 public function create_choices( $data ) {
922 $returned_choices = [];
923 $tool_calls = [];
924 $text_content = '';
925
926 // First, collect all tool calls and text content
927 foreach ( $data['content'] as $content ) {
928 if ( $content['type'] === 'tool_use' ) {
929 // Collect all tool calls
930 $arguments = $content['input'] ?? new stdClass();
931
932 // Ensure arguments is properly formatted
933 if ( empty( $arguments ) ) {
934 $arguments = new stdClass();
935 }
936 else if ( is_array( $arguments ) && count( $arguments ) === 0 ) {
937 $arguments = new stdClass();
938 }
939
940 $tool_calls[] = [
941 'id' => $content['id'],
942 'type' => 'function',
943 'function' => [
944 'name' => $content['name'],
945 'arguments' => $arguments,
946 ]
947 ];
948 }
949 else if ( $content['type'] === 'text' ) {
950 $text_content .= $content['text'];
951 }
952 }
953
954 // Create a single choice with both tool calls and text content (like OpenAI does)
955 $message = [];
956
957 if ( !empty( $text_content ) ) {
958 $message['content'] = $text_content;
959 }
960
961 if ( !empty( $tool_calls ) ) {
962 $message['tool_calls'] = $tool_calls;
963 }
964
965 // Only create a choice if there's content or tool calls
966 if ( !empty( $message ) ) {
967 $returned_choices[] = [
968 'message' => $message
969 ];
970 }
971
972 // Safety refusal (Claude Fable 5 and newer): the request was declined by a
973 // safety classifier, so there is no content. Surface a readable message
974 // instead of letting the reply come back empty.
975 if ( empty( $returned_choices ) && ( $data['stop_reason'] ?? '' ) === 'refusal' ) {
976 $category = $data['stop_details']['category'] ?? null;
977 if ( !empty( $category ) ) {
978 Meow_MWAI_Logging::log( 'Anthropic: Request refused by safety classifier (category: ' . $category . ').' );
979 }
980 $returned_choices[] = [
981 'message' => [
982 'content' => __( 'This request was declined by the model\'s safety system. Please rephrase your message and try again.', 'ai-engine' )
983 ]
984 ];
985 }
986
987 return $returned_choices;
988 }
989
990 /**
991 * Override reset to include Anthropic-specific state
992 */
993 protected function reset_request_state() {
994 parent::reset_request_state();
995
996 // Reset Anthropic-specific state
997 $this->mcpTools = [];
998 $this->mcpToolCount = 0;
999 // Note: mcpServerNames is configuration, not request state
1000 }
1001
1002 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
1003 // Reset request-specific state to prevent leakage between requests
1004 $this->reset_request_state();
1005
1006 $isStreaming = !is_null( $streamCallback );
1007
1008 // Initialize debug mode
1009 $this->init_debug_mode( $query );
1010
1011 // IMPORTANT: Prepare query BEFORE setting up streaming hooks
1012 // The streaming hook intercepts ALL wp_remote_* calls, so preparation must happen first
1013 $this->prepare_query( $query );
1014
1015 if ( $isStreaming ) {
1016 $this->streamCallback = $streamCallback;
1017 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
1018 }
1019
1020 $this->reset_stream();
1021 $data = null;
1022 $body = $this->build_body( $query, $streamCallback );
1023 $url = $this->build_url( $query );
1024 $headers = $this->build_headers( $query );
1025 $options = $this->build_options( $headers, $body );
1026
1027 // Emit "Request sent" event for feedback queries
1028 if ( $this->currentDebugMode && !empty( $streamCallback ) &&
1029 ( $query instanceof Meow_MWAI_Query_Feedback || $query instanceof Meow_MWAI_Query_AssistFeedback ) ) {
1030 $event = Meow_MWAI_Event::request_sent()
1031 ->set_metadata( 'is_feedback', true )
1032 ->set_metadata( 'feedback_count', count( $query->blocks ) );
1033 call_user_func( $streamCallback, $event );
1034 }
1035
1036 try {
1037 $res = $this->run_query( $url, $options, $streamCallback );
1038 $reply = new Meow_MWAI_Reply( $query );
1039 $returned_id = null;
1040 $returned_model = null;
1041 $returned_choices = [];
1042
1043 // Streaming Mode
1044 if ( $isStreaming ) {
1045 $returned_id = $this->inId;
1046 $returned_model = $this->inModel ? $this->inModel : $query->model;
1047 if ( !is_null( $this->streamInTokens && !is_null( $this->streamOutTokens ) ) ) {
1048 $returned_in_tokens = $this->streamInTokens;
1049 $returned_out_tokens = $this->streamOutTokens;
1050 }
1051 $data = $this->streamBlocks;
1052
1053 // Clean up streaming data as well
1054 if ( isset( $data['content'] ) && is_array( $data['content'] ) ) {
1055 foreach ( $data['content'] as &$content ) {
1056 if ( $content['type'] === 'tool_use' && isset( $content['input'] ) ) {
1057 if ( empty( $content['input'] ) || ( is_array( $content['input'] ) && count( $content['input'] ) === 0 ) ) {
1058 $content['input'] = new stdClass();
1059 }
1060 }
1061 }
1062 unset( $content );
1063 }
1064
1065 $returned_choices = $this->create_choices( $this->streamBlocks );
1066 }
1067 // Standard Mode
1068 else {
1069 $data = $res['data'];
1070
1071 // Clean up tool_use inputs in the raw data BEFORE it gets stored
1072 if ( isset( $data['content'] ) && is_array( $data['content'] ) ) {
1073 foreach ( $data['content'] as &$content ) {
1074 if ( $content['type'] === 'tool_use' && isset( $content['input'] ) ) {
1075 if ( empty( $content['input'] ) || ( is_array( $content['input'] ) && count( $content['input'] ) === 0 ) ) {
1076 $content['input'] = new stdClass();
1077 }
1078 }
1079 }
1080 unset( $content );
1081 }
1082
1083 $returned_id = $data['id'];
1084 $returned_model = $data['model'];
1085 $usage = $data['usage'];
1086 if ( !empty( $usage ) ) {
1087 $returned_in_tokens = isset( $usage['input_tokens'] ) ? $usage['input_tokens'] : null;
1088 $returned_out_tokens = isset( $usage['output_tokens'] ) ? $usage['output_tokens'] : null;
1089 }
1090 $returned_choices = $this->create_choices( $data );
1091 }
1092
1093 $reply->set_choices( $returned_choices, $data );
1094 if ( !empty( $returned_id ) ) {
1095 $reply->set_id( $returned_id );
1096 }
1097
1098 // Handle tokens.
1099 $this->handle_tokens_usage(
1100 $reply,
1101 $query,
1102 $returned_model,
1103 $returned_in_tokens,
1104 $returned_out_tokens
1105 );
1106
1107 return $reply;
1108 }
1109 catch ( Exception $e ) {
1110 $error = $e->getMessage();
1111 $json = json_decode( $error, true );
1112 if ( json_last_error() === JSON_ERROR_NONE ) {
1113 if ( isset( $json['error'] ) && isset( $json['error']['message'] ) ) {
1114 $error = $json['error']['message'];
1115 }
1116 }
1117 Meow_MWAI_Logging::error( '(Anthropic) ' . $error );
1118 $service = $this->get_service_name();
1119 $message = "From $service: " . $error;
1120 throw new Exception( $message );
1121 }
1122 finally {
1123 if ( $isStreaming ) {
1124 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
1125 }
1126 }
1127 }
1128
1129 protected function build_options( $headers, $json = null, $forms = null, $method = 'POST' ) {
1130 $body = null;
1131 if ( !empty( $forms ) ) {
1132 $boundary = wp_generate_password( 24, false );
1133 $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary;
1134 $body = $this->build_form_body( $forms, $boundary );
1135 }
1136 else if ( !empty( $json ) ) {
1137 // For Anthropic, we need to ensure empty objects stay as objects, not arrays
1138 // JSON_FORCE_OBJECT would force everything to be an object, which we don't want
1139 // Instead, we've already converted empty arrays to stdClass in build_body
1140 $body = $this->safe_json_encode( $json, 'request body' );
1141
1142 // Debug logging to verify JSON encoding
1143 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1144 // Check if the body contains tool_use blocks with empty inputs
1145 if ( strpos( $body, '"tool_use"' ) !== false ) {
1146 error_log( 'AI Engine: Anthropic JSON body after encoding (first 1000 chars): ' . substr( $body, 0, 1000 ) );
1147
1148 // Check specifically for "input":[] which would be wrong
1149 if ( strpos( $body, '"input":[]' ) !== false ) {
1150 error_log( 'AI Engine: WARNING - Found "input":[] in JSON body, this should be "input":{} for Anthropic API' );
1151 }
1152 }
1153 }
1154 }
1155 $options = [
1156 'headers' => $headers,
1157 'method' => $method,
1158 'timeout' => MWAI_TIMEOUT,
1159 'body' => $body,
1160 'sslverify' => MWAI_SSL_VERIFY
1161 ];
1162 return $options;
1163 }
1164
1165 protected function get_service_name() {
1166 return 'Anthropic';
1167 }
1168
1169 public function get_models() {
1170 return apply_filters( 'mwai_anthropic_models', MWAI_ANTHROPIC_MODELS );
1171 }
1172
1173 public static function get_models_static() {
1174 return MWAI_ANTHROPIC_MODELS;
1175 }
1176
1177 public function handle_tokens_usage(
1178 $reply,
1179 $query,
1180 $returned_model,
1181 $returned_in_tokens,
1182 $returned_out_tokens,
1183 $returned_price = null
1184 ) {
1185 $returned_in_tokens = !is_null( $returned_in_tokens ) ?
1186 $returned_in_tokens : $reply->get_in_tokens( $query );
1187 $returned_out_tokens = !is_null( $returned_out_tokens ) ?
1188 $returned_out_tokens : $reply->get_out_tokens();
1189 if ( !empty( $reply->id ) ) {
1190 // Would be cool to retrieve the usage from the API, but it's not possible.
1191 }
1192 $usage = $this->core->record_tokens_usage( $returned_model, $returned_in_tokens, $returned_out_tokens );
1193 $reply->set_usage( $usage );
1194
1195 // Set accuracy based on data availability
1196 if ( !is_null( $returned_in_tokens ) && !is_null( $returned_out_tokens ) ) {
1197 // Anthropic provides token counts from API = tokens accuracy
1198 $reply->set_usage_accuracy( 'tokens' );
1199 }
1200 else {
1201 // Fallback to estimated
1202 $reply->set_usage_accuracy( 'estimated' );
1203 }
1204 }
1205
1206 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
1207 return parent::get_price( $query, $reply );
1208 }
1209
1210 /**
1211 * Check the connection to Anthropic by listing available models.
1212 */
1213 public function connection_check() {
1214 try {
1215 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
1216 $url = trailingslashit( $endpoint ) . 'models';
1217
1218 $response = wp_remote_get( $url, [
1219 'headers' => [
1220 'x-api-key' => $this->apiKey,
1221 'anthropic-version' => '2023-06-01',
1222 ],
1223 'timeout' => 30,
1224 ] );
1225
1226 if ( is_wp_error( $response ) ) {
1227 throw new Exception( $response->get_error_message() );
1228 }
1229
1230 $code = wp_remote_retrieve_response_code( $response );
1231 $body = json_decode( wp_remote_retrieve_body( $response ), true );
1232
1233 if ( $code === 401 || ( isset( $body['error']['type'] ) && $body['error']['type'] === 'authentication_error' ) ) {
1234 throw new Exception( 'Invalid API key' );
1235 }
1236
1237 if ( $code !== 200 ) {
1238 throw new Exception( 'Connection failed: ' . ( $body['error']['message'] ?? "HTTP $code" ) );
1239 }
1240
1241 $availableModels = [];
1242 if ( isset( $body['data'] ) && is_array( $body['data'] ) ) {
1243 foreach ( array_slice( $body['data'], 0, 10 ) as $model ) {
1244 $availableModels[] = $model['id'] ?? 'unknown';
1245 }
1246 }
1247
1248 return [
1249 'models' => $availableModels,
1250 'service' => 'Anthropic'
1251 ];
1252 }
1253 catch ( Exception $e ) {
1254 throw new Exception( 'Connection failed: ' . $e->getMessage() );
1255 }
1256 }
1257
1258 /**
1259 * Upload a file to Anthropic Files API
1260 *
1261 * @param string $filename The name of the file
1262 * @param string $data The file content (binary)
1263 * @param string $purpose For Anthropic, this is the MIME type (API difference from OpenAI)
1264 * @return array The response from the API containing file_id
1265 * @throws Exception If upload fails
1266 */
1267 public function upload_file( $filename, $data, $purpose = 'application/pdf' ) {
1268 global $wp_filter;
1269
1270 // For Anthropic, $purpose is actually the MIME type (different from OpenAI's API)
1271 $mimeType = $purpose;
1272
1273 // Build multipart form data
1274 $boundary = wp_generate_password( 24, false );
1275 $body = '';
1276 $body .= "--$boundary\r\n";
1277 $body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n";
1278 $body .= 'Content-Type: ' . $mimeType . "\r\n\r\n";
1279 $body .= $data . "\r\n";
1280 $body .= "--$boundary\r\n";
1281 $body .= "Content-Disposition: form-data; name=\"mime_type\"\r\n\r\n";
1282 $body .= $mimeType . "\r\n";
1283 $body .= "--$boundary--\r\n";
1284
1285 // Temporarily remove ALL http_api_curl hooks to prevent streaming hook interference
1286 // Save current hooks
1287 $saved_hooks = null;
1288 if ( isset( $wp_filter['http_api_curl'] ) ) {
1289 $saved_hooks = $wp_filter['http_api_curl'];
1290 unset( $wp_filter['http_api_curl'] );
1291 }
1292
1293 // Upload using WordPress HTTP API
1294 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
1295 $url = $endpoint . '/files';
1296 $response = wp_remote_post( $url, [
1297 'headers' => [
1298 'x-api-key' => $this->apiKey,
1299 'anthropic-version' => '2023-06-01',
1300 'anthropic-beta' => 'files-api-2025-04-14',
1301 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
1302 ],
1303 'body' => $body,
1304 'timeout' => 60
1305 ] );
1306
1307 // Restore hooks
1308 if ( $saved_hooks !== null ) {
1309 $wp_filter['http_api_curl'] = $saved_hooks;
1310 }
1311
1312 if ( is_wp_error( $response ) ) {
1313 throw new Exception( 'File upload failed: ' . $response->get_error_message() );
1314 }
1315
1316 $response_body = wp_remote_retrieve_body( $response );
1317 $result = json_decode( $response_body, true );
1318
1319 // Check for API errors
1320 if ( isset( $result['error'] ) ) {
1321 throw new Exception( 'Anthropic Files API error: ' . ( $result['error']['message'] ?? 'Unknown error' ) );
1322 }
1323
1324 return $result;
1325 }
1326
1327 /**
1328 * Delete a file from Anthropic Files API
1329 *
1330 * @param string $fileId The Anthropic file ID to delete
1331 * @return array The response from the API
1332 * @throws Exception If deletion fails
1333 */
1334 public function delete_file( $fileId ) {
1335 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
1336 $url = $endpoint . '/files/' . $fileId;
1337
1338 $response = wp_remote_request( $url, [
1339 'method' => 'DELETE',
1340 'headers' => [
1341 'x-api-key' => $this->apiKey,
1342 'anthropic-version' => '2023-06-01',
1343 'anthropic-beta' => 'files-api-2025-04-14',
1344 ],
1345 'timeout' => 30
1346 ] );
1347
1348 if ( is_wp_error( $response ) ) {
1349 throw new Exception( 'File deletion failed: ' . $response->get_error_message() );
1350 }
1351
1352 $response_code = wp_remote_retrieve_response_code( $response );
1353 $response_body = wp_remote_retrieve_body( $response );
1354 $result = json_decode( $response_body, true );
1355
1356 // Check for API errors
1357 if ( $response_code >= 400 ) {
1358 $error_message = isset( $result['error']['message'] ) ? $result['error']['message'] : 'Unknown error';
1359 throw new Exception( 'Anthropic Files API error: ' . $error_message );
1360 }
1361
1362 return $result;
1363 }
1364 }
1365