PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.22
MxChat – AI Chatbot & Content Generation for WordPress v3.2.22
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
mxchat-basic / includes / class-mxchat-editor-assistant-rest.php

class-mxchat-editor-assistant-rest.php in MxChat – AI Chatbot & Content Generation for WordPress 3.2.22, at includes/class-mxchat-editor-assistant-rest.php

581 lines 25.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST endpoint that transforms a single block of text using whichever chat
4 * provider the site has configured in mxchat-basic.
5 *
6 * POST /wp-json/mxchat-editor/v1/transform
7 * { action: <slug>, content: <string>, locale?: <iso code> }
8 * → { result: <string>, action: <slug>, model: <string> }
9 *
10 * Auth: standard WP REST nonce (X-WP-Nonce header set by the Gutenberg sidebar
11 * via wp.apiFetch). Caller must have edit_posts capability.
12 *
13 * Provider dispatch: reads mxchat_options['model'] + the matching API key
14 * (openai api_key, gemini_api_key, claude_api_key for Anthropic) and makes a
15 * one-shot chat-completion call. Grok / custom providers are out of scope for v0.1.
16 *
17 * @package MxChatEditorAssistant
18 */
19
20 if (!defined('ABSPATH')) {
21 exit;
22 }
23
24 class MxChat_Editor_Assistant_REST {
25
26 const NAMESPACE_V1 = 'mxchat-editor/v1';
27 const ROUTE_TRANSFORM = '/transform';
28
29 public static function register() {
30 add_action('rest_api_init', array(__CLASS__, 'register_routes'));
31 }
32
33 public static function register_routes() {
34 register_rest_route(
35 self::NAMESPACE_V1,
36 self::ROUTE_TRANSFORM,
37 array(
38 'methods' => 'POST',
39 'callback' => array(__CLASS__, 'handle_transform'),
40 'permission_callback' => array(__CLASS__, 'permission_check'),
41 'args' => array(
42 'action' => array(
43 'required' => true,
44 'type' => 'string',
45 ),
46 'content' => array(
47 'required' => true,
48 'type' => 'string',
49 ),
50 'locale' => array(
51 'required' => false,
52 'type' => 'string',
53 ),
54 ),
55 )
56 );
57 }
58
59 public static function permission_check() {
60 return current_user_can('edit_posts');
61 }
62
63 /**
64 * Action registry. Each action defines a system prompt template; the user
65 * content is appended verbatim. Keep prompts short — block-level surgery,
66 * not essay generation.
67 */
68 public static function get_actions() {
69 return array(
70 'rewrite_concise' => array(
71 'label' => __('Rewrite — concise', 'mxchat'),
72 'description' => __('Shorter, tighter version of the selected block.', 'mxchat'),
73 'system' => 'Rewrite the user\'s text to be more concise. Preserve the meaning, tone, and any inline HTML formatting (links, em, strong). Return ONLY the rewritten text — no preface, no explanation, no quotation marks around the output.',
74 ),
75 'rewrite_expand' => array(
76 'label' => __('Rewrite — expand', 'mxchat'),
77 'description' => __('Longer, more detailed version of the selected block.', 'mxchat'),
78 'system' => 'Rewrite the user\'s text in an expanded form, adding helpful detail and clarification while keeping the same meaning. Preserve inline HTML formatting. Return ONLY the rewritten text.',
79 ),
80 'rewrite_formal' => array(
81 'label' => __('Rewrite — formal', 'mxchat'),
82 'description' => __('More formal, professional register.', 'mxchat'),
83 'system' => 'Rewrite the user\'s text in a more formal, professional tone. Preserve meaning and inline HTML formatting. Return ONLY the rewritten text.',
84 ),
85 'rewrite_casual' => array(
86 'label' => __('Rewrite — casual', 'mxchat'),
87 'description' => __('More casual, conversational tone.', 'mxchat'),
88 'system' => 'Rewrite the user\'s text in a more casual, conversational tone. Preserve meaning and inline HTML formatting. Return ONLY the rewritten text.',
89 ),
90 'summarize' => array(
91 'label' => __('Summarize', 'mxchat'),
92 'description' => __('One-paragraph summary of the selected block.', 'mxchat'),
93 'system' => 'Summarize the user\'s text in a single paragraph. Capture the key point only. Return ONLY the summary text, no preface.',
94 ),
95 'translate' => array(
96 'label' => __('Translate', 'mxchat'),
97 'description' => __('Translate to a target language.', 'mxchat'),
98 'system' => 'Translate the user\'s text into {LOCALE_LABEL}. Preserve meaning and inline HTML formatting. Return ONLY the translated text, no preface, no explanation.',
99 'needs_locale' => true,
100 ),
101 'continue' => array(
102 'label' => __('Continue writing', 'mxchat'),
103 'description' => __('Generate the next sentence(s) that would naturally follow.', 'mxchat'),
104 'system' => 'Continue the user\'s text. Generate 1-3 sentences that naturally follow what they have written, matching tone and style. Return ONLY the continuation — do NOT repeat the user\'s text. Do NOT add a preface like "Here\'s a continuation:".',
105 ),
106 'fix_grammar' => array(
107 'label' => __('Fix grammar & spelling', 'mxchat'),
108 'description' => __('Correct grammar and spelling without changing meaning.', 'mxchat'),
109 'system' => 'Correct grammar and spelling in the user\'s text. Do NOT change meaning, tone, or style. If there is nothing to fix, return the original text unchanged. Return ONLY the corrected text.',
110 ),
111 );
112 }
113
114 /**
115 * Lighter version of the action registry for JS — strips the system prompt
116 * so it doesn't ship to the browser. Order is preserved.
117 */
118 public static function get_actions_for_client() {
119 $out = array();
120 foreach (self::get_actions() as $slug => $cfg) {
121 $out[] = array(
122 'slug' => $slug,
123 'label' => $cfg['label'],
124 'description' => $cfg['description'],
125 'needs_locale' => !empty($cfg['needs_locale']),
126 );
127 }
128 return $out;
129 }
130
131 public static function handle_transform(WP_REST_Request $request) {
132 // Folded into core (plan-8cb0cb) as a FREE feature — no PRO license gate.
133 // The block-editor availability is controlled by the mxchat_editor_assistant_enabled
134 // toggle (off by default); when off, this route is never registered.
135 $slug = sanitize_key($request->get_param('action'));
136 $content = (string) $request->get_param('content');
137 $locale = sanitize_text_field((string) $request->get_param('locale'));
138
139 $actions = self::get_actions();
140 if (!isset($actions[$slug])) {
141 return new WP_Error(
142 'mxchat_editor_assistant_unknown_action',
143 __('Unknown action.', 'mxchat'),
144 array('status' => 400)
145 );
146 }
147
148 $content = trim($content);
149 if ($content === '') {
150 return new WP_Error(
151 'mxchat_editor_assistant_empty',
152 __('Block has no text content to transform.', 'mxchat'),
153 array('status' => 400)
154 );
155 }
156 if (strlen($content) > 16000) {
157 return new WP_Error(
158 'mxchat_editor_assistant_too_long',
159 __('Block is too long for v0.1 (16k char cap). Split it first.', 'mxchat'),
160 array('status' => 413)
161 );
162 }
163
164 $action = $actions[$slug];
165 $system_prompt = $action['system'];
166 if (!empty($action['needs_locale'])) {
167 $locale_label = self::resolve_locale_label($locale);
168 if ($locale_label === '') {
169 return new WP_Error(
170 'mxchat_editor_assistant_locale',
171 __('Target language is required for Translate.', 'mxchat'),
172 array('status' => 400)
173 );
174 }
175 $system_prompt = str_replace('{LOCALE_LABEL}', $locale_label, $system_prompt);
176 }
177
178 $provider_result = self::dispatch_to_provider($system_prompt, $content);
179 if (is_wp_error($provider_result)) {
180 return $provider_result;
181 }
182
183 return rest_ensure_response(array(
184 'result' => $provider_result['text'],
185 'action' => $slug,
186 'model' => $provider_result['model'],
187 ));
188 }
189
190 public static function resolve_locale_label($iso) {
191 $map = array(
192 'en' => 'English',
193 'es' => 'Spanish',
194 'fr' => 'French',
195 'de' => 'German',
196 'ja' => 'Japanese',
197 'pt' => 'Portuguese',
198 );
199 return isset($map[$iso]) ? $map[$iso] : '';
200 }
201
202 /**
203 * Provider dispatch — one-shot chat completion against whichever model is
204 * configured in mxchat_options. Mirrors the providers mxchat-basic ships:
205 * OpenAI (gpt-*), Anthropic (claude-*), Google (gemini-*).
206 *
207 * Grok and any custom provider degrade with a clear error in v0.1.
208 */
209 private static function dispatch_to_provider($system_prompt, $user_content) {
210 $opts = get_option('mxchat_options', array());
211 $model = isset($opts['model']) ? (string) $opts['model'] : '';
212 if ($model === '') {
213 return new WP_Error(
214 'mxchat_editor_assistant_no_model',
215 __('No AI model is configured in MxChat Settings. Set a model first.', 'mxchat'),
216 array('status' => 503)
217 );
218 }
219
220 $provider = self::detect_provider($model);
221 switch ($provider) {
222 case 'openai':
223 return self::call_openai($opts, $model, $system_prompt, $user_content);
224 case 'anthropic':
225 return self::call_anthropic($opts, $model, $system_prompt, $user_content);
226 case 'google':
227 return self::call_gemini($opts, $model, $system_prompt, $user_content);
228 case 'grok':
229 return self::call_grok($opts, $model, $system_prompt, $user_content);
230 case 'custom':
231 return self::call_custom($opts, $system_prompt, $user_content);
232 default:
233 return new WP_Error(
234 'mxchat_editor_assistant_provider_unsupported',
235 sprintf(
236 /* translators: %s: model name */
237 __('The Editor Assistant supports OpenAI, Anthropic, Google, Grok (xAI), and custom OpenAI-compatible providers. Configured model (%s) is not yet supported — set the site to one of those in MxChat → Settings.', 'mxchat'),
238 esc_html($model)
239 ),
240 array('status' => 501)
241 );
242 }
243 }
244
245 /**
246 * Map the configured model id to a provider family. Mirrors mxchat-basic's
247 * own routing: prefix-based, with 'custom-provider' as the sentinel model
248 * for the OpenAI-compatible custom/Azure path.
249 */
250 public static function detect_provider($model) {
251 $m = strtolower($model);
252 if ($m === 'custom-provider') {
253 return 'custom';
254 }
255 if (strpos($m, 'gpt-') === 0 || strpos($m, 'o1') === 0 || strpos($m, 'o3') === 0 || strpos($m, 'o4') === 0) {
256 return 'openai';
257 }
258 if (strpos($m, 'claude') !== false) {
259 return 'anthropic';
260 }
261 if (strpos($m, 'gemini') !== false) {
262 return 'google';
263 }
264 if (strpos($m, 'grok') === 0) {
265 return 'grok';
266 }
267 return 'unsupported';
268 }
269
270 private static function call_openai($opts, $model, $system_prompt, $user_content) {
271 $api_key = isset($opts['api_key']) ? trim((string) $opts['api_key']) : '';
272 if ($api_key === '') {
273 return new WP_Error(
274 'mxchat_editor_assistant_no_key',
275 __('OpenAI API key is missing in MxChat settings.', 'mxchat'),
276 array('status' => 503)
277 );
278 }
279 $body = array(
280 'model' => $model,
281 'messages' => array(
282 array('role' => 'system', 'content' => $system_prompt),
283 array('role' => 'user', 'content' => $user_content),
284 ),
285 'temperature' => 0.4,
286 );
287
288 // GPT-5 reasoning models reject non-default temperature (API 400: only the
289 // default 1 is supported) and route reasoning via reasoning_effort. Mirror the
290 // streaming sibling (class-mxchat-editor-assistant-stream.php stream_openai_compatible)
291 // and mxchat-basic's integrator EXACTLY so the non-streaming path (multi-block +
292 // any non-stream fallback) is GPT-5-safe. Without this the multi-block path 400s on
293 // every GPT-5 model — which is most installs (plan-8cb0cb fold consistency fix).
294 // Only triggers for gpt-5* ids; Grok / custom OpenAI-compatible keep the 0.4 above.
295 if (strpos($model, 'gpt-5') === 0) {
296 $body['temperature'] = 1;
297 $no_reasoning_models = array('gpt-5.2', 'gpt-5.1-chat-latest', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
298 if (!in_array($model, $no_reasoning_models, true)) {
299 if ($model === 'gpt-5.1-2025-11-13') {
300 $body['reasoning_effort'] = 'low';
301 } elseif ($model === 'gpt-5.5' || $model === 'gpt-5.4') {
302 $body['reasoning_effort'] = 'none';
303 } elseif (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) {
304 // plan-6fb107: gpt-5.6 rejects 'minimal' (live 400); core uses 'low'.
305 $body['reasoning_effort'] = 'low';
306 } else {
307 $body['reasoning_effort'] = 'minimal';
308 }
309 }
310 }
311
312 $response = wp_remote_post('https://api.openai.com/v1/chat/completions', array(
313 'timeout' => 45,
314 'headers' => array(
315 'Authorization' => 'Bearer ' . $api_key,
316 'Content-Type' => 'application/json',
317 ),
318 'body' => wp_json_encode($body),
319 ));
320 if (is_wp_error($response)) {
321 return $response;
322 }
323 $code = wp_remote_retrieve_response_code($response);
324 $data = json_decode(wp_remote_retrieve_body($response), true);
325 if ($code !== 200 || !is_array($data) || empty($data['choices'][0]['message']['content'])) {
326 $msg = isset($data['error']['message']) ? $data['error']['message'] : 'OpenAI request failed';
327 return new WP_Error('mxchat_editor_assistant_openai', $msg, array('status' => 502));
328 }
329 return array(
330 'text' => trim((string) $data['choices'][0]['message']['content']),
331 'model' => $model,
332 );
333 }
334
335 private static function call_anthropic($opts, $model, $system_prompt, $user_content) {
336 // mxchat-basic stores the Claude/Anthropic key under 'claude_api_key' (the
337 // canonical core option). Read that first; fall back to 'anthropic_api_key'
338 // for forward-compat if a future field ever uses that name.
339 $api_key = trim((string) ($opts['claude_api_key'] ?? $opts['anthropic_api_key'] ?? ''));
340 if ($api_key === '') {
341 return new WP_Error(
342 'mxchat_editor_assistant_no_key',
343 __('Anthropic API key is missing in MxChat settings.', 'mxchat'),
344 array('status' => 503)
345 );
346 }
347 $body = array(
348 'model' => $model,
349 'max_tokens' => 1024,
350 // Prompt-cache breakpoint (plan 1ff43b): the editor-assistant
351 // instruction is a fixed template reused across requests.
352 'system' => trim((string) $system_prompt) === '' ? $system_prompt : array(
353 array('type' => 'text', 'text' => $system_prompt, 'cache_control' => array('type' => 'ephemeral')),
354 ),
355 'messages' => array(
356 array('role' => 'user', 'content' => $user_content),
357 ),
358 );
359 $response = wp_remote_post('https://api.anthropic.com/v1/messages', array(
360 'timeout' => 45,
361 'headers' => array(
362 'x-api-key' => $api_key,
363 'anthropic-version' => '2023-06-01',
364 'Content-Type' => 'application/json',
365 ),
366 'body' => wp_json_encode($body),
367 ));
368 if (is_wp_error($response)) {
369 return $response;
370 }
371 $code = wp_remote_retrieve_response_code($response);
372 $data = json_decode(wp_remote_retrieve_body($response), true);
373 if ($code !== 200 || !is_array($data)) {
374 $msg = isset($data['error']['message']) ? $data['error']['message'] : 'Anthropic request failed';
375 return new WP_Error('mxchat_editor_assistant_anthropic', $msg, array('status' => 502));
376 }
377 // claude-fable-5 prepends a `thinking` block, so content[0] is not the
378 // answer — take the first TEXT block.
379 $text = '';
380 foreach ((array) ($data['content'] ?? array()) as $block) {
381 if (isset($block['type'], $block['text']) && $block['type'] === 'text' && trim($block['text']) !== '') {
382 $text = trim($block['text']);
383 break;
384 }
385 }
386 if ($text === '') {
387 $msg = isset($data['error']['message']) ? $data['error']['message'] : 'Anthropic request failed';
388 return new WP_Error('mxchat_editor_assistant_anthropic', $msg, array('status' => 502));
389 }
390 return array(
391 'text' => $text,
392 'model' => $model,
393 );
394 }
395
396 private static function call_gemini($opts, $model, $system_prompt, $user_content) {
397 $api_key = isset($opts['gemini_api_key']) ? trim((string) $opts['gemini_api_key']) : '';
398 if ($api_key === '') {
399 return new WP_Error(
400 'mxchat_editor_assistant_no_key',
401 __('Google Gemini API key is missing in MxChat settings.', 'mxchat'),
402 array('status' => 503)
403 );
404 }
405 // Gemini's REST shape — system prompt rides as a separate system_instruction.
406 $url = sprintf(
407 'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s',
408 rawurlencode($model),
409 rawurlencode($api_key)
410 );
411 $body = array(
412 'system_instruction' => array(
413 'parts' => array(array('text' => $system_prompt)),
414 ),
415 'contents' => array(
416 array(
417 'role' => 'user',
418 'parts' => array(array('text' => $user_content)),
419 ),
420 ),
421 'generationConfig' => array(
422 'temperature' => 0.4,
423 ),
424 );
425 $response = wp_remote_post($url, array(
426 'timeout' => 45,
427 'headers' => array('Content-Type' => 'application/json'),
428 'body' => wp_json_encode($body),
429 ));
430 if (is_wp_error($response)) {
431 return $response;
432 }
433 $code = wp_remote_retrieve_response_code($response);
434 $data = json_decode(wp_remote_retrieve_body($response), true);
435 if ($code !== 200 || !is_array($data)) {
436 $msg = isset($data['error']['message']) ? $data['error']['message'] : 'Gemini request failed';
437 return new WP_Error('mxchat_editor_assistant_gemini', $msg, array('status' => 502));
438 }
439 $parts = isset($data['candidates'][0]['content']['parts']) ? $data['candidates'][0]['content']['parts'] : array();
440 $text = '';
441 foreach ($parts as $part) {
442 if (isset($part['text'])) {
443 $text .= $part['text'];
444 }
445 }
446 if ($text === '') {
447 return new WP_Error('mxchat_editor_assistant_gemini', __('Gemini returned no text content.', 'mxchat'), array('status' => 502));
448 }
449 return array(
450 'text' => trim($text),
451 'model' => $model,
452 );
453 }
454
455 /**
456 * Grok (xAI) — OpenAI-compatible Chat Completions at api.x.ai. Reads the same
457 * 'xai_api_key' option mxchat-basic uses for its Grok provider.
458 */
459 private static function call_grok($opts, $model, $system_prompt, $user_content) {
460 $api_key = isset($opts['xai_api_key']) ? trim((string) $opts['xai_api_key']) : '';
461 if ($api_key === '') {
462 return new WP_Error(
463 'mxchat_editor_assistant_no_key',
464 __('Grok (xAI) API key is missing in MxChat settings.', 'mxchat'),
465 array('status' => 503)
466 );
467 }
468 $body = array(
469 'model' => $model,
470 'messages' => array(
471 array('role' => 'system', 'content' => $system_prompt),
472 array('role' => 'user', 'content' => $user_content),
473 ),
474 'temperature' => 0.4,
475 );
476 $response = wp_remote_post('https://api.x.ai/v1/chat/completions', array(
477 'timeout' => 45,
478 'headers' => array(
479 'Authorization' => 'Bearer ' . $api_key,
480 'Content-Type' => 'application/json',
481 ),
482 'body' => wp_json_encode($body),
483 ));
484 if (is_wp_error($response)) {
485 return $response;
486 }
487 $code = wp_remote_retrieve_response_code($response);
488 $data = json_decode(wp_remote_retrieve_body($response), true);
489 if ($code !== 200 || !is_array($data) || empty($data['choices'][0]['message']['content'])) {
490 $msg = isset($data['error']['message']) ? $data['error']['message'] : 'Grok request failed';
491 return new WP_Error('mxchat_editor_assistant_grok', $msg, array('status' => 502));
492 }
493 return array(
494 'text' => trim((string) $data['choices'][0]['message']['content']),
495 'model' => $model,
496 );
497 }
498
499 /**
500 * Custom OpenAI-compatible provider (Ollama, LM Studio, vLLM, llama.cpp,
501 * Azure OpenAI, …). Resolves the same custom_provider_* config keys + auth
502 * scheme + Azure api-version query string that mxchat-basic's chat dispatcher
503 * uses, so a site configured for Azure chat works here with zero extra setup.
504 */
505 private static function call_custom($opts, $system_prompt, $user_content) {
506 $cfg = self::resolve_custom_provider($opts);
507 if ($cfg['base_url'] === '') {
508 return new WP_Error(
509 'mxchat_editor_assistant_no_custom_base',
510 __('Custom provider Base URL is not configured in MxChat settings.', 'mxchat'),
511 array('status' => 503)
512 );
513 }
514 $body = array(
515 'model' => $cfg['model'],
516 'messages' => array(
517 array('role' => 'system', 'content' => $system_prompt),
518 array('role' => 'user', 'content' => $user_content),
519 ),
520 'temperature' => 0.4,
521 );
522 $response = wp_remote_post($cfg['chat_url'], array(
523 'timeout' => 45,
524 'headers' => $cfg['headers'],
525 'body' => wp_json_encode($body),
526 ));
527 if (is_wp_error($response)) {
528 return $response;
529 }
530 $code = wp_remote_retrieve_response_code($response);
531 $data = json_decode(wp_remote_retrieve_body($response), true);
532 if ($code !== 200 || !is_array($data) || empty($data['choices'][0]['message']['content'])) {
533 $msg = isset($data['error']['message']) ? $data['error']['message'] : 'Custom provider request failed';
534 return new WP_Error('mxchat_editor_assistant_custom', $msg, array('status' => 502));
535 }
536 return array(
537 'text' => trim((string) $data['choices'][0]['message']['content']),
538 'model' => $cfg['model'],
539 );
540 }
541
542 /**
543 * Resolve the custom-provider config from mxchat_options. Returns the same
544 * shape mxchat-basic's mxchat_resolve_custom_provider() returns, but with
545 * an associative-array header map (wp_remote_post / curl friendly).
546 *
547 * @return array{base_url:string,api_key:string,model:string,auth_scheme:string,api_version:string,chat_url:string,headers:array}
548 */
549 public static function resolve_custom_provider($opts) {
550 $base_url = isset($opts['custom_provider_base_url']) ? rtrim(trim((string) $opts['custom_provider_base_url']), '/') : '';
551 $api_key = isset($opts['custom_provider_api_key']) ? trim((string) $opts['custom_provider_api_key']) : '';
552 $model = isset($opts['custom_provider_model']) ? trim((string) $opts['custom_provider_model']) : '';
553 $auth_scheme = isset($opts['custom_provider_auth_scheme']) ? (string) $opts['custom_provider_auth_scheme'] : 'bearer';
554 $api_version = isset($opts['custom_provider_api_version']) ? trim((string) $opts['custom_provider_api_version']) : '';
555
556 $chat_url = $base_url === '' ? '' : $base_url . '/chat/completions';
557 if ($chat_url !== '' && $api_version !== '') {
558 $chat_url .= (strpos($chat_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($api_version);
559 }
560
561 $headers = array('Content-Type' => 'application/json');
562 if ($api_key !== '') {
563 if ($auth_scheme === 'api-key') {
564 $headers['api-key'] = $api_key;
565 } else {
566 $headers['Authorization'] = 'Bearer ' . $api_key;
567 }
568 }
569
570 return array(
571 'base_url' => $base_url,
572 'api_key' => $api_key,
573 'model' => $model !== '' ? $model : 'default',
574 'auth_scheme' => $auth_scheme,
575 'api_version' => $api_version,
576 'chat_url' => $chat_url,
577 'headers' => $headers,
578 );
579 }
580 }
581