PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.3.8
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.3.8
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 4 months ago chatml.php 4 months ago core.php 4 months ago factory.php 8 months ago google.php 4 months ago mistral.php 5 months ago open-router.php 5 months ago openai.php 4 months ago perplexity.php 6 months ago replicate.php 5 months ago
anthropic.php
1271 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 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'] = [
408 [
409 'type' => 'text',
410 'text' => $query->instructions,
411 'cache_control' => [ 'type' => 'ephemeral' ]
412 ]
413 ];
414 }
415
416 // Build the messages
417 $body['messages'][] = [ 'role' => 'user', 'content' => $query->message ];
418
419 if ( !empty( $query->blocks ) ) {
420 foreach ( $query->blocks as $feedback_block ) {
421 $contentBlock = $feedback_block['rawMessage']['content'];
422
423 // Process each content item individually to ensure proper handling of multiple tool_use blocks
424 if ( is_array( $contentBlock ) ) {
425 foreach ( $contentBlock as &$contentItem ) {
426 if ( isset( $contentItem['type'] ) && $contentItem['type'] === 'tool_use' ) {
427 // Debug logging for tool_use blocks
428 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
429 error_log( 'AI Engine: Anthropic tool_use block - ID: ' . ( $contentItem['id'] ?? 'unknown' ) .
430 ', Name: ' . ( $contentItem['name'] ?? 'unknown' ) .
431 ', Input type: ' . gettype( $contentItem['input'] ?? null ) .
432 ', Input value: ' . json_encode( $contentItem['input'] ?? null ) );
433 }
434
435 // Ensure input is an object, not an array
436 if ( isset( $contentItem['input'] ) ) {
437 if ( empty( $contentItem['input'] ) || ( is_array( $contentItem['input'] ) && count( $contentItem['input'] ) === 0 ) ) {
438 $contentItem['input'] = new stdClass();
439 }
440 else {
441 // Apply replaceEmptyArrayWithObject only to the input field
442 $contentItem['input'] = $this->replaceEmptyArrayWithObject( $contentItem['input'] );
443 }
444 }
445 else {
446 $contentItem['input'] = new stdClass();
447 }
448
449 // Debug logging after conversion
450 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
451 error_log( 'AI Engine: After conversion - Input type: ' . gettype( $contentItem['input'] ) .
452 ', Input value: ' . json_encode( $contentItem['input'] ) );
453 }
454 }
455 }
456 unset( $contentItem );
457 }
458
459 // Final debug logging before adding the message
460 if ( $this->core->get_option( 'queries_debug_mode' ) && is_array( $contentBlock ) ) {
461 error_log( 'AI Engine: Final contentBlock being added to messages: ' . json_encode( $contentBlock ) );
462 }
463
464 $assistantMessageIndex = count( $body['messages'] );
465 $body['messages'][] = [
466 'role' => 'assistant',
467 'content' => $contentBlock
468 ];
469
470 // Collect all tool results for this message
471 $toolResults = [];
472
473 foreach ( $feedback_block['feedbacks'] as $feedback ) {
474 $feedbackValue = $feedback['reply']['value'];
475 if ( !is_string( $feedbackValue ) ) {
476 $feedbackValue = json_encode( $feedbackValue );
477 }
478
479 $toolResults[] = [
480 'type' => 'tool_result',
481 'tool_use_id' => $feedback['request']['toolId'],
482 'content' => [
483 [
484 'type' => 'text',
485 'text' => $feedbackValue
486 ]
487 ],
488 'is_error' => false // Cool, Anthropic supports errors!
489 ];
490
491 // Note: Function result events are now emitted centrally in core.php
492 // when the function is actually executed
493 }
494
495 // Add all tool results in a single user message
496 // Anthropic requires all tool_results for a message to be in one content array
497 if ( !empty( $toolResults ) ) {
498 $body['messages'][] = [
499 'role' => 'user',
500 'content' => $toolResults
501 ];
502 }
503 }
504 }
505
506 // TODO: This WAS COPIED FROM BELOW
507 // Support for functions
508 if ( !empty( $query->functions ) ) {
509 $model = $this->retrieve_model_info( $query->model );
510 if ( !empty( $model['tags'] ) && !in_array( 'functions', $model['tags'] ) ) {
511 Meow_MWAI_Logging::warn( 'The model "' . $query->model . '" doesn\'t support Function Calling.' );
512 }
513 else {
514 $body['tools'] = [];
515 // Dynamic function: they will interactively enhance the completion (tools).
516 foreach ( $query->functions as $function ) {
517 $body['tools'][] = $function->serializeForAnthropic();
518 }
519 // Static functions: they will be executed at the end of the completion.
520 //$body['function_call'] = $query->functionCall;
521 }
522 }
523
524 // To avoid errors with Anthropic's API, we need to replace empty arrays with empty objects
525 // Note: We've already handled tool_use inputs above, so no need to process them again
526 return $body;
527 }
528 else if ( $query instanceof Meow_MWAI_Query_Text ) {
529 $body = [
530 'model' => $query->model,
531 'stream' => !is_null( $streamCallback ),
532 ];
533
534 if ( !empty( $query->maxTokens ) ) {
535 $body['max_tokens'] = $query->maxTokens;
536 }
537 else {
538 // https://docs.anthropic.com/en/docs/about-claude/models#model-comparison-table
539 $body['max_tokens'] = 4096;
540 }
541
542 if ( !empty( $query->temperature ) ) {
543 $body['temperature'] = $query->temperature;
544 }
545
546 if ( !empty( $query->stop ) ) {
547 $body['stop'] = $query->stop;
548 }
549
550 // Build system prompt with caching support.
551 // Instructions are cached (static per chatbot), context is not (varies per query).
552 $systemBlocks = [];
553 if ( !empty( $query->instructions ) ) {
554 $systemBlocks[] = [
555 'type' => 'text',
556 'text' => $query->instructions,
557 'cache_control' => [ 'type' => 'ephemeral' ]
558 ];
559 }
560 if ( !empty( $query->context ) ) {
561 $framedContext = $this->core->frame_context( $query->context );
562 $systemBlocks[] = [
563 'type' => 'text',
564 'text' => $framedContext
565 ];
566 }
567 if ( !empty( $systemBlocks ) ) {
568 $body['system'] = $systemBlocks;
569 }
570
571 // Support for functions
572 if ( !empty( $query->functions ) ) {
573 $model = $this->retrieve_model_info( $query->model );
574 if ( !empty( $model['tags'] ) && !in_array( 'functions', $model['tags'] ) ) {
575 Meow_MWAI_Logging::warn( 'The model "' . $query->model . '" doesn\'t support Function Calling.' );
576 }
577 else {
578 $body['tools'] = [];
579 // Dynamic function: they will interactively enhance the completion (tools).
580 foreach ( $query->functions as $function ) {
581 $body['tools'][] = $function->serializeForAnthropic();
582 }
583 // Static functions: they will be executed at the end of the completion.
584 //$body['function_call'] = $query->functionCall;
585 }
586 }
587
588 $body['messages'] = $this->build_messages( $query );
589
590 // Add MCP servers if available
591 if ( isset( $query->mcpServers ) && is_array( $query->mcpServers ) && !empty( $query->mcpServers ) ) {
592 $body['mcp_servers'] = [];
593 $mcp_envs = $this->core->get_option( 'mcp_envs' );
594 $this->mcpServerNames = []; // Reset MCP server names
595
596 foreach ( $query->mcpServers as $mcpServer ) {
597 if ( isset( $mcpServer['id'] ) ) {
598 // Find the full MCP server configuration by ID
599 foreach ( $mcp_envs as $env ) {
600 if ( $env['id'] === $mcpServer['id'] ) {
601 $mcp_config = [
602 'type' => 'url',
603 'url' => $env['url'],
604 'name' => $env['name'],
605 'tool_configuration' => [
606 'enabled' => true
607 ]
608 ];
609
610 // Add authorization token if available
611 if ( !empty( $env['token'] ) ) {
612 $mcp_config['authorization_token'] = $env['token'];
613 }
614
615 $body['mcp_servers'][] = $mcp_config;
616 $this->mcpServerNames[] = $env['name']; // Track MCP server names
617 break;
618 }
619 }
620 }
621 }
622 }
623
624 // Add code_execution tool if code_interpreter is enabled
625 if ( !empty( $query->tools ) && is_array( $query->tools ) && in_array( 'code_interpreter', $query->tools ) ) {
626 if ( !isset( $body['tools'] ) ) {
627 $body['tools'] = [];
628 }
629 $body['tools'][] = [
630 'type' => 'code_execution_20250825',
631 'name' => 'code_execution'
632 ];
633 Meow_MWAI_Logging::log( 'Anthropic: Added code_execution tool to request' );
634 }
635
636 return $body;
637 }
638 else {
639 throw new Exception( 'AI Engine: Unsupported query type.' );
640 }
641 }
642
643 protected function stream_data_handler( $json ) {
644 $content = null;
645 $type = !empty( $json['type'] ) ? $json['type'] : null;
646 if ( is_null( $type ) ) {
647 return $content;
648 }
649
650 if ( $type === 'message_start' ) {
651 $usage = $json['message']['usage'];
652 $this->streamInTokens = $usage['input_tokens'];
653 $this->inModel = $json['message']['model'];
654 $this->inId = $json['message']['id'];
655
656 // Send MCP discovery event if MCP servers are configured
657 if ( $this->currentDebugMode && $this->streamCallback ) {
658 if ( !empty( $this->mcpServerNames ) ) {
659 $serverCount = count( $this->mcpServerNames );
660
661 // Get MCP tools count
662 $mcpTools = apply_filters( 'mwai_mcp_tools', [] );
663 $toolCount = count( $mcpTools );
664
665 $event = Meow_MWAI_Event::mcp_discovery( $serverCount, $toolCount )
666 ->set_metadata( 'servers', $this->mcpServerNames );
667 call_user_func( $this->streamCallback, $event );
668 }
669 }
670 }
671 else if ( $type === 'content_block_start' ) {
672 $this->streamBlocks['content'][] = $json['content_block'];
673
674 // Send "Generating response..." when we start a text block
675 if ( $this->currentDebugMode && $this->streamCallback ) {
676 $block = $json['content_block'];
677 if ( $block['type'] === 'text' && !$this->textStarted ) {
678 $this->textStarted = true;
679 $event = Meow_MWAI_Event::generating_response();
680 call_user_func( $this->streamCallback, $event );
681 }
682 }
683 }
684 else if ( $type === 'content_block_delta' ) {
685 $index = $json['index'];
686 $block = $this->streamBlocks['content'][$index];
687 if ( $json['delta']['type'] === 'text_delta' ) {
688 $block['text'] .= $json['delta']['text'];
689 $isThinkingStart = strpos( $block['text'], '<thinking' ) === 0;
690 $isThinkingEnd = strpos( $block['text'], '</thinking>' ) === 0;
691
692 if ( $isThinkingStart ) {
693 $this->streamIsThinking = true;
694 // Send thinking start event
695 if ( $this->currentDebugMode && $this->streamCallback ) {
696 $event = Meow_MWAI_Event::thinking( 'Thinking...' );
697 call_user_func( $this->streamCallback, $event );
698 }
699 }
700 if ( $isThinkingEnd ) {
701 $this->streamIsThinking = false;
702 // Send thinking end event
703 if ( $this->currentDebugMode && $this->streamCallback ) {
704 $event = Meow_MWAI_Event::thinking( 'Thinking completed.' )
705 ->set_metadata( 'status', 'completed' );
706 call_user_func( $this->streamCallback, $event );
707 }
708 }
709 $content = $json['delta']['text'];
710 }
711 else if ( $json['delta']['type'] === 'input_json_delta' ) {
712 // Somehow, the input is set as an array, but it should be a string since it's JSON.
713 $block['input'] = is_array( $block['input'] ) ? '' : $block['input'];
714 $block['input'] .= $json['delta']['partial_json'];
715
716 // Skip sending tool arguments event - too verbose
717 // if ( $this->currentDebugMode && $this->streamCallback && isset($block['type']) && $block['type'] === 'tool_use' ) {
718 // $event = ( new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['TOOL_ARGS'] ) )
719 // ->set_content( 'Streaming tool arguments...' )
720 // ->set_metadata( 'tool_name', $block['name'] ?? 'unknown' )
721 // ->set_metadata( 'partial_args', $json['delta']['partial_json'] );
722 // call_user_func( $this->streamCallback, $event );
723 // }
724 }
725 $this->streamBlocks['content'][$index] = $block;
726 }
727 // At the end of a block, let's look for any 'input' not yet decoded from JSON
728 else if ( $type === 'content_block_stop' ) {
729 $index = $json['index'];
730 $block = $this->streamBlocks['content'][$index];
731 if ( isset( $block['input'] ) && is_string( $block['input'] ) ) {
732 $block['input'] = json_decode( $block['input'], true );
733 }
734
735 // For tool_use blocks, ensure empty inputs are objects, not arrays
736 if ( $block['type'] === 'tool_use' && isset( $block['input'] ) ) {
737 if ( empty( $block['input'] ) || ( is_array( $block['input'] ) && count( $block['input'] ) === 0 ) ) {
738 $block['input'] = new stdClass();
739 }
740 }
741
742 $this->streamBlocks['content'][$index] = $block;
743
744 // Send event for content block completion
745 if ( $this->currentDebugMode && $this->streamCallback ) {
746 if ( $block['type'] === 'mcp_tool_use' ) {
747 // Store the tool name for later lookup when we get the result
748 $this->mcpTools[$block['id']] = $block['name'];
749
750 $event = Meow_MWAI_Event::mcp_calling( $block['name'], $block['id'], $block['input'] ?? [] )
751 ->set_metadata( 'server_name', $block['server_name'] ?? 'unknown' );
752 call_user_func( $this->streamCallback, $event );
753 }
754 else if ( $block['type'] === 'mcp_tool_result' ) {
755 // Look up the tool name from the tool_use_id
756 $tool_use_id = $block['tool_use_id'] ?? '';
757 $tool_name = isset( $this->mcpTools[$tool_use_id] ) ? $this->mcpTools[$tool_use_id] : 'unknown';
758
759 $event = Meow_MWAI_Event::mcp_result( $tool_name, $tool_use_id )
760 ->set_metadata( 'content', $block['content'] ?? '' );
761 call_user_func( $this->streamCallback, $event );
762 }
763 else if ( $block['type'] === 'tool_use' ) {
764 // Regular tool use (non-MCP)
765 $event = Meow_MWAI_Event::function_calling( $block['name'] ?? 'unknown', $block['input'] ?? [] )
766 ->set_metadata( 'tool_id', $block['id'] ?? '' );
767 call_user_func( $this->streamCallback, $event );
768 }
769 else if ( $block['type'] === 'text' ) {
770 // Don't send any event here - the text generation is handled by content deltas
771 // and completion is handled by message_stop
772 }
773 else if ( $block['type'] === 'ping' ) {
774 // https://docs.anthropic.com/en/docs/build-with-claude/streaming#ping-events
775 }
776 else {
777 Meow_MWAI_Logging::log( 'Anthropic: Unknown block type in content_block_stop: ' . $block['type'] );
778 }
779 }
780 }
781 else if ( $type === 'message_delta' ) {
782 $usage = $json['usage'];
783 $this->streamOutTokens = $usage['output_tokens'];
784 }
785 else if ( $type === 'error' ) {
786 $error = $json['error'];
787 $message = $error['message'];
788
789 // Send error event
790 if ( $this->currentDebugMode && $this->streamCallback ) {
791 $event = Meow_MWAI_Event::error( $message )
792 ->set_metadata( 'error_type', $error['type'] ?? 'unknown' );
793 call_user_func( $this->streamCallback, $event );
794 }
795
796 throw new Exception( $message );
797 }
798 else if ( $type === 'message_stop' ) {
799 // Skip sending completion event - too verbose
800 // if ( $this->currentDebugMode && $this->streamCallback ) {
801 // $event = Meow_MWAI_Event::stream_completed()
802 // ->set_metadata( 'total_tokens', ($this->streamInTokens ?? 0) + ($this->streamOutTokens ?? 0) );
803 // call_user_func( $this->streamCallback, $event );
804 // }
805 }
806 else if ( $type === 'keepalive' ) {
807 // Forward keepalive as SSE comment to keep browser connection alive during long MCP calls
808 echo ": keepalive\n\n";
809 if ( ob_get_level() > 0 ) {
810 ob_end_flush();
811 }
812 flush();
813 }
814 else {
815 Meow_MWAI_Logging::log( "Anthropic: Unknown stream data type: $type" );
816 }
817
818 // Avoid some endings
819 $endings = [ '<|im_end|>', '</s>' ];
820 if ( in_array( $content, $endings ) ) {
821 $content = null;
822 }
823
824 // If the stream is thinking, we don't want to return anything yet.
825 if ( $this->streamIsThinking ) {
826 $content = null;
827 }
828
829 return ( $content === '0' || !empty( $content ) ) ? $content : null;
830 }
831
832 // This create the "choices" (even though, often, it is only one choice).
833 // It is basically the reply, but one that is understood by the Meow_MWAI_Reply class.
834 public function create_choices( $data ) {
835 $returned_choices = [];
836 $tool_calls = [];
837 $text_content = '';
838
839 // First, collect all tool calls and text content
840 foreach ( $data['content'] as $content ) {
841 if ( $content['type'] === 'tool_use' ) {
842 // Collect all tool calls
843 $arguments = $content['input'] ?? new stdClass();
844
845 // Ensure arguments is properly formatted
846 if ( empty( $arguments ) ) {
847 $arguments = new stdClass();
848 }
849 else if ( is_array( $arguments ) && count( $arguments ) === 0 ) {
850 $arguments = new stdClass();
851 }
852
853 $tool_calls[] = [
854 'id' => $content['id'],
855 'type' => 'function',
856 'function' => [
857 'name' => $content['name'],
858 'arguments' => $arguments,
859 ]
860 ];
861 }
862 else if ( $content['type'] === 'text' ) {
863 $text_content .= $content['text'];
864 }
865 }
866
867 // Create a single choice with both tool calls and text content (like OpenAI does)
868 $message = [];
869
870 if ( !empty( $text_content ) ) {
871 $message['content'] = $text_content;
872 }
873
874 if ( !empty( $tool_calls ) ) {
875 $message['tool_calls'] = $tool_calls;
876 }
877
878 // Only create a choice if there's content or tool calls
879 if ( !empty( $message ) ) {
880 $returned_choices[] = [
881 'message' => $message
882 ];
883 }
884
885 return $returned_choices;
886 }
887
888 /**
889 * Override reset to include Anthropic-specific state
890 */
891 protected function reset_request_state() {
892 parent::reset_request_state();
893
894 // Reset Anthropic-specific state
895 $this->mcpTools = [];
896 $this->mcpToolCount = 0;
897 // Note: mcpServerNames is configuration, not request state
898 }
899
900 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
901 // Reset request-specific state to prevent leakage between requests
902 $this->reset_request_state();
903
904 $isStreaming = !is_null( $streamCallback );
905
906 // Initialize debug mode
907 $this->init_debug_mode( $query );
908
909 // IMPORTANT: Prepare query BEFORE setting up streaming hooks
910 // The streaming hook intercepts ALL wp_remote_* calls, so preparation must happen first
911 $this->prepare_query( $query );
912
913 if ( $isStreaming ) {
914 $this->streamCallback = $streamCallback;
915 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
916 }
917
918 $this->reset_stream();
919 $data = null;
920 $body = $this->build_body( $query, $streamCallback );
921 $url = $this->build_url( $query );
922 $headers = $this->build_headers( $query );
923 $options = $this->build_options( $headers, $body );
924
925 // Emit "Request sent" event for feedback queries
926 if ( $this->currentDebugMode && !empty( $streamCallback ) &&
927 ( $query instanceof Meow_MWAI_Query_Feedback || $query instanceof Meow_MWAI_Query_AssistFeedback ) ) {
928 $event = Meow_MWAI_Event::request_sent()
929 ->set_metadata( 'is_feedback', true )
930 ->set_metadata( 'feedback_count', count( $query->blocks ) );
931 call_user_func( $streamCallback, $event );
932 }
933
934 try {
935 $res = $this->run_query( $url, $options, $streamCallback );
936 $reply = new Meow_MWAI_Reply( $query );
937 $returned_id = null;
938 $returned_model = null;
939 $returned_choices = [];
940
941 // Streaming Mode
942 if ( $isStreaming ) {
943 $returned_id = $this->inId;
944 $returned_model = $this->inModel ? $this->inModel : $query->model;
945 if ( !is_null( $this->streamInTokens && !is_null( $this->streamOutTokens ) ) ) {
946 $returned_in_tokens = $this->streamInTokens;
947 $returned_out_tokens = $this->streamOutTokens;
948 }
949 $data = $this->streamBlocks;
950
951 // Clean up streaming data as well
952 if ( isset( $data['content'] ) && is_array( $data['content'] ) ) {
953 foreach ( $data['content'] as &$content ) {
954 if ( $content['type'] === 'tool_use' && isset( $content['input'] ) ) {
955 if ( empty( $content['input'] ) || ( is_array( $content['input'] ) && count( $content['input'] ) === 0 ) ) {
956 $content['input'] = new stdClass();
957 }
958 }
959 }
960 unset( $content );
961 }
962
963 $returned_choices = $this->create_choices( $this->streamBlocks );
964 }
965 // Standard Mode
966 else {
967 $data = $res['data'];
968
969 // Clean up tool_use inputs in the raw data BEFORE it gets stored
970 if ( isset( $data['content'] ) && is_array( $data['content'] ) ) {
971 foreach ( $data['content'] as &$content ) {
972 if ( $content['type'] === 'tool_use' && isset( $content['input'] ) ) {
973 if ( empty( $content['input'] ) || ( is_array( $content['input'] ) && count( $content['input'] ) === 0 ) ) {
974 $content['input'] = new stdClass();
975 }
976 }
977 }
978 unset( $content );
979 }
980
981 $returned_id = $data['id'];
982 $returned_model = $data['model'];
983 $usage = $data['usage'];
984 if ( !empty( $usage ) ) {
985 $returned_in_tokens = isset( $usage['input_tokens'] ) ? $usage['input_tokens'] : null;
986 $returned_out_tokens = isset( $usage['output_tokens'] ) ? $usage['output_tokens'] : null;
987 }
988 $returned_choices = $this->create_choices( $data );
989 }
990
991 $reply->set_choices( $returned_choices, $data );
992 if ( !empty( $returned_id ) ) {
993 $reply->set_id( $returned_id );
994 }
995
996 // Handle tokens.
997 $this->handle_tokens_usage(
998 $reply,
999 $query,
1000 $returned_model,
1001 $returned_in_tokens,
1002 $returned_out_tokens
1003 );
1004
1005 return $reply;
1006 }
1007 catch ( Exception $e ) {
1008 $error = $e->getMessage();
1009 $json = json_decode( $error, true );
1010 if ( json_last_error() === JSON_ERROR_NONE ) {
1011 if ( isset( $json['error'] ) && isset( $json['error']['message'] ) ) {
1012 $error = $json['error']['message'];
1013 }
1014 }
1015 Meow_MWAI_Logging::error( '(Anthropic) ' . $error );
1016 $service = $this->get_service_name();
1017 $message = "From $service: " . $error;
1018 throw new Exception( $message );
1019 }
1020 finally {
1021 if ( $isStreaming ) {
1022 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
1023 }
1024 }
1025 }
1026
1027 protected function build_options( $headers, $json = null, $forms = null, $method = 'POST' ) {
1028 $body = null;
1029 if ( !empty( $forms ) ) {
1030 $boundary = wp_generate_password( 24, false );
1031 $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary;
1032 $body = $this->build_form_body( $forms, $boundary );
1033 }
1034 else if ( !empty( $json ) ) {
1035 // For Anthropic, we need to ensure empty objects stay as objects, not arrays
1036 // JSON_FORCE_OBJECT would force everything to be an object, which we don't want
1037 // Instead, we've already converted empty arrays to stdClass in build_body
1038 $body = $this->safe_json_encode( $json, 'request body' );
1039
1040 // Debug logging to verify JSON encoding
1041 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
1042 // Check if the body contains tool_use blocks with empty inputs
1043 if ( strpos( $body, '"tool_use"' ) !== false ) {
1044 error_log( 'AI Engine: Anthropic JSON body after encoding (first 1000 chars): ' . substr( $body, 0, 1000 ) );
1045
1046 // Check specifically for "input":[] which would be wrong
1047 if ( strpos( $body, '"input":[]' ) !== false ) {
1048 error_log( 'AI Engine: WARNING - Found "input":[] in JSON body, this should be "input":{} for Anthropic API' );
1049 }
1050 }
1051 }
1052 }
1053 $options = [
1054 'headers' => $headers,
1055 'method' => $method,
1056 'timeout' => MWAI_TIMEOUT,
1057 'body' => $body,
1058 'sslverify' => MWAI_SSL_VERIFY
1059 ];
1060 return $options;
1061 }
1062
1063 protected function get_service_name() {
1064 return 'Anthropic';
1065 }
1066
1067 public function get_models() {
1068 return apply_filters( 'mwai_anthropic_models', MWAI_ANTHROPIC_MODELS );
1069 }
1070
1071 public static function get_models_static() {
1072 return MWAI_ANTHROPIC_MODELS;
1073 }
1074
1075 public function handle_tokens_usage(
1076 $reply,
1077 $query,
1078 $returned_model,
1079 $returned_in_tokens,
1080 $returned_out_tokens,
1081 $returned_price = null
1082 ) {
1083 $returned_in_tokens = !is_null( $returned_in_tokens ) ?
1084 $returned_in_tokens : $reply->get_in_tokens( $query );
1085 $returned_out_tokens = !is_null( $returned_out_tokens ) ?
1086 $returned_out_tokens : $reply->get_out_tokens();
1087 if ( !empty( $reply->id ) ) {
1088 // Would be cool to retrieve the usage from the API, but it's not possible.
1089 }
1090 $usage = $this->core->record_tokens_usage( $returned_model, $returned_in_tokens, $returned_out_tokens );
1091 $reply->set_usage( $usage );
1092
1093 // Set accuracy based on data availability
1094 if ( !is_null( $returned_in_tokens ) && !is_null( $returned_out_tokens ) ) {
1095 // Anthropic provides token counts from API = tokens accuracy
1096 $reply->set_usage_accuracy( 'tokens' );
1097 }
1098 else {
1099 // Fallback to estimated
1100 $reply->set_usage_accuracy( 'estimated' );
1101 }
1102 }
1103
1104 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
1105 return parent::get_price( $query, $reply );
1106 }
1107
1108 /**
1109 * Check the connection to Anthropic by listing available models.
1110 * Anthropic doesn't provide a models endpoint, so we just verify authentication works.
1111 */
1112 public function connection_check() {
1113 try {
1114 // Get the endpoint
1115 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
1116
1117 // For Anthropic, we'll use the messages endpoint with a minimal request to verify auth
1118 $url = trailingslashit( $endpoint ) . 'messages';
1119
1120 // Create a minimal query just to test authentication
1121 $testBody = [
1122 'model' => 'claude-3-haiku-20240307', // Use cheapest model
1123 'max_tokens' => 1,
1124 'messages' => [
1125 ['role' => 'user', 'content' => 'Hi']
1126 ],
1127 'metadata' => [
1128 'user_id' => 'connection_test'
1129 ]
1130 ];
1131
1132 // Build headers with a dummy query
1133 $dummyQuery = new Meow_MWAI_Query_Text( 'test' );
1134 $headers = $this->build_headers( $dummyQuery );
1135 $options = $this->build_options( $headers, $testBody );
1136
1137 // Try to make a minimal request
1138 $response = $this->run_query( $url, $options );
1139
1140 // If we get here without exception, the API key is valid
1141 // Get the list of available models from our constants
1142 $models = $this->get_models();
1143 $modelNames = array_map( function ( $model ) {
1144 return $model['model'] ?? $model['name'] ?? 'unknown';
1145 }, $models );
1146
1147 return [
1148 'models' => array_slice( $modelNames, 0, 10 ), // Return first 10 models
1149 'service' => 'Anthropic'
1150 ];
1151 }
1152 catch ( Exception $e ) {
1153 // Check if it's an authentication error
1154 $message = $e->getMessage();
1155 if ( strpos( $message, 'authentication_error' ) !== false ||
1156 strpos( $message, 'invalid x-api-key' ) !== false ||
1157 strpos( $message, '401' ) !== false ) {
1158 throw new Exception( 'Invalid API key' );
1159 }
1160 throw new Exception( 'Connection failed: ' . $message );
1161 }
1162 }
1163
1164 /**
1165 * Upload a file to Anthropic Files API
1166 *
1167 * @param string $filename The name of the file
1168 * @param string $data The file content (binary)
1169 * @param string $purpose For Anthropic, this is the MIME type (API difference from OpenAI)
1170 * @return array The response from the API containing file_id
1171 * @throws Exception If upload fails
1172 */
1173 public function upload_file( $filename, $data, $purpose = 'application/pdf' ) {
1174 global $wp_filter;
1175
1176 // For Anthropic, $purpose is actually the MIME type (different from OpenAI's API)
1177 $mimeType = $purpose;
1178
1179 // Build multipart form data
1180 $boundary = wp_generate_password( 24, false );
1181 $body = '';
1182 $body .= "--$boundary\r\n";
1183 $body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n";
1184 $body .= 'Content-Type: ' . $mimeType . "\r\n\r\n";
1185 $body .= $data . "\r\n";
1186 $body .= "--$boundary\r\n";
1187 $body .= "Content-Disposition: form-data; name=\"mime_type\"\r\n\r\n";
1188 $body .= $mimeType . "\r\n";
1189 $body .= "--$boundary--\r\n";
1190
1191 // Temporarily remove ALL http_api_curl hooks to prevent streaming hook interference
1192 // Save current hooks
1193 $saved_hooks = null;
1194 if ( isset( $wp_filter['http_api_curl'] ) ) {
1195 $saved_hooks = $wp_filter['http_api_curl'];
1196 unset( $wp_filter['http_api_curl'] );
1197 }
1198
1199 // Upload using WordPress HTTP API
1200 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
1201 $url = $endpoint . '/files';
1202 $response = wp_remote_post( $url, [
1203 'headers' => [
1204 'x-api-key' => $this->apiKey,
1205 'anthropic-version' => '2023-06-01',
1206 'anthropic-beta' => 'files-api-2025-04-14',
1207 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
1208 ],
1209 'body' => $body,
1210 'timeout' => 60
1211 ] );
1212
1213 // Restore hooks
1214 if ( $saved_hooks !== null ) {
1215 $wp_filter['http_api_curl'] = $saved_hooks;
1216 }
1217
1218 if ( is_wp_error( $response ) ) {
1219 throw new Exception( 'File upload failed: ' . $response->get_error_message() );
1220 }
1221
1222 $response_body = wp_remote_retrieve_body( $response );
1223 $result = json_decode( $response_body, true );
1224
1225 // Check for API errors
1226 if ( isset( $result['error'] ) ) {
1227 throw new Exception( 'Anthropic Files API error: ' . ( $result['error']['message'] ?? 'Unknown error' ) );
1228 }
1229
1230 return $result;
1231 }
1232
1233 /**
1234 * Delete a file from Anthropic Files API
1235 *
1236 * @param string $fileId The Anthropic file ID to delete
1237 * @return array The response from the API
1238 * @throws Exception If deletion fails
1239 */
1240 public function delete_file( $fileId ) {
1241 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
1242 $url = $endpoint . '/files/' . $fileId;
1243
1244 $response = wp_remote_request( $url, [
1245 'method' => 'DELETE',
1246 'headers' => [
1247 'x-api-key' => $this->apiKey,
1248 'anthropic-version' => '2023-06-01',
1249 'anthropic-beta' => 'files-api-2025-04-14',
1250 ],
1251 'timeout' => 30
1252 ] );
1253
1254 if ( is_wp_error( $response ) ) {
1255 throw new Exception( 'File deletion failed: ' . $response->get_error_message() );
1256 }
1257
1258 $response_code = wp_remote_retrieve_response_code( $response );
1259 $response_body = wp_remote_retrieve_body( $response );
1260 $result = json_decode( $response_body, true );
1261
1262 // Check for API errors
1263 if ( $response_code >= 400 ) {
1264 $error_message = isset( $result['error']['message'] ) ? $result['error']['message'] : 'Unknown error';
1265 throw new Exception( 'Anthropic Files API error: ' . $error_message );
1266 }
1267
1268 return $result;
1269 }
1270 }
1271