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