PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.2.7
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.2.7
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 8 months ago chatml.php 7 months ago core.php 7 months ago factory.php 8 months ago google.php 7 months ago mistral.php 9 months ago open-router.php 7 months ago openai.php 7 months ago perplexity.php 10 months ago replicate.php 7 months ago
anthropic.php
1129 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 // Process each attachment - upload PDFs to Anthropic Files API
42 foreach ( $attachments as $index => $file ) {
43 $mimeType = $file->get_mimeType() ?? '';
44 $isPDF = strpos( $mimeType, 'application/pdf' ) === 0;
45
46 // Skip files already uploaded (type = provider_file_id)
47 if ( $file->get_type() === 'provider_file_id' ) {
48 continue;
49 }
50
51 // Only PDFs need to be uploaded to Anthropic Files API
52 // Images are handled differently (base64 in build_messages)
53 if ( $isPDF ) {
54 try {
55 // Get file data from WordPress uploads directory
56 $refId = $file->get_refId();
57 $data = $this->core->files->get_data( $refId );
58 $filename = $file->get_filename();
59
60 // Upload to Anthropic Files API
61 $uploadedFile = $this->upload_file( $filename, $data, $mimeType );
62 $fileId = $uploadedFile['id'] ?? null;
63
64 if ( $fileId ) {
65 // Replace the file object in attachedFiles array with provider_file_id type
66 if ( !empty( $query->attachedFiles ) && isset( $query->attachedFiles[$index] ) ) {
67 $query->attachedFiles[$index] = Meow_MWAI_Query_DroppedFile::from_provider_file_id(
68 $fileId,
69 $file->get_purpose(),
70 $file->get_mimeType()
71 );
72 }
73 // Also update legacy attachedFile if this is the first file
74 if ( $index === 0 && !empty( $query->attachedFile ) ) {
75 $query->attachedFile = Meow_MWAI_Query_DroppedFile::from_provider_file_id(
76 $fileId,
77 $file->get_purpose(),
78 $file->get_mimeType()
79 );
80 }
81 }
82 } catch ( Exception $e ) {
83 error_log( '[AI Engine] Failed to upload PDF to Anthropic Files API: ' . $e->getMessage() );
84 // Keep the original file - build_messages() will fall back to base64
85 }
86 }
87 }
88 }
89
90 protected function isMCPTool( $toolName ) {
91 // Get all MCP tools from the filter
92 $mcpTools = apply_filters( 'mwai_mcp_tools', [] );
93
94 // Log available MCP tools for debugging
95 if ( empty( $mcpTools ) ) {
96 Meow_MWAI_Logging::log( 'Anthropic: No MCP tools available from filter' );
97 }
98
99 foreach ( $mcpTools as $tool ) {
100 if ( isset( $tool['name'] ) && $tool['name'] === $toolName ) {
101 Meow_MWAI_Logging::log( "Anthropic: Found MCP tool match: {$toolName}" );
102 return true;
103 }
104 }
105
106 // If we have MCP servers but tool not found, it might be an issue
107 if ( !empty( $this->mcpServerNames ) && !empty( $toolName ) ) {
108 Meow_MWAI_Logging::log( "Anthropic: Tool '{$toolName}' not found in MCP tools list" );
109 }
110
111 return false;
112 }
113
114 public function reset_stream() {
115 $this->streamContent = null;
116 $this->streamBuffer = null;
117 $this->streamFunctionCall = null;
118 $this->streamToolCalls = [];
119 $this->streamLastMessage = null;
120 $this->streamInTokens = null;
121 $this->streamOutTokens = null;
122 $this->streamIsThinking = false;
123 $this->mcpTools = []; // Reset MCP tools tracking
124 $this->textStarted = false; // Reset text started flag
125 $this->requestSentEmitted = false; // Reset request sent flag
126 $this->emittedFunctionResults = []; // Reset function result tracking
127
128 $this->streamBlocks = [
129 'role' => 'assistant',
130 'content' => []
131 ];
132
133 $this->inModel = null;
134 $this->inId = null;
135 }
136
137 protected function set_environment() {
138 $env = $this->env;
139 $this->apiKey = $env['apikey'];
140 }
141
142 protected function build_url( $query, $endpoint = null ) {
143 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
144 if ( $query instanceof Meow_MWAI_Query_Text || $query instanceof Meow_MWAI_Query_Feedback ) {
145 $url = trailingslashit( $endpoint ) . 'messages';
146 }
147 else {
148 throw new Exception( 'AI Engine: Unsupported query type.' );
149 }
150 return $url;
151 }
152
153 protected function build_headers( $query ) {
154 parent::build_headers( $query );
155 $headers = [
156 'Content-Type' => 'application/json',
157 'x-api-key' => $this->apiKey,
158 'anthropic-version' => '2023-06-01',
159 'anthropic-beta' => 'tools-2024-04-04, pdfs-2024-09-25, mcp-client-2025-04-04, files-api-2025-04-14',
160 'User-Agent' => 'AI Engine',
161 ];
162 return $headers;
163 }
164
165 public function final_checks( Meow_MWAI_Query_Base $query ) {
166 // We skip this completely.
167 // maxMessages is handed in build_messages().
168 }
169
170 /**
171 * Build messages array for Anthropic API request.
172 *
173 * This method constructs the 'messages' array that will be sent to Anthropic's API.
174 * It processes both conversation history and file attachments.
175 *
176 * CRITICAL: This method handles BOTH single file (attachedFile) and multi-file (attachedFiles).
177 * The attachedFiles array is the PRIMARY path for multi-file uploads.
178 *
179 * @param Meow_MWAI_Query_Text $query The query to build messages from
180 * @return array Messages formatted for Anthropic API
181 */
182 protected function build_messages( $query ) {
183 $messages = [];
184
185 // Add conversation history (previous messages)
186 foreach ( $query->messages as $message ) {
187 $messages[] = $message;
188 }
189
190 // Limit message history if maxMessages is set
191 if ( !empty( $query->maxMessages ) ) {
192 $messages = array_slice( $messages, -$query->maxMessages );
193 }
194
195 // Anthropic requires first message to have 'user' role
196 if ( !empty( $messages ) && $messages[0]['role'] !== 'user' ) {
197 array_shift( $messages );
198 }
199
200 // =====================================================================
201 // FILE UPLOAD: Process all attachments (unified approach)
202 // =====================================================================
203 // Uses getAttachments() which returns both attachedFiles and legacy attachedFile
204 $attachments = method_exists( $query, 'getAttachments' ) ? $query->getAttachments() : [];
205 if ( !empty( $attachments ) ) {
206 $message = $query->get_message();
207 if ( empty( $message ) ) {
208 $message = 'I uploaded files. Do not consider this message as part of the conversation.';
209 }
210
211 // Build content array: [text, document, document, ...] or [text, image, image, ...]
212 $content = [
213 [
214 'type' => 'text',
215 'text' => $message
216 ]
217 ];
218
219 // Process each file and add to content array
220 foreach ( $attachments as $file ) {
221 $mime = $file->get_mimeType();
222 $isPDF = $mime === 'application/pdf';
223 $isIMG = !$isPDF && $file->is_image();
224 $isProviderFile = $file->get_type() === 'provider_file_id';
225
226 // ===== PDF FILES =====
227 if ( $isPDF ) {
228 $documentSource = null;
229 if ( $isProviderFile ) {
230 // File was uploaded in prepare_query() - use file_id reference
231 // This is the EXPECTED path after prepare_query() runs
232 $fileId = $file->get_refId();
233 $documentSource = [
234 'type' => 'file',
235 'file_id' => $fileId // e.g., file_011CTkNhtS6cU3CKcvTPCfvw
236 ];
237 } else {
238 // Fallback: File not uploaded yet (shouldn't happen if prepare_query ran)
239 // This handles edge cases where prepare_query was skipped
240 try {
241 $refId = $file->get_refId();
242 $data = $this->core->files->get_data( $refId );
243 $filename = $file->get_filename();
244 $uploadedFile = $this->upload_file( $filename, $data, $mime );
245 $fileId = $uploadedFile['id'] ?? null;
246
247 if ( $fileId ) {
248 $documentSource = [
249 'type' => 'file',
250 'file_id' => $fileId
251 ];
252 } else {
253 throw new Exception( 'Upload failed - no file_id returned' );
254 }
255 } catch ( Exception $e ) {
256 error_log( '[AI Engine] Failed to upload PDF to Anthropic Files API: ' . $e->getMessage() . ', falling back to base64' );
257 // Last resort: base64 encoding (less efficient)
258 $data = $file->get_base64();
259 $documentSource = [
260 'type' => 'base64',
261 'media_type' => 'application/pdf',
262 'data' => $data
263 ];
264 }
265 }
266
267 // Add document to content array
268 $content[] = [
269 'type' => 'document',
270 'source' => $documentSource
271 ];
272 }
273 // ===== IMAGE FILES =====
274 else if ( $isIMG ) {
275 $imageSource = null;
276 if ( $isProviderFile ) {
277 // Use file_id reference (if uploaded to Files API)
278 $fileId = $file->get_refId();
279 $imageSource = [
280 'type' => 'file',
281 'file_id' => $fileId
282 ];
283 } else {
284 // Use base64 encoding (standard for images)
285 $data = $file->get_base64();
286 $imageSource = [
287 'type' => 'base64',
288 'media_type' => $mime,
289 'data' => $data
290 ];
291 }
292
293 // Add image to content array
294 $content[] = [
295 'type' => 'image',
296 'source' => $imageSource
297 ];
298 }
299 }
300
301 // Add the complete message with all files to messages array
302 $messages[] = [
303 'role' => 'user',
304 'content' => $content // [text, document, document] or [text, image, image]
305 ];
306 }
307 else {
308 $messages[] = [ 'role' => 'user', 'content' => $query->get_message() ];
309 }
310
311 return $messages;
312 }
313
314 // Define a function to recursively replace empty arrays with empty stdClass objects
315 // To avoid errors with OpenAI's API
316 private function replaceEmptyArrayWithObject( $item ) {
317 if ( is_array( $item ) ) {
318 if ( empty( $item ) ) {
319 return new stdClass(); // Replace empty array with empty object
320 }
321 foreach ( $item as $key => $value ) {
322 $item[$key] = $this->replaceEmptyArrayWithObject( $value ); // Recurse
323 }
324 }
325 return $item;
326 }
327
328 protected function build_body( $query, $streamCallback = null, $extra = null ) {
329 if ( $query instanceof Meow_MWAI_Query_Feedback ) {
330 $body = [
331 'model' => $query->model,
332 'max_tokens' => $query->maxTokens,
333 'temperature' => $query->temperature,
334 'stream' => !is_null( $streamCallback ),
335 'messages' => []
336 ];
337
338 if ( !empty( $query->instructions ) ) {
339 $body['system'] = $query->instructions;
340 }
341
342 // Build the messages
343 $body['messages'][] = [ 'role' => 'user', 'content' => $query->message ];
344
345 if ( !empty( $query->blocks ) ) {
346 foreach ( $query->blocks as $feedback_block ) {
347 $contentBlock = $feedback_block['rawMessage']['content'];
348
349 // Process each content item individually to ensure proper handling of multiple tool_use blocks
350 if ( is_array( $contentBlock ) ) {
351 foreach ( $contentBlock as &$contentItem ) {
352 if ( isset( $contentItem['type'] ) && $contentItem['type'] === 'tool_use' ) {
353 // Debug logging for tool_use blocks
354 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
355 error_log( 'AI Engine: Anthropic tool_use block - ID: ' . ( $contentItem['id'] ?? 'unknown' ) .
356 ', Name: ' . ( $contentItem['name'] ?? 'unknown' ) .
357 ', Input type: ' . gettype( $contentItem['input'] ?? null ) .
358 ', Input value: ' . json_encode( $contentItem['input'] ?? null ) );
359 }
360
361 // Ensure input is an object, not an array
362 if ( isset( $contentItem['input'] ) ) {
363 if ( empty( $contentItem['input'] ) || ( is_array( $contentItem['input'] ) && count( $contentItem['input'] ) === 0 ) ) {
364 $contentItem['input'] = new stdClass();
365 } else {
366 // Apply replaceEmptyArrayWithObject only to the input field
367 $contentItem['input'] = $this->replaceEmptyArrayWithObject( $contentItem['input'] );
368 }
369 } else {
370 $contentItem['input'] = new stdClass();
371 }
372
373 // Debug logging after conversion
374 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
375 error_log( 'AI Engine: After conversion - Input type: ' . gettype( $contentItem['input'] ) .
376 ', Input value: ' . json_encode( $contentItem['input'] ) );
377 }
378 }
379 }
380 unset( $contentItem );
381 }
382
383 // Final debug logging before adding the message
384 if ( $this->core->get_option( 'queries_debug_mode' ) && is_array( $contentBlock ) ) {
385 error_log( 'AI Engine: Final contentBlock being added to messages: ' . json_encode( $contentBlock ) );
386 }
387
388 $assistantMessageIndex = count( $body['messages'] );
389 $body['messages'][] = [
390 'role' => 'assistant',
391 'content' => $contentBlock
392 ];
393
394 // Collect all tool results for this message
395 $toolResults = [];
396
397 foreach ( $feedback_block['feedbacks'] as $feedback ) {
398 $feedbackValue = $feedback['reply']['value'];
399 if ( !is_string( $feedbackValue ) ) {
400 $feedbackValue = json_encode( $feedbackValue );
401 }
402
403 $toolResults[] = [
404 'type' => 'tool_result',
405 'tool_use_id' => $feedback['request']['toolId'],
406 'content' => [
407 [
408 'type' => 'text',
409 'text' => $feedbackValue
410 ]
411 ],
412 'is_error' => false // Cool, Anthropic supports errors!
413 ];
414
415 // Note: Function result events are now emitted centrally in core.php
416 // when the function is actually executed
417 }
418
419 // Add all tool results in a single user message
420 // Anthropic requires all tool_results for a message to be in one content array
421 if ( !empty( $toolResults ) ) {
422 $body['messages'][] = [
423 'role' => 'user',
424 'content' => $toolResults
425 ];
426 }
427 }
428 }
429
430 // TODO: This WAS COPIED FROM BELOW
431 // Support for functions
432 if ( !empty( $query->functions ) ) {
433 $model = $this->retrieve_model_info( $query->model );
434 if ( !empty( $model['tags'] ) && !in_array( 'functions', $model['tags'] ) ) {
435 Meow_MWAI_Logging::warn( 'The model "' . $query->model . '" doesn\'t support Function Calling.' );
436 }
437 else {
438 $body['tools'] = [];
439 // Dynamic function: they will interactively enhance the completion (tools).
440 foreach ( $query->functions as $function ) {
441 $body['tools'][] = $function->serializeForAnthropic();
442 }
443 // Static functions: they will be executed at the end of the completion.
444 //$body['function_call'] = $query->functionCall;
445 }
446 }
447
448 // To avoid errors with Anthropic's API, we need to replace empty arrays with empty objects
449 // Note: We've already handled tool_use inputs above, so no need to process them again
450 return $body;
451 }
452 else if ( $query instanceof Meow_MWAI_Query_Text ) {
453 $body = [
454 'model' => $query->model,
455 'stream' => !is_null( $streamCallback ),
456 ];
457
458 if ( !empty( $query->maxTokens ) ) {
459 $body['max_tokens'] = $query->maxTokens;
460 }
461 else {
462 // https://docs.anthropic.com/en/docs/about-claude/models#model-comparison-table
463 $body['max_tokens'] = 4096;
464 }
465
466 if ( !empty( $query->temperature ) ) {
467 $body['temperature'] = $query->temperature;
468 }
469
470 if ( !empty( $query->stop ) ) {
471 $body['stop'] = $query->stop;
472 }
473
474 // First, we need to add the first message (the instructions).
475 if ( !empty( $query->instructions ) ) {
476 $body['system'] = $query->instructions;
477 }
478
479 // If there is a context, we need to add it.
480 if ( !empty( $query->context ) ) {
481 if ( empty( $body['system'] ) ) {
482 $body['system'] = '';
483 }
484 $body['system'] = empty( $body['system'] ) ? '' : $body['system'] . "\n\n";
485 $body['system'] = $body['system'] . "Context:\n\n" . $query->context;
486 }
487
488 // Support for functions
489 if ( !empty( $query->functions ) ) {
490 $model = $this->retrieve_model_info( $query->model );
491 if ( !empty( $model['tags'] ) && !in_array( 'functions', $model['tags'] ) ) {
492 Meow_MWAI_Logging::warn( 'The model "' . $query->model . '" doesn\'t support Function Calling.' );
493 }
494 else {
495 $body['tools'] = [];
496 // Dynamic function: they will interactively enhance the completion (tools).
497 foreach ( $query->functions as $function ) {
498 $body['tools'][] = $function->serializeForAnthropic();
499 }
500 // Static functions: they will be executed at the end of the completion.
501 //$body['function_call'] = $query->functionCall;
502 }
503 }
504
505 $body['messages'] = $this->build_messages( $query );
506
507 // Add MCP servers if available
508 if ( isset( $query->mcpServers ) && is_array( $query->mcpServers ) && !empty( $query->mcpServers ) ) {
509 $body['mcp_servers'] = [];
510 $mcp_envs = $this->core->get_option( 'mcp_envs' );
511 $this->mcpServerNames = []; // Reset MCP server names
512
513 foreach ( $query->mcpServers as $mcpServer ) {
514 if ( isset( $mcpServer['id'] ) ) {
515 // Find the full MCP server configuration by ID
516 foreach ( $mcp_envs as $env ) {
517 if ( $env['id'] === $mcpServer['id'] ) {
518 $mcp_config = [
519 'type' => 'url',
520 'url' => $env['url'],
521 'name' => $env['name'],
522 'tool_configuration' => [
523 'enabled' => true
524 ]
525 ];
526
527 // Add authorization token if available
528 if ( !empty( $env['token'] ) ) {
529 $mcp_config['authorization_token'] = $env['token'];
530 }
531
532 $body['mcp_servers'][] = $mcp_config;
533 $this->mcpServerNames[] = $env['name']; // Track MCP server names
534 break;
535 }
536 }
537 }
538 }
539 }
540
541 return $body;
542 }
543 else {
544 throw new Exception( 'AI Engine: Unsupported query type.' );
545 }
546 }
547
548 protected function stream_data_handler( $json ) {
549 $content = null;
550 $type = !empty( $json['type'] ) ? $json['type'] : null;
551 if ( is_null( $type ) ) {
552 return $content;
553 }
554
555 if ( $type === 'message_start' ) {
556 $usage = $json['message']['usage'];
557 $this->streamInTokens = $usage['input_tokens'];
558 $this->inModel = $json['message']['model'];
559 $this->inId = $json['message']['id'];
560
561 // Send MCP discovery event if MCP servers are configured
562 if ( $this->currentDebugMode && $this->streamCallback ) {
563 if ( !empty( $this->mcpServerNames ) ) {
564 $serverCount = count( $this->mcpServerNames );
565
566 // Get MCP tools count
567 $mcpTools = apply_filters( 'mwai_mcp_tools', [] );
568 $toolCount = count( $mcpTools );
569
570 $event = Meow_MWAI_Event::mcp_discovery( $serverCount, $toolCount )
571 ->set_metadata( 'servers', $this->mcpServerNames );
572 call_user_func( $this->streamCallback, $event );
573 }
574 }
575 }
576 else if ( $type === 'content_block_start' ) {
577 $this->streamBlocks['content'][] = $json['content_block'];
578
579 // Send "Generating response..." when we start a text block
580 if ( $this->currentDebugMode && $this->streamCallback ) {
581 $block = $json['content_block'];
582 if ( $block['type'] === 'text' && !$this->textStarted ) {
583 $this->textStarted = true;
584 $event = Meow_MWAI_Event::generating_response();
585 call_user_func( $this->streamCallback, $event );
586 }
587 }
588 }
589 else if ( $type === 'content_block_delta' ) {
590 $index = $json['index'];
591 $block = $this->streamBlocks['content'][$index];
592 if ( $json['delta']['type'] === 'text_delta' ) {
593 $block['text'] .= $json['delta']['text'];
594 $isThinkingStart = strpos( $block['text'], '<thinking' ) === 0;
595 $isThinkingEnd = strpos( $block['text'], '</thinking>' ) === 0;
596
597 if ( $isThinkingStart ) {
598 $this->streamIsThinking = true;
599 // Send thinking start event
600 if ( $this->currentDebugMode && $this->streamCallback ) {
601 $event = Meow_MWAI_Event::thinking( 'Thinking...' );
602 call_user_func( $this->streamCallback, $event );
603 }
604 }
605 if ( $isThinkingEnd ) {
606 $this->streamIsThinking = false;
607 // Send thinking end event
608 if ( $this->currentDebugMode && $this->streamCallback ) {
609 $event = Meow_MWAI_Event::thinking( 'Thinking completed.' )
610 ->set_metadata( 'status', 'completed' );
611 call_user_func( $this->streamCallback, $event );
612 }
613 }
614 $content = $json['delta']['text'];
615 }
616 else if ( $json['delta']['type'] === 'input_json_delta' ) {
617 // Somehow, the input is set as an array, but it should be a string since it's JSON.
618 $block['input'] = is_array( $block['input'] ) ? '' : $block['input'];
619 $block['input'] .= $json['delta']['partial_json'];
620
621 // Skip sending tool arguments event - too verbose
622 // if ( $this->currentDebugMode && $this->streamCallback && isset($block['type']) && $block['type'] === 'tool_use' ) {
623 // $event = ( new Meow_MWAI_Event( 'live', MWAI_STREAM_TYPES['TOOL_ARGS'] ) )
624 // ->set_content( 'Streaming tool arguments...' )
625 // ->set_metadata( 'tool_name', $block['name'] ?? 'unknown' )
626 // ->set_metadata( 'partial_args', $json['delta']['partial_json'] );
627 // call_user_func( $this->streamCallback, $event );
628 // }
629 }
630 $this->streamBlocks['content'][$index] = $block;
631 }
632 // At the end of a block, let's look for any 'input' not yet decoded from JSON
633 else if ( $type === 'content_block_stop' ) {
634 $index = $json['index'];
635 $block = $this->streamBlocks['content'][$index];
636 if ( isset( $block['input'] ) && is_string( $block['input'] ) ) {
637 $block['input'] = json_decode( $block['input'], true );
638 }
639
640 // For tool_use blocks, ensure empty inputs are objects, not arrays
641 if ( $block['type'] === 'tool_use' && isset( $block['input'] ) ) {
642 if ( empty( $block['input'] ) || ( is_array( $block['input'] ) && count( $block['input'] ) === 0 ) ) {
643 $block['input'] = new stdClass();
644 }
645 }
646
647 $this->streamBlocks['content'][$index] = $block;
648
649 // Send event for content block completion
650 if ( $this->currentDebugMode && $this->streamCallback ) {
651 if ( $block['type'] === 'mcp_tool_use' ) {
652 // Store the tool name for later lookup when we get the result
653 $this->mcpTools[$block['id']] = $block['name'];
654
655 $event = Meow_MWAI_Event::mcp_calling( $block['name'], $block['id'], $block['input'] ?? [] )
656 ->set_metadata( 'server_name', $block['server_name'] ?? 'unknown' );
657 call_user_func( $this->streamCallback, $event );
658 }
659 else if ( $block['type'] === 'mcp_tool_result' ) {
660 // Look up the tool name from the tool_use_id
661 $tool_use_id = $block['tool_use_id'] ?? '';
662 $tool_name = isset( $this->mcpTools[$tool_use_id] ) ? $this->mcpTools[$tool_use_id] : 'unknown';
663
664 $event = Meow_MWAI_Event::mcp_result( $tool_name, $tool_use_id )
665 ->set_metadata( 'content', $block['content'] ?? '' );
666 call_user_func( $this->streamCallback, $event );
667 }
668 else if ( $block['type'] === 'tool_use' ) {
669 // Regular tool use (non-MCP)
670 $event = Meow_MWAI_Event::function_calling( $block['name'] ?? 'unknown', $block['input'] ?? [] )
671 ->set_metadata( 'tool_id', $block['id'] ?? '' );
672 call_user_func( $this->streamCallback, $event );
673 }
674 else if ( $block['type'] === 'text' ) {
675 // Don't send any event here - the text generation is handled by content deltas
676 // and completion is handled by message_stop
677 }
678 else if ( $block['type'] === 'ping' ) {
679 // https://docs.anthropic.com/en/docs/build-with-claude/streaming#ping-events
680 }
681 else {
682 Meow_MWAI_Logging::log( 'Anthropic: Unknown block type in content_block_stop: ' . $block['type'] );
683 }
684 }
685 }
686 else if ( $type === 'message_delta' ) {
687 $usage = $json['usage'];
688 $this->streamOutTokens = $usage['output_tokens'];
689 }
690 else if ( $type === 'error' ) {
691 $error = $json['error'];
692 $message = $error['message'];
693
694 // Send error event
695 if ( $this->currentDebugMode && $this->streamCallback ) {
696 $event = Meow_MWAI_Event::error( $message )
697 ->set_metadata( 'error_type', $error['type'] ?? 'unknown' );
698 call_user_func( $this->streamCallback, $event );
699 }
700
701 throw new Exception( $message );
702 }
703 else if ( $type === 'message_stop' ) {
704 // Skip sending completion event - too verbose
705 // if ( $this->currentDebugMode && $this->streamCallback ) {
706 // $event = Meow_MWAI_Event::stream_completed()
707 // ->set_metadata( 'total_tokens', ($this->streamInTokens ?? 0) + ($this->streamOutTokens ?? 0) );
708 // call_user_func( $this->streamCallback, $event );
709 // }
710 }
711 else {
712 Meow_MWAI_Logging::log( "Anthropic: Unknown stream data type: $type" );
713 }
714
715 // Avoid some endings
716 $endings = [ '<|im_end|>', '</s>' ];
717 if ( in_array( $content, $endings ) ) {
718 $content = null;
719 }
720
721 // If the stream is thinking, we don't want to return anything yet.
722 if ( $this->streamIsThinking ) {
723 $content = null;
724 }
725
726 return ( $content === '0' || !empty( $content ) ) ? $content : null;
727 }
728
729 // This create the "choices" (even though, often, it is only one choice).
730 // It is basically the reply, but one that is understood by the Meow_MWAI_Reply class.
731 public function create_choices( $data ) {
732 $returned_choices = [];
733 $tool_calls = [];
734 $text_content = '';
735
736 // First, collect all tool calls and text content
737 foreach ( $data['content'] as $content ) {
738 if ( $content['type'] === 'tool_use' ) {
739 // Collect all tool calls
740 $arguments = $content['input'] ?? new stdClass();
741
742 // Ensure arguments is properly formatted
743 if ( empty( $arguments ) ) {
744 $arguments = new stdClass();
745 } else if ( is_array( $arguments ) && count( $arguments ) === 0 ) {
746 $arguments = new stdClass();
747 }
748
749 $tool_calls[] = [
750 'id' => $content['id'],
751 'type' => 'function',
752 'function' => [
753 'name' => $content['name'],
754 'arguments' => $arguments,
755 ]
756 ];
757 }
758 else if ( $content['type'] === 'text' ) {
759 $text_content .= $content['text'];
760 }
761 }
762
763 // Create a single choice with both tool calls and text content (like OpenAI does)
764 $message = [];
765
766 if ( !empty( $text_content ) ) {
767 $message['content'] = $text_content;
768 }
769
770 if ( !empty( $tool_calls ) ) {
771 $message['tool_calls'] = $tool_calls;
772 }
773
774 // Only create a choice if there's content or tool calls
775 if ( !empty( $message ) ) {
776 $returned_choices[] = [
777 'message' => $message
778 ];
779 }
780
781 return $returned_choices;
782 }
783
784 /**
785 * Override reset to include Anthropic-specific state
786 */
787 protected function reset_request_state() {
788 parent::reset_request_state();
789
790 // Reset Anthropic-specific state
791 $this->mcpTools = [];
792 $this->mcpToolCount = 0;
793 // Note: mcpServerNames is configuration, not request state
794 }
795
796 public function run_completion_query( $query, $streamCallback = null ): Meow_MWAI_Reply {
797 // Reset request-specific state to prevent leakage between requests
798 $this->reset_request_state();
799
800 $isStreaming = !is_null( $streamCallback );
801
802 // Initialize debug mode
803 $this->init_debug_mode( $query );
804
805 // IMPORTANT: Prepare query BEFORE setting up streaming hooks
806 // The streaming hook intercepts ALL wp_remote_* calls, so preparation must happen first
807 $this->prepare_query( $query );
808
809 if ( $isStreaming ) {
810 $this->streamCallback = $streamCallback;
811 add_action( 'http_api_curl', [ $this, 'stream_handler' ], 10, 3 );
812 }
813
814 $this->reset_stream();
815 $data = null;
816 $body = $this->build_body( $query, $streamCallback );
817 $url = $this->build_url( $query );
818 $headers = $this->build_headers( $query );
819 $options = $this->build_options( $headers, $body );
820
821 // Emit "Request sent" event for feedback queries
822 if ( $this->currentDebugMode && !empty( $streamCallback ) &&
823 ( $query instanceof Meow_MWAI_Query_Feedback || $query instanceof Meow_MWAI_Query_AssistFeedback ) ) {
824 $event = Meow_MWAI_Event::request_sent()
825 ->set_metadata( 'is_feedback', true )
826 ->set_metadata( 'feedback_count', count( $query->blocks ) );
827 call_user_func( $streamCallback, $event );
828 }
829
830 try {
831 $res = $this->run_query( $url, $options, $streamCallback );
832 $reply = new Meow_MWAI_Reply( $query );
833 $returned_id = null;
834 $returned_model = null;
835 $returned_choices = [];
836
837 // Streaming Mode
838 if ( $isStreaming ) {
839 $returned_id = $this->inId;
840 $returned_model = $this->inModel ? $this->inModel : $query->model;
841 if ( !is_null( $this->streamInTokens && !is_null( $this->streamOutTokens ) ) ) {
842 $returned_in_tokens = $this->streamInTokens;
843 $returned_out_tokens = $this->streamOutTokens;
844 }
845 $data = $this->streamBlocks;
846
847 // Clean up streaming data as well
848 if ( isset( $data['content'] ) && is_array( $data['content'] ) ) {
849 foreach ( $data['content'] as &$content ) {
850 if ( $content['type'] === 'tool_use' && isset( $content['input'] ) ) {
851 if ( empty( $content['input'] ) || ( is_array( $content['input'] ) && count( $content['input'] ) === 0 ) ) {
852 $content['input'] = new stdClass();
853 }
854 }
855 }
856 unset( $content );
857 }
858
859 $returned_choices = $this->create_choices( $this->streamBlocks );
860 }
861 // Standard Mode
862 else {
863 $data = $res['data'];
864
865 // Clean up tool_use inputs in the raw data BEFORE it gets stored
866 if ( isset( $data['content'] ) && is_array( $data['content'] ) ) {
867 foreach ( $data['content'] as &$content ) {
868 if ( $content['type'] === 'tool_use' && isset( $content['input'] ) ) {
869 if ( empty( $content['input'] ) || ( is_array( $content['input'] ) && count( $content['input'] ) === 0 ) ) {
870 $content['input'] = new stdClass();
871 }
872 }
873 }
874 unset( $content );
875 }
876
877 $returned_id = $data['id'];
878 $returned_model = $data['model'];
879 $usage = $data['usage'];
880 if ( !empty( $usage ) ) {
881 $returned_in_tokens = isset( $usage['input_tokens'] ) ? $usage['input_tokens'] : null;
882 $returned_out_tokens = isset( $usage['output_tokens'] ) ? $usage['output_tokens'] : null;
883 }
884 $returned_choices = $this->create_choices( $data );
885 }
886
887
888 $reply->set_choices( $returned_choices, $data );
889 if ( !empty( $returned_id ) ) {
890 $reply->set_id( $returned_id );
891 }
892
893 // Handle tokens.
894 $this->handle_tokens_usage(
895 $reply,
896 $query,
897 $returned_model,
898 $returned_in_tokens,
899 $returned_out_tokens
900 );
901
902 return $reply;
903 }
904 catch ( Exception $e ) {
905 $error = $e->getMessage();
906 $json = json_decode( $error, true );
907 if ( json_last_error() === JSON_ERROR_NONE ) {
908 if ( isset( $json['error'] ) && isset( $json['error']['message'] ) ) {
909 $error = $json['error']['message'];
910 }
911 }
912 Meow_MWAI_Logging::error( '(Anthropic) ' . $error );
913 $service = $this->get_service_name();
914 $message = "From $service: " . $error;
915 throw new Exception( $message );
916 }
917 finally {
918 if ( $isStreaming ) {
919 remove_action( 'http_api_curl', [ $this, 'stream_handler' ] );
920 }
921 }
922 }
923
924 protected function build_options( $headers, $json = null, $forms = null, $method = 'POST' ) {
925 $body = null;
926 if ( !empty( $forms ) ) {
927 $boundary = wp_generate_password( 24, false );
928 $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary;
929 $body = $this->build_form_body( $forms, $boundary );
930 }
931 else if ( !empty( $json ) ) {
932 // For Anthropic, we need to ensure empty objects stay as objects, not arrays
933 // JSON_FORCE_OBJECT would force everything to be an object, which we don't want
934 // Instead, we've already converted empty arrays to stdClass in build_body
935 $body = $this->safe_json_encode( $json, 'request body' );
936
937 // Debug logging to verify JSON encoding
938 if ( $this->core->get_option( 'queries_debug_mode' ) ) {
939 // Check if the body contains tool_use blocks with empty inputs
940 if ( strpos( $body, '"tool_use"' ) !== false ) {
941 error_log( 'AI Engine: Anthropic JSON body after encoding (first 1000 chars): ' . substr( $body, 0, 1000 ) );
942
943 // Check specifically for "input":[] which would be wrong
944 if ( strpos( $body, '"input":[]' ) !== false ) {
945 error_log( 'AI Engine: WARNING - Found "input":[] in JSON body, this should be "input":{} for Anthropic API' );
946 }
947 }
948 }
949 }
950 $options = [
951 'headers' => $headers,
952 'method' => $method,
953 'timeout' => MWAI_TIMEOUT,
954 'body' => $body,
955 'sslverify' => false
956 ];
957 return $options;
958 }
959
960 protected function get_service_name() {
961 return 'Anthropic';
962 }
963
964 public function get_models() {
965 return apply_filters( 'mwai_anthropic_models', MWAI_ANTHROPIC_MODELS );
966 }
967
968 public static function get_models_static() {
969 return MWAI_ANTHROPIC_MODELS;
970 }
971
972 public function handle_tokens_usage(
973 $reply,
974 $query,
975 $returned_model,
976 $returned_in_tokens,
977 $returned_out_tokens,
978 $returned_price = null
979 ) {
980 $returned_in_tokens = !is_null( $returned_in_tokens ) ?
981 $returned_in_tokens : $reply->get_in_tokens( $query );
982 $returned_out_tokens = !is_null( $returned_out_tokens ) ?
983 $returned_out_tokens : $reply->get_out_tokens();
984 if ( !empty( $reply->id ) ) {
985 // Would be cool to retrieve the usage from the API, but it's not possible.
986 }
987 $usage = $this->core->record_tokens_usage( $returned_model, $returned_in_tokens, $returned_out_tokens );
988 $reply->set_usage( $usage );
989
990 // Set accuracy based on data availability
991 if ( !is_null( $returned_in_tokens ) && !is_null( $returned_out_tokens ) ) {
992 // Anthropic provides token counts from API = tokens accuracy
993 $reply->set_usage_accuracy( 'tokens' );
994 } else {
995 // Fallback to estimated
996 $reply->set_usage_accuracy( 'estimated' );
997 }
998 }
999
1000 public function get_price( Meow_MWAI_Query_Base $query, Meow_MWAI_Reply $reply ) {
1001 return parent::get_price( $query, $reply );
1002 }
1003
1004 /**
1005 * Check the connection to Anthropic by listing available models.
1006 * Anthropic doesn't provide a models endpoint, so we just verify authentication works.
1007 */
1008 public function connection_check() {
1009 try {
1010 // Get the endpoint
1011 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
1012
1013 // For Anthropic, we'll use the messages endpoint with a minimal request to verify auth
1014 $url = trailingslashit( $endpoint ) . 'messages';
1015
1016 // Create a minimal query just to test authentication
1017 $testBody = [
1018 'model' => 'claude-3-haiku-20240307', // Use cheapest model
1019 'max_tokens' => 1,
1020 'messages' => [
1021 ['role' => 'user', 'content' => 'Hi']
1022 ],
1023 'metadata' => [
1024 'user_id' => 'connection_test'
1025 ]
1026 ];
1027
1028 // Build headers with a dummy query
1029 $dummyQuery = new Meow_MWAI_Query_Text( 'test' );
1030 $headers = $this->build_headers( $dummyQuery );
1031 $options = $this->build_options( $headers, $testBody );
1032
1033 // Try to make a minimal request
1034 $response = $this->run_query( $url, $options );
1035
1036 // If we get here without exception, the API key is valid
1037 // Get the list of available models from our constants
1038 $models = $this->get_models();
1039 $modelNames = array_map( function( $model ) {
1040 return $model['model'] ?? $model['name'] ?? 'unknown';
1041 }, $models );
1042
1043 return [
1044 'models' => array_slice( $modelNames, 0, 10 ), // Return first 10 models
1045 'service' => 'Anthropic'
1046 ];
1047 }
1048 catch ( Exception $e ) {
1049 // Check if it's an authentication error
1050 $message = $e->getMessage();
1051 if ( strpos( $message, 'authentication_error' ) !== false ||
1052 strpos( $message, 'invalid x-api-key' ) !== false ||
1053 strpos( $message, '401' ) !== false ) {
1054 throw new Exception( 'Invalid API key' );
1055 }
1056 throw new Exception( 'Connection failed: ' . $message );
1057 }
1058 }
1059
1060 /**
1061 * Upload a file to Anthropic Files API
1062 *
1063 * @param string $filename The name of the file
1064 * @param string $data The file content (binary)
1065 * @param string $purpose For Anthropic, this is the MIME type (API difference from OpenAI)
1066 * @return array The response from the API containing file_id
1067 * @throws Exception If upload fails
1068 */
1069 public function upload_file( $filename, $data, $purpose = 'application/pdf' ) {
1070 global $wp_filter;
1071
1072 // For Anthropic, $purpose is actually the MIME type (different from OpenAI's API)
1073 $mimeType = $purpose;
1074
1075 // Build multipart form data
1076 $boundary = wp_generate_password( 24, false );
1077 $body = '';
1078 $body .= "--$boundary\r\n";
1079 $body .= "Content-Disposition: form-data; name=\"file\"; filename=\"{$filename}\"\r\n";
1080 $body .= "Content-Type: " . $mimeType . "\r\n\r\n";
1081 $body .= $data . "\r\n";
1082 $body .= "--$boundary\r\n";
1083 $body .= "Content-Disposition: form-data; name=\"mime_type\"\r\n\r\n";
1084 $body .= $mimeType . "\r\n";
1085 $body .= "--$boundary--\r\n";
1086
1087 // Temporarily remove ALL http_api_curl hooks to prevent streaming hook interference
1088 // Save current hooks
1089 $saved_hooks = null;
1090 if ( isset( $wp_filter['http_api_curl'] ) ) {
1091 $saved_hooks = $wp_filter['http_api_curl'];
1092 unset( $wp_filter['http_api_curl'] );
1093 }
1094
1095 // Upload using WordPress HTTP API
1096 $endpoint = apply_filters( 'mwai_anthropic_endpoint', 'https://api.anthropic.com/v1', $this->env );
1097 $url = $endpoint . '/files';
1098 $response = wp_remote_post( $url, [
1099 'headers' => [
1100 'x-api-key' => $this->apiKey,
1101 'anthropic-version' => '2023-06-01',
1102 'anthropic-beta' => 'files-api-2025-04-14',
1103 'Content-Type' => 'multipart/form-data; boundary=' . $boundary
1104 ],
1105 'body' => $body,
1106 'timeout' => 60
1107 ] );
1108
1109 // Restore hooks
1110 if ( $saved_hooks !== null ) {
1111 $wp_filter['http_api_curl'] = $saved_hooks;
1112 }
1113
1114 if ( is_wp_error( $response ) ) {
1115 throw new Exception( 'File upload failed: ' . $response->get_error_message() );
1116 }
1117
1118 $response_body = wp_remote_retrieve_body( $response );
1119 $result = json_decode( $response_body, true );
1120
1121 // Check for API errors
1122 if ( isset( $result['error'] ) ) {
1123 throw new Exception( 'Anthropic Files API error: ' . ( $result['error']['message'] ?? 'Unknown error' ) );
1124 }
1125
1126 return $result;
1127 }
1128 }
1129