PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / trunk
AI Engine – The Chatbot, AI Framework & MCP for WordPress vtrunk
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 5 hours ago chatml.php 1 week ago core.php 2 days ago custom.php 1 month ago factory.php 1 week ago google-interactions.php 2 days ago google.php 1 week ago mistral.php 1 week ago open-router.php 3 weeks ago openai.php 3 weeks ago ovh.php 1 week ago perplexity.php 6 months ago replicate.php 5 months ago xai.php 1 month ago
anthropic.php
1383 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 else if ( $block['type'] === 'thinking' || $block['type'] === 'redacted_thinking' ) {
762 $event = Meow_MWAI_Event::thinking( 'Thinking...' );
763 call_user_func( $this->streamCallback, $event );
764 }
765 }
766 }
767 else if ( $type === 'content_block_delta' ) {
768 $index = $json['index'];
769 $block = $this->streamBlocks['content'][$index];
770 if ( $json['delta']['type'] === 'text_delta' ) {
771 $block['text'] .= $json['delta']['text'];
772 $isThinkingStart = strpos( $block['text'], '<thinking' ) === 0;
773 $isThinkingEnd = strpos( $block['text'], '</thinking>' ) === 0;
774
775 if ( $isThinkingStart ) {
776 $this->streamIsThinking = true;
777 // Send thinking start event
778 if ( $this->currentDebugMode && $this->streamCallback ) {
779 $event = Meow_MWAI_Event::thinking( 'Thinking...' );
780 call_user_func( $this->streamCallback, $event );
781 }
782 }
783 if ( $isThinkingEnd ) {
784 $this->streamIsThinking = false;
785 // Send thinking end event
786 if ( $this->currentDebugMode && $this->streamCallback ) {
787 $event = Meow_MWAI_Event::thinking( 'Thinking completed.' )
788 ->set_metadata( 'status', 'completed' );
789 call_user_func( $this->streamCallback, $event );
790 }
791 }
792 $content = $json['delta']['text'];
793 }
794 else if ( $json['delta']['type'] === 'thinking_delta' ) {
795 // Models with adaptive thinking (Sonnet 5+) stream thinking blocks. They must be
796 // accumulated in full (text + signature below), because the feedback query echoes
797 // the raw assistant message back and the API rejects incomplete thinking blocks.
798 $block['thinking'] = ( $block['thinking'] ?? '' ) . $json['delta']['thinking'];
799 }
800 else if ( $json['delta']['type'] === 'signature_delta' ) {
801 $block['signature'] = ( $block['signature'] ?? '' ) . $json['delta']['signature'];
802 }
803 else if ( $json['delta']['type'] === 'input_json_delta' ) {
804 // Somehow, the input is set as an array, but it should be a string since it's JSON.
805 $block['input'] = is_array( $block['input'] ) ? '' : $block['input'];
806 $block['input'] .= $json['delta']['partial_json'];
807
808 // Skip sending tool arguments event - too verbose
809 // if ( $this->currentDebugMode && $this->streamCallback && isset($block['type']) && $block['type'] === 'tool_use' ) {
810 // $event = ( new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['TOOL_ARGS'] ) )
811 // ->set_content( 'Streaming tool arguments...' )
812 // ->set_metadata( 'tool_name', $block['name'] ?? 'unknown' )
813 // ->set_metadata( 'partial_args', $json['delta']['partial_json'] );
814 // call_user_func( $this->streamCallback, $event );
815 // }
816 }
817 $this->streamBlocks['content'][$index] = $block;
818 }
819 // At the end of a block, let's look for any 'input' not yet decoded from JSON
820 else if ( $type === 'content_block_stop' ) {
821 $index = $json['index'];
822 $block = $this->streamBlocks['content'][$index];
823 if ( isset( $block['input'] ) && is_string( $block['input'] ) ) {
824 $block['input'] = json_decode( $block['input'], true );
825 }
826
827 // For tool_use blocks, ensure empty inputs are objects, not arrays
828 if ( $block['type'] === 'tool_use' && isset( $block['input'] ) ) {
829 if ( empty( $block['input'] ) || ( is_array( $block['input'] ) && count( $block['input'] ) === 0 ) ) {
830 $block['input'] = new stdClass();
831 }
832 }
833
834 $this->streamBlocks['content'][$index] = $block;
835
836 // Send event for content block completion
837 if ( $this->currentDebugMode && $this->streamCallback ) {
838 if ( $block['type'] === 'mcp_tool_use' ) {
839 // Store the tool name for later lookup when we get the result
840 $this->mcpTools[$block['id']] = $block['name'];
841
842 $event = Meow_MWAI_Event::mcp_calling( $block['name'], $block['id'], $block['input'] ?? [] )
843 ->set_metadata( 'server_name', $block['server_name'] ?? 'unknown' );
844 call_user_func( $this->streamCallback, $event );
845 }
846 else if ( $block['type'] === 'mcp_tool_result' ) {
847 // Look up the tool name from the tool_use_id
848 $tool_use_id = $block['tool_use_id'] ?? '';
849 $tool_name = isset( $this->mcpTools[$tool_use_id] ) ? $this->mcpTools[$tool_use_id] : 'unknown';
850
851 $event = Meow_MWAI_Event::mcp_result( $tool_name, $tool_use_id )
852 ->set_metadata( 'content', $block['content'] ?? '' );
853 call_user_func( $this->streamCallback, $event );
854 }
855 else if ( $block['type'] === 'tool_use' ) {
856 // Regular tool use (non-MCP)
857 $event = Meow_MWAI_Event::function_calling( $block['name'] ?? 'unknown', $block['input'] ?? [] )
858 ->set_metadata( 'tool_id', $block['id'] ?? '' );
859 call_user_func( $this->streamCallback, $event );
860 }
861 else if ( $block['type'] === 'thinking' || $block['type'] === 'redacted_thinking' ) {
862 $event = Meow_MWAI_Event::thinking( 'Thinking completed.' )
863 ->set_metadata( 'status', 'completed' );
864 call_user_func( $this->streamCallback, $event );
865 }
866 else if ( $block['type'] === 'text' ) {
867 // Don't send any event here - the text generation is handled by content deltas
868 // and completion is handled by message_stop
869 }
870 else if ( $block['type'] === 'ping' ) {
871 // https://docs.anthropic.com/en/docs/build-with-claude/streaming#ping-events
872 }
873 else {
874 Meow_MWAI_Logging::log( 'Anthropic: Unknown block type in content_block_stop: ' . $block['type'] );
875 }
876 }
877 }
878 else if ( $type === 'message_delta' ) {
879 $usage = $json['usage'];
880 $this->streamOutTokens = $usage['output_tokens'];
881 // Carry the stop_reason so create_choices() can detect a safety refusal.
882 if ( isset( $json['delta']['stop_reason'] ) ) {
883 $this->streamBlocks['stop_reason'] = $json['delta']['stop_reason'];
884 }
885 }
886 else if ( $type === 'error' ) {
887 $error = $json['error'];
888 $message = $error['message'];
889
890 // Send error event
891 if ( $this->currentDebugMode && $this->streamCallback ) {
892 $event = Meow_MWAI_Event::error( $message )
893 ->set_metadata( 'error_type', $error['type'] ?? 'unknown' );
894 call_user_func( $this->streamCallback, $event );
895 }
896
897 throw new Exception( $message );
898 }
899 else if ( $type === 'message_stop' ) {
900 // Skip sending completion event - too verbose
901 // if ( $this->currentDebugMode && $this->streamCallback ) {
902 // $event = Meow_MWAI_Event::stream_completed()
903 // ->set_metadata( 'total_tokens', ($this->streamInTokens ?? 0) + ($this->streamOutTokens ?? 0) );
904 // call_user_func( $this->streamCallback, $event );
905 // }
906 }
907 else if ( $type === 'ping' ) {
908 // Documented keepalive heartbeat, nothing to do.
909 // https://docs.anthropic.com/en/docs/build-with-claude/streaming#event-types
910 }
911 else if ( $type === 'keepalive' ) {
912 // Forward keepalive as SSE comment to keep browser connection alive during long MCP calls
913 echo ": keepalive\n\n";
914 if ( ob_get_level() > 0 ) {
915 ob_end_flush();
916 }
917 flush();
918 }
919 else {
920 Meow_MWAI_Logging::log( "Anthropic: Unknown stream data type: $type" );
921 }
922
923 // Avoid some endings
924 $endings = [ '<|im_end|>', '</s>' ];
925 if ( in_array( $content, $endings ) ) {
926 $content = null;
927 }
928
929 // If the stream is thinking, we don't want to return anything yet.
930 if ( $this->streamIsThinking ) {
931 $content = null;
932 }
933
934 return ( $content === '0' || !empty( $content ) ) ? $content : null;
935 }
936
937 // This create the "choices" (even though, often, it is only one choice).
938 // It is basically the reply, but one that is understood by the Meow_MWAI_Reply class.
939 public function create_choices( $data ) {
940 $returned_choices = [];
941 $tool_calls = [];
942 $text_content = '';
943
944 // First, collect all tool calls and text content
945 foreach ( $data['content'] as $content ) {
946 if ( $content['type'] === 'tool_use' ) {
947 // Collect all tool calls
948 $arguments = $content['input'] ?? new stdClass();
949
950 // Ensure arguments is properly formatted
951 if ( empty( $arguments ) ) {
952 $arguments = new stdClass();
953 }
954 else if ( is_array( $arguments ) && count( $arguments ) === 0 ) {
955 $arguments = new stdClass();
956 }
957
958 $tool_calls[] = [
959 'id' => $content['id'],
960 'type' => 'function',
961 'function' => [
962 'name' => $content['name'],
963 'arguments' => $arguments,
964 ]
965 ];
966 }
967 else if ( $content['type'] === 'text' ) {
968 $text_content .= $content['text'];
969 }
970 }
971
972 // Create a single choice with both tool calls and text content (like OpenAI does)
973 $message = [];
974
975 if ( !empty( $text_content ) ) {
976 $message['content'] = $text_content;
977 }
978
979 if ( !empty( $tool_calls ) ) {
980 $message['tool_calls'] = $tool_calls;
981 }
982
983 // Only create a choice if there's content or tool calls
984 if ( !empty( $message ) ) {
985 $returned_choices[] = [
986 'message' => $message
987 ];
988 }
989
990 // Safety refusal (Claude Fable 5 and newer): the request was declined by a
991 // safety classifier, so there is no content. Surface a readable message
992 // instead of letting the reply come back empty.
993 if ( empty( $returned_choices ) && ( $data['stop_reason'] ?? '' ) === 'refusal' ) {
994 $category = $data['stop_details']['category'] ?? null;
995 if ( !empty( $category ) ) {
996 Meow_MWAI_Logging::log( 'Anthropic: Request refused by safety classifier (category: ' . $category . ').' );
997 }
998 $returned_choices[] = [
999 'message' => [
1000 'content' => __( 'This request was declined by the model\'s safety system. Please rephrase your message and try again.', 'ai-engine' )
1001 ]
1002 ];
1003 }
1004
1005 return $returned_choices;
1006 }
1007
1008 /**
1009 * Override reset to include Anthropic-specific state
1010 */
1011 protected function reset_request_state() {
1012 parent::reset_request_state();
1013
1014 // Reset Anthropic-specific state
1015 $this->mcpTools = [];
1016 $this->mcpToolCount = 0;
1017 // Note: mcpServerNames is configuration, not request state
1018 }
1019
1020 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
1021 // Reset request-specific state to prevent leakage between requests
1022 $this->reset_request_state();
1023
1024 $isStreaming = !is_null( $streamCallback );
1025
1026 // Initialize debug mode
1027 $this->init_debug_mode( $query );
1028
1029 // IMPORTANT: Prepare query BEFORE setting up streaming hooks
1030 // The streaming hook intercepts ALL wp_remote_* calls, so preparation must happen first
1031 $this->prepare_query( $query );
1032
1033 if ( $isStreaming ) {
1034 $this->streamCallback = $streamCallback;
1035 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
1036 }
1037
1038 $this->reset_stream();
1039 $data = null;
1040 $body = $this->build_body( $query, $streamCallback );
1041 $url = $this->build_url( $query );
1042 $headers = $this->build_headers( $query );
1043 $options = $this->build_options( $headers, $body );
1044
1045 // Emit "Request sent" event for feedback queries
1046 if ( $this->currentDebugMode && !empty( $streamCallback ) &&
1047 ( $query instanceof Meow_MWAI_Query_Feedback || $query instanceof Meow_MWAI_Query_AssistFeedback ) ) {
1048 $event = Meow_MWAI_Event::request_sent()
1049 ->set_metadata( 'is_feedback', true )
1050 ->set_metadata( 'feedback_count', count( $query->blocks ) );
1051 call_user_func( $streamCallback, $event );
1052 }
1053
1054 try {
1055 $res = $this->run_query( $url, $options, $streamCallback );
1056 $reply = new Meow_MWAI_Reply( $query );
1057 $returned_id = null;
1058 $returned_model = null;
1059 $returned_choices = [];
1060
1061 // Streaming Mode
1062 if ( $isStreaming ) {
1063 $returned_id = $this->inId;
1064 $returned_model = $this->inModel ? $this->inModel : $query->model;
1065 if ( !is_null( $this->streamInTokens && !is_null( $this->streamOutTokens ) ) ) {
1066 $returned_in_tokens = $this->streamInTokens;
1067 $returned_out_tokens = $this->streamOutTokens;
1068 }
1069 $data = $this->streamBlocks;
1070
1071 // Clean up streaming data as well
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_choices = $this->create_choices( $this->streamBlocks );
1084 }
1085 // Standard Mode
1086 else {
1087 $data = $res['data'];
1088
1089 // Clean up tool_use inputs in the raw data BEFORE it gets stored
1090 if ( isset( $data['content'] ) && is_array( $data['content'] ) ) {
1091 foreach ( $data['content'] as &$content ) {
1092 if ( $content['type'] === 'tool_use' && isset( $content['input'] ) ) {
1093 if ( empty( $content['input'] ) || ( is_array( $content['input'] ) && count( $content['input'] ) === 0 ) ) {
1094 $content['input'] = new stdClass();
1095 }
1096 }
1097 }
1098 unset( $content );
1099 }
1100
1101 $returned_id = $data['id'];
1102 $returned_model = $data['model'];
1103 $usage = $data['usage'];
1104 if ( !empty( $usage ) ) {
1105 $returned_in_tokens = isset( $usage['input_tokens'] ) ? $usage['input_tokens'] : null;
1106 $returned_out_tokens = isset( $usage['output_tokens'] ) ? $usage['output_tokens'] : null;
1107 }
1108 $returned_choices = $this->create_choices( $data );
1109 }
1110
1111 $reply->set_choices( $returned_choices, $data );
1112 if ( !empty( $returned_id ) ) {
1113 $reply->set_id( $returned_id );
1114 }
1115
1116 // Handle tokens.
1117 $this->handle_tokens_usage(
1118 $reply,
1119 $query,
1120 $returned_model,
1121 $returned_in_tokens,
1122 $returned_out_tokens
1123 );
1124
1125 return $reply;
1126 }
1127 catch ( Exception $e ) {
1128 $error = $e->getMessage();
1129 $json = json_decode( $error, true );
1130 if ( json_last_error() === JSON_ERROR_NONE ) {
1131 if ( isset( $json['error'] ) && isset( $json['error']['message'] ) ) {
1132 $error = $json['error']['message'];
1133 }
1134 }
1135 Meow_MWAI_Logging::error( '(Anthropic) ' . $error );
1136 $service = $this->get_service_name();
1137 $message = "From $service: " . $error;
1138 throw new Exception( $message );
1139 }
1140 finally {
1141 if ( $isStreaming ) {
1142 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
1143 }
1144 }
1145 }
1146
1147 protected function build_options( $headers, $json = null, $forms = null, $method = 'POST' ) {
1148 $body = null;
1149 if ( !empty( $forms ) ) {
1150 $boundary = wp_generate_password( 24, false );
1151 $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary;
1152 $body = $this->build_form_body( $forms, $boundary );
1153 }
1154 else if ( !empty( $json ) ) {
1155 // For Anthropic, we need to ensure empty objects stay as objects, not arrays
1156 // JSON_FORCE_OBJECT would force everything to be an object, which we don't want
1157 // Instead, we've already converted empty arrays to stdClass in build_body
1158 $body = $this->safe_json_encode( $json, 'request body' );
1159
1160 // Debug logging to verify JSON encoding
1161 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1162 // Check if the body contains tool_use blocks with empty inputs
1163 if ( strpos( $body, '"tool_use"' ) !== false ) {
1164 error_log( 'AI Engine: Anthropic JSON body after encoding (first 1000 chars): ' . substr( $body, 0, 1000 ) );
1165
1166 // Check specifically for "input":[] which would be wrong
1167 if ( strpos( $body, '"input":[]' ) !== false ) {
1168 error_log( 'AI Engine: WARNING - Found "input":[] in JSON body, this should be "input":{} for Anthropic API' );
1169 }
1170 }
1171 }
1172 }
1173 $options = [
1174 'headers' => $headers,
1175 'method' => $method,
1176 'timeout' => MWAI_TIMEOUT,
1177 'body' => $body,
1178 'sslverify' => MWAI_SSL_VERIFY
1179 ];
1180 return $options;
1181 }
1182
1183 protected function get_service_name() {
1184 return 'Anthropic';
1185 }
1186
1187 public function get_models() {
1188 return apply_filters( 'mwai_anthropic_models', MWAI_ANTHROPIC_MODELS );
1189 }
1190
1191 public static function get_models_static() {
1192 return MWAI_ANTHROPIC_MODELS;
1193 }
1194
1195 public function handle_tokens_usage(
1196 $reply,
1197 $query,
1198 $returned_model,
1199 $returned_in_tokens,
1200 $returned_out_tokens,
1201 $returned_price = null
1202 ) {
1203 $returned_in_tokens = !is_null( $returned_in_tokens ) ?
1204 $returned_in_tokens : $reply->get_in_tokens( $query );
1205 $returned_out_tokens = !is_null( $returned_out_tokens ) ?
1206 $returned_out_tokens : $reply->get_out_tokens();
1207 if ( !empty( $reply->id ) ) {
1208 // Would be cool to retrieve the usage from the API, but it's not possible.
1209 }
1210 $usage = $this->core->record_tokens_usage( $returned_model, $returned_in_tokens, $returned_out_tokens );
1211 $reply->set_usage( $usage );
1212
1213 // Set accuracy based on data availability
1214 if ( !is_null( $returned_in_tokens ) && !is_null( $returned_out_tokens ) ) {
1215 // Anthropic provides token counts from API = tokens accuracy
1216 $reply->set_usage_accuracy( 'tokens' );
1217 }
1218 else {
1219 // Fallback to estimated
1220 $reply->set_usage_accuracy( 'estimated' );
1221 }
1222 }
1223
1224 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
1225 return parent::get_price( $query, $reply );
1226 }
1227
1228 /**
1229 * Check the connection to Anthropic by listing available models.
1230 */
1231 public function connection_check() {
1232 try {
1233 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
1234 $url = trailingslashit( $endpoint ) . 'models';
1235
1236 $response = wp_remote_get( $url, [
1237 'headers' => [
1238 'x-api-key' => $this->apiKey,
1239 'anthropic-version' => '2023-06-01',
1240 ],
1241 'timeout' => 30,
1242 ] );
1243
1244 if ( is_wp_error( $response ) ) {
1245 throw new Exception( $response->get_error_message() );
1246 }
1247
1248 $code = wp_remote_retrieve_response_code( $response );
1249 $body = json_decode( wp_remote_retrieve_body( $response ), true );
1250
1251 if ( $code === 401 || ( isset( $body['error']['type'] ) && $body['error']['type'] === 'authentication_error' ) ) {
1252 throw new Exception( 'Invalid API key' );
1253 }
1254
1255 if ( $code !== 200 ) {
1256 throw new Exception( 'Connection failed: ' . ( $body['error']['message'] ?? "HTTP $code" ) );
1257 }
1258
1259 $availableModels = [];
1260 if ( isset( $body['data'] ) && is_array( $body['data'] ) ) {
1261 foreach ( array_slice( $body['data'], 0, 10 ) as $model ) {
1262 $availableModels[] = $model['id'] ?? 'unknown';
1263 }
1264 }
1265
1266 return [
1267 'models' => $availableModels,
1268 'service' => 'Anthropic'
1269 ];
1270 }
1271 catch ( Exception $e ) {
1272 throw new Exception( 'Connection failed: ' . $e->getMessage() );
1273 }
1274 }
1275
1276 /**
1277 * Upload a file to Anthropic Files API
1278 *
1279 * @param string $filename The name of the file
1280 * @param string $data The file content (binary)
1281 * @param string $purpose For Anthropic, this is the MIME type (API difference from OpenAI)
1282 * @return array The response from the API containing file_id
1283 * @throws Exception If upload fails
1284 */
1285 public function upload_file( $filename, $data, $purpose = 'application/pdf' ) {
1286 global $wp_filter;
1287
1288 // For Anthropic, $purpose is actually the MIME type (different from OpenAI's API)
1289 $mimeType = $purpose;
1290
1291 // Build multipart form data
1292 $boundary = wp_generate_password( 24, false );
1293 $body = '';
1294 $body .= "--$boundary\r\n";
1295 $body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n";
1296 $body .= 'Content-Type: ' . $mimeType . "\r\n\r\n";
1297 $body .= $data . "\r\n";
1298 $body .= "--$boundary\r\n";
1299 $body .= "Content-Disposition: form-data; name=\"mime_type\"\r\n\r\n";
1300 $body .= $mimeType . "\r\n";
1301 $body .= "--$boundary--\r\n";
1302
1303 // Temporarily remove ALL http_api_curl hooks to prevent streaming hook interference
1304 // Save current hooks
1305 $saved_hooks = null;
1306 if ( isset( $wp_filter['http_api_curl'] ) ) {
1307 $saved_hooks = $wp_filter['http_api_curl'];
1308 unset( $wp_filter['http_api_curl'] );
1309 }
1310
1311 // Upload using WordPress HTTP API
1312 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
1313 $url = $endpoint . '/files';
1314 $response = wp_remote_post( $url, [
1315 'headers' => [
1316 'x-api-key' => $this->apiKey,
1317 'anthropic-version' => '2023-06-01',
1318 'anthropic-beta' => 'files-api-2025-04-14',
1319 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
1320 ],
1321 'body' => $body,
1322 'timeout' => 60
1323 ] );
1324
1325 // Restore hooks
1326 if ( $saved_hooks !== null ) {
1327 $wp_filter['http_api_curl'] = $saved_hooks;
1328 }
1329
1330 if ( is_wp_error( $response ) ) {
1331 throw new Exception( 'File upload failed: ' . $response->get_error_message() );
1332 }
1333
1334 $response_body = wp_remote_retrieve_body( $response );
1335 $result = json_decode( $response_body, true );
1336
1337 // Check for API errors
1338 if ( isset( $result['error'] ) ) {
1339 throw new Exception( 'Anthropic Files API error: ' . ( $result['error']['message'] ?? 'Unknown error' ) );
1340 }
1341
1342 return $result;
1343 }
1344
1345 /**
1346 * Delete a file from Anthropic Files API
1347 *
1348 * @param string $fileId The Anthropic file ID to delete
1349 * @return array The response from the API
1350 * @throws Exception If deletion fails
1351 */
1352 public function delete_file( $fileId ) {
1353 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
1354 $url = $endpoint . '/files/' . $fileId;
1355
1356 $response = wp_remote_request( $url, [
1357 'method' => 'DELETE',
1358 'headers' => [
1359 'x-api-key' => $this->apiKey,
1360 'anthropic-version' => '2023-06-01',
1361 'anthropic-beta' => 'files-api-2025-04-14',
1362 ],
1363 'timeout' => 30
1364 ] );
1365
1366 if ( is_wp_error( $response ) ) {
1367 throw new Exception( 'File deletion failed: ' . $response->get_error_message() );
1368 }
1369
1370 $response_code = wp_remote_retrieve_response_code( $response );
1371 $response_body = wp_remote_retrieve_body( $response );
1372 $result = json_decode( $response_body, true );
1373
1374 // Check for API errors
1375 if ( $response_code >= 400 ) {
1376 $error_message = isset( $result['error']['message'] ) ? $result['error']['message'] : 'Unknown error';
1377 throw new Exception( 'Anthropic Files API error: ' . $error_message );
1378 }
1379
1380 return $result;
1381 }
1382 }
1383