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