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