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