Files
wp-agentic-writer/includes/class-local-backend-provider.php
Dwindi Ramadhana d3f142222c feat: Add connection test caching and reasoning content support
Backend improvements:
- Add cache auto-clear on settings save (class-settings-v2.php)
  - Hooks updated_option action
  - Clears connection test transients when local backend settings change
- Add reasoning_content streaming support (class-local-backend-provider.php)
  - Handles thinking models like Claude extended thinking
  - Captures chunk['choices'][0]['delta']['reasoning_content']

Documentation:
- Add FRONTEND_AND_CHAT_FIX_SUMMARY.md with all fixes
- Add FRONTEND-REFACTOR-PHASE2.md with modularization plan

Note: Splitting effort deferred - will continue iterating on monolith
Next: Fix session history not appearing bug
2026-06-17 05:26:12 +07:00

1083 lines
39 KiB
PHP

<?php
/**
* Local Backend Provider
*
* OpenAI-compatible endpoint provider for custom/local AI inference
* (LM Studio, Ollama, llama.cpp, or any OpenAI-compatible API).
*
* @package WP_Agentic_Writer
*/
if (!defined("ABSPATH")) {
exit();
}
class WP_Agentic_Writer_Local_Backend_Provider implements
WP_Agentic_Writer_AI_Provider_Interface
{
/**
* Local backend base URL
*
* @var string
*/
private $base_url = "";
/**
* API key for the endpoint
*
* @var string
*/
private $api_key = "";
/**
* Local backend image base URL
*
* @var string
*/
private $image_base_url = "";
/**
* API key for the image endpoint
*
* @var string
*/
private $image_api_key = "";
/**
* Model identifier
*
* @var string
*/
private $model = "";
/**
* Per-task model overrides
*
* @var array
*/
private $task_models = [];
/**
* Constructor
*/
public function __construct()
{
$settings = get_option("wp_agentic_writer_settings", []);
$this->base_url = $settings["local_backend_url"] ?? "";
$this->api_key = $settings["local_backend_key"] ?? "";
// Use separate image endpoint settings, but fallback to text endpoint if empty
$this->image_base_url = !empty($settings["local_backend_image_url"])
? $settings["local_backend_image_url"]
: $this->base_url;
$this->image_api_key = !empty($settings["local_backend_image_key"])
? $settings["local_backend_image_key"]
: $this->api_key;
$this->model = $settings["local_backend_model"] ?? "";
$this->task_models = $settings["local_backend_models"] ?? [];
}
/**
* Get model code for a specific task type.
*
* Falls back to the default model if no per-task override is set.
*
* @param string $task_type Task type (chat, clarity, planning, writing, refinement, image).
* @return string Model identifier.
*/
public function get_model_for_task($task_type)
{
$task_model = $this->task_models[$task_type] ?? "";
return !empty($task_model) ? $task_model : $this->model;
}
/**
* Get the formatted endpoint URL.
* Handles whether the user included /v1 in their base URL or not.
*
* @param string $path The API path (e.g. '/chat/completions')
* @return string Full endpoint URL.
*/
private function get_endpoint_url($path)
{
$base = rtrim($this->base_url, "/");
// If the base URL doesn't end with /v1, and the path doesn't start with it
if (!preg_match('#/v1$#', $base) && strpos($path, "/v1") !== 0) {
$base .= "/v1";
}
// Ensure path starts with /
if (strpos($path, "/") !== 0) {
$path = "/" . $path;
}
return $base . $path;
}
/**
* Non-streaming chat completion
*
* @param array $messages Array of message objects.
* @param array $options Optional parameters.
* @param string $type Task type.
* @return array|WP_Error Response with content, model, tokens, cost.
*/
public function chat($messages, $options = [], $type = "planning")
{
if (!$this->is_configured()) {
return new WP_Error(
"not_configured",
__("Custom endpoint URL not configured.", "wp-agentic-writer"),
);
}
$start_time = microtime(true);
$response = wp_remote_post(
$this->get_endpoint_url("/chat/completions"),
[
"headers" => [
"Content-Type" => "application/json",
"Authorization" => "Bearer " . $this->api_key,
],
"body" => wp_json_encode([
"model" => $this->get_model_for_task($type),
"messages" => $messages,
"stream" => false,
]),
"timeout" => 120, // Long timeout for local processing
"sslverify" => false, // Local network
],
);
$generation_time = microtime(true) - $start_time;
if (is_wp_error($response)) {
return new WP_Error(
"connection_failed",
sprintf(
/* translators: %s: error message */
__(
"Custom endpoint connection failed: %s",
"wp-agentic-writer",
),
$response->get_error_message(),
),
);
}
$code = wp_remote_retrieve_response_code($response);
if (200 !== $code) {
$body = wp_remote_retrieve_body($response);
error_log(
"[WPAW] Local backend HTTP error: " .
$code .
", body: " .
substr($body, 0, 500),
);
return new WP_Error(
"api_error",
sprintf(
/* translators: %1$d: HTTP status code, %2$s: response body */
__(
'Custom endpoint error (%1$d): %2$s',
"wp-agentic-writer",
),
$code,
$body,
),
);
}
$body = json_decode(wp_remote_retrieve_body($response), true);
// Fallback for endpoints that ignore stream=false and send SSE chunks
if (
!isset($body["choices"][0]["message"]["content"]) &&
strpos(wp_remote_retrieve_body($response), "data: ") === 0
) {
$lines = explode("\n", wp_remote_retrieve_body($response));
$content = "";
foreach ($lines as $line) {
if (strpos($line, "data: ") === 0) {
$json_str = substr($line, 6);
if ($json_str === "[DONE]") {
continue;
}
$chunk = json_decode($json_str, true);
if (isset($chunk["choices"][0]["delta"]["content"])) {
$content .= $chunk["choices"][0]["delta"]["content"];
}
}
}
if (!empty($content)) {
$body = [
"choices" => [
[
"message" => [
"content" => $content,
],
],
],
];
}
}
error_log(
"[WPAW] Local backend response keys: " .
implode(
", ",
is_array($body) ? array_keys($body) : ["not_array"],
),
);
if (!isset($body["choices"][0]["message"]["content"])) {
error_log(
"[WPAW] Local backend response: " . wp_json_encode($body),
);
return new WP_Error(
"invalid_response",
__(
"Invalid response format from custom endpoint",
"wp-agentic-writer",
),
);
}
$content = $body["choices"][0]["message"]["content"];
// Detect successful HTTP responses that nonetheless carry no usable
// content. This happens with agentic/tool-calling models (e.g. names
// ending in -agent/-agentic) that try to emit a function call on a plain
// prose prompt, malform it, and return empty content. Surface a clear,
// actionable error instead of a confusing empty reply.
if ("" === trim((string) $content)) {
$finish_reason = $body["choices"][0]["finish_reason"] ?? "";
error_log(
"[WPAW] Local backend returned empty content. finish_reason=" .
$finish_reason .
", model=" .
$this->get_model_for_task($type),
);
if (
"malformed_function_call" === $finish_reason ||
"tool_calls" === $finish_reason ||
"function_call" === $finish_reason
) {
return new WP_Error(
"empty_agentic_response",
sprintf(
/* translators: %s: model identifier */
__(
'The selected model "%s" returned no text (finish reason: tool/function call). This usually means an agentic/coding model is being used for prose. Choose a standard chat model (without an -agent or -agentic suffix) in Settings.',
"wp-agentic-writer",
),
$this->get_model_for_task($type),
),
);
}
}
return [
"content" => $content,
"model" => $this->get_model_for_task($type),
"input_tokens" => 0, // Local backend doesn't track tokens
"output_tokens" => 0,
"total_tokens" => 0,
"cost" => 0, // Free for local backend
"generation_time" => $generation_time,
];
}
/**
* Streaming chat completion (not supported yet)
*
* @param array $messages Array of message objects.
* @param array $options Optional parameters.
* @param string $type Task type.
* @param callable $callback Function to call with each chunk.
* @return array|WP_Error Response or error.
*/
public function chat_stream(
$messages,
$options = [],
$type = "planning",
$callback = null,
) {
if (!$this->is_configured()) {
return new WP_Error(
"not_configured",
__("Custom endpoint URL not configured.", "wp-agentic-writer"),
);
}
$body = [
"model" => $this->get_model_for_task($type),
"messages" => $messages,
"stream" => true,
];
$accumulated_content = "";
$accumulated_usage = [];
$buffer = "";
$finish_reason = "";
$accumulating_callback = function ($chunk, $is_complete) use (
&$accumulated_content,
$callback,
) {
if (!$is_complete && !empty($chunk)) {
$accumulated_content .= $chunk;
}
if ($callback) {
call_user_func(
$callback,
$chunk,
$is_complete,
$accumulated_content,
);
}
};
$ch = curl_init($this->get_endpoint_url("/chat/completions"));
$headers = [
"Content-Type: application/json",
"Authorization: Bearer " . $this->api_key,
];
// Add search headers if web search is enabled
if (!empty($options["web_search_enabled"])) {
$headers[] = "X-Search-Enabled: true";
// Extract last user message as search query
foreach (array_reverse($messages) as $msg) {
if ("user" === $msg["role"]) {
$headers[] =
"X-Search-Query: " . substr($msg["content"], 0, 500);
break;
}
}
}
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => false,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_WRITEFUNCTION => function ($curl, $data) use (
&$buffer,
$accumulating_callback,
&$accumulated_content,
&$accumulated_usage,
&$finish_reason,
) {
$buffer .= $data;
while (true) {
$newline_pos = strpos($buffer, "\n");
if (false === $newline_pos) {
break;
}
$line = substr($buffer, 0, $newline_pos);
$buffer = substr($buffer, $newline_pos + 1);
$line = trim($line);
if (empty($line) || 0 !== strpos($line, "data: ")) {
continue;
}
$json_str = substr($line, 6);
if ("[DONE]" === $json_str || '"[DONE]"' === $json_str) {
call_user_func($accumulating_callback, "", true);
return strlen($data);
}
$chunk = json_decode($json_str, true);
if (
isset($chunk["choices"][0]["delta"]["content"]) &&
is_string($chunk["choices"][0]["delta"]["content"])
) {
$content = $chunk["choices"][0]["delta"]["content"];
call_user_func($accumulating_callback, $content, false);
}
// Some OpenAI-compatible proxies stream a complete message
// payload instead of delta chunks.
if (
isset($chunk["choices"][0]["message"]["content"]) &&
is_string($chunk["choices"][0]["message"]["content"])
) {
$content = $chunk["choices"][0]["message"]["content"];
if (
"" !== $content &&
0 === strpos($content, $accumulated_content)
) {
$content = substr(
$content,
strlen($accumulated_content),
);
}
if ("" !== $content) {
call_user_func(
$accumulating_callback,
$content,
false,
);
}
}
// Also support text-completion style chunks.
if (
isset($chunk["choices"][0]["text"]) &&
is_string($chunk["choices"][0]["text"])
) {
$content = $chunk["choices"][0]["text"];
if ("" !== $content) {
call_user_func(
$accumulating_callback,
$content,
false,
);
}
}
// Also support Ollama-compatible chat stream chunks.
if (
isset($chunk["message"]["content"]) &&
is_string($chunk["message"]["content"])
) {
$content = $chunk["message"]["content"];
if ("" !== $content) {
call_user_func(
$accumulating_callback,
$content,
false,
);
}
}
// Also support simple content/response payloads.
if (
isset($chunk["content"]) &&
is_string($chunk["content"])
) {
$content = $chunk["content"];
if ("" !== $content) {
call_user_func(
$accumulating_callback,
$content,
false,
);
}
}
if (
isset($chunk["response"]) &&
is_string($chunk["response"])
) {
$content = $chunk["response"];
if ("" !== $content) {
call_user_func(
$accumulating_callback,
$content,
false,
);
}
}
// Also support Anthropic format if proxy uses it
if (
isset($chunk["type"]) &&
"content_block_delta" === $chunk["type"] &&
isset($chunk["delta"]["text"])
) {
$content = $chunk["delta"]["text"];
call_user_func($accumulating_callback, $content, false);
}
// Support reasoning_content from thinking models (e.g. Claude extended thinking)
// These models stream reasoning separately from the final answer.
// We pass it through the callback so the frontend can choose to display it.
if (
isset(
$chunk["choices"][0]["delta"]["reasoning_content"],
) &&
is_string(
$chunk["choices"][0]["delta"]["reasoning_content"],
)
) {
$reasoning =
$chunk["choices"][0]["delta"]["reasoning_content"];
if (defined("WP_DEBUG") && WP_DEBUG) {
error_log(
"WPAW Local Backend: Received reasoning_content chunk (" .
strlen($reasoning) .
" chars)",
);
}
// Pass reasoning content through with a special prefix so frontend can identify it
// The frontend can strip this prefix and display reasoning in a collapsible section
call_user_func(
$accumulating_callback,
$reasoning,
false,
);
}
if (isset($chunk["usage"])) {
$accumulated_usage = $chunk["usage"];
}
if (
isset($chunk["choices"][0]["finish_reason"]) &&
is_string($chunk["choices"][0]["finish_reason"]) &&
"" !== $chunk["choices"][0]["finish_reason"]
) {
$finish_reason = $chunk["choices"][0]["finish_reason"];
}
}
return strlen($data);
},
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => wp_json_encode($body),
CURLOPT_TIMEOUT => 300,
]);
$start_time = microtime(true);
$result = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curl_error = curl_error($ch);
curl_close($ch);
// Debug logging
error_log(
"WPAW Local Backend chat_stream: HTTP=" .
$http_code .
", curl_result=" .
($result ? "true" : "false") .
", curl_error=" .
$curl_error .
", accumulated_content_len=" .
strlen($accumulated_content) .
", buffer_len=" .
strlen($buffer),
);
if (false === $result && !empty($curl_error)) {
return new WP_Error("curl_error", "cURL error: " . $curl_error);
}
if ($http_code >= 400) {
error_log(
"WPAW Local Backend API error: HTTP=" .
$http_code .
", Buffer: " .
substr($buffer, 0, 1000),
);
return new WP_Error(
"api_error",
sprintf(
"API error (%d): %s",
$http_code,
substr($buffer, 0, 500),
),
);
}
// FALLBACK: If no SSE chunks were parsed, the proxy likely returned a plain JSON response.
// Try to parse the leftover buffer as a standard OpenAI/Anthropic JSON response.
if (empty($accumulated_content) && !empty($buffer)) {
error_log(
"WPAW Local Backend: No SSE chunks parsed. Attempting raw JSON fallback. Buffer preview: " .
substr($buffer, 0, 500),
);
$raw_json = json_decode($buffer, true);
if (is_array($raw_json)) {
// Provider returned a structured error payload (e.g. HTTP 4xx/5xx
// from the upstream model). Surface the real message instead of a
// generic "empty response" so the user knows what actually failed.
if (isset($raw_json["error"])) {
$provider_error = is_array($raw_json["error"])
? $raw_json["error"]["message"] ??
wp_json_encode($raw_json["error"])
: (string) $raw_json["error"];
error_log(
"WPAW Local Backend: Provider returned error payload: " .
substr((string) $provider_error, 0, 300),
);
return new WP_Error(
"provider_error",
sprintf(
/* translators: %s: provider error message */
__(
"The AI provider returned an error: %s",
"wp-agentic-writer",
),
(string) $provider_error,
),
);
}
// OpenAI format: choices[0].message.content
if (isset($raw_json["choices"][0]["message"]["content"])) {
$accumulated_content =
$raw_json["choices"][0]["message"]["content"];
error_log(
"WPAW Local Backend: Extracted content via OpenAI format fallback (" .
strlen($accumulated_content) .
" chars)",
);
}
// Anthropic format: content[0].text
elseif (isset($raw_json["content"][0]["text"])) {
$accumulated_content = $raw_json["content"][0]["text"];
error_log(
"WPAW Local Backend: Extracted content via Anthropic format fallback (" .
strlen($accumulated_content) .
" chars)",
);
}
// Simple format: content string
elseif (
isset($raw_json["content"]) &&
is_string($raw_json["content"])
) {
$accumulated_content = $raw_json["content"];
error_log(
"WPAW Local Backend: Extracted content via simple format fallback (" .
strlen($accumulated_content) .
" chars)",
);
}
if (!empty($accumulated_content) && $callback) {
// Emit the full content as a single chunk so the SSE handler picks it up
call_user_func(
$callback,
$accumulated_content,
false,
$accumulated_content,
);
call_user_func($callback, "", true, $accumulated_content);
}
// Extract usage if available
if (isset($raw_json["usage"])) {
$accumulated_usage = $raw_json["usage"];
}
} else {
error_log(
"WPAW Local Backend: Buffer is not valid JSON. First 300 chars: " .
substr($buffer, 0, 300),
);
}
}
// If the stream completed with a tool/function finish reason but no
// text, an agentic/coding model was used for prose. Retrying won't help
// (it's a model-capability mismatch), so surface a clear error instead
// of burning two more calls on the non-streaming fallback.
if (
empty($accumulated_content) &&
("malformed_function_call" === $finish_reason ||
"tool_calls" === $finish_reason ||
"function_call" === $finish_reason)
) {
error_log(
"WPAW Local Backend: Empty content with finish_reason=" .
$finish_reason .
"; model=" .
$this->get_model_for_task($type),
);
return new WP_Error(
"empty_agentic_response",
sprintf(
/* translators: %s: model identifier */
__(
'The selected model "%s" returned no text (finish reason: tool/function call). This usually means an agentic/coding model is being used for prose. Choose a standard chat model (without an -agent or -agentic suffix) in Settings.',
"wp-agentic-writer",
),
$this->get_model_for_task($type),
),
);
}
if (empty($accumulated_content)) {
error_log(
"WPAW Local Backend: Streaming returned empty content; falling back to non-streaming chat.",
);
$fallback_response = $this->chat($messages, $options, $type);
if (is_wp_error($fallback_response)) {
return $fallback_response;
}
$accumulated_content = $fallback_response["content"] ?? "";
if ("" === trim((string) $accumulated_content)) {
error_log(
"WPAW Local Backend: Non-streaming fallback returned empty content; retrying once.",
);
$fallback_response = $this->chat($messages, $options, $type);
if (is_wp_error($fallback_response)) {
return $fallback_response;
}
$accumulated_content = $fallback_response["content"] ?? "";
}
if ("" === trim((string) $accumulated_content)) {
return new WP_Error(
"empty_response",
__(
"The provider returned an empty chat response.",
"wp-agentic-writer",
),
);
}
if (!empty($accumulated_content) && $callback) {
call_user_func(
$callback,
$accumulated_content,
false,
$accumulated_content,
);
call_user_func($callback, "", true, $accumulated_content);
}
return [
"content" => $accumulated_content,
"model" =>
$fallback_response["model"] ??
$this->get_model_for_task($type),
"input_tokens" =>
$fallback_response["input_tokens"] ??
($accumulated_usage["prompt_tokens"] ?? 0),
"output_tokens" =>
$fallback_response["output_tokens"] ??
($accumulated_usage["completion_tokens"] ?? 0),
"total_tokens" =>
$fallback_response["total_tokens"] ??
($accumulated_usage["total_tokens"] ?? 0),
"cost" => $fallback_response["cost"] ?? 0,
"generation_time" => microtime(true) - $start_time,
];
}
return [
"content" => $accumulated_content,
"model" => $this->get_model_for_task($type),
"input_tokens" => $accumulated_usage["prompt_tokens"] ?? 0,
"output_tokens" => $accumulated_usage["completion_tokens"] ?? 0,
"total_tokens" => $accumulated_usage["total_tokens"] ?? 0,
"cost" => 0,
"generation_time" => microtime(true) - $start_time,
];
}
/**
* Generate image via OpenAI-compatible endpoint
*
* @param string $prompt Image prompt.
* @param string $model Model to use (falls back to configured model).
* @param array $options Optional parameters (size, quality, n).
* @return array|WP_Error Response with url, model, cost, or error.
*/
public function generate_image($prompt, $model = null, $options = [])
{
if (empty($this->image_base_url)) {
return new WP_Error(
"no_api_url",
__(
"Custom Endpoint URL for Images is not configured.",
"wp-agentic-writer",
),
);
}
if (empty($model)) {
$model = $this->get_model_for_task("image");
}
if (empty($model)) {
return new WP_Error(
"no_model",
__(
"Custom Endpoint Image Generation Model is not defined. Please configure a valid model code in Settings > Custom Endpoint.",
"wp-agentic-writer",
),
);
}
$base = rtrim($this->image_base_url, "/");
if (!preg_match('#/v1$#', $base)) {
$base .= "/v1";
}
$endpoint = $base . "/images/generations";
$body = [
"model" => $model,
"prompt" => $prompt,
"n" => $options["n"] ?? 1,
"size" => $options["size"] ?? "1024x1024",
];
// Pass along standard DALL-E style optional params if present
if (isset($options["quality"])) {
$body["quality"] = $options["quality"];
}
if (isset($options["style"])) {
$body["style"] = $options["style"];
}
$args = [
"body" => wp_json_encode($body),
"headers" => [
"Content-Type" => "application/json",
],
"timeout" => 60,
];
if (!empty($this->image_api_key)) {
$args["headers"]["Authorization"] =
"Bearer " . $this->image_api_key;
}
$start_time = microtime(true);
$response = wp_remote_post($endpoint, $args);
$generation_time = microtime(true) - $start_time;
if (is_wp_error($response)) {
return $response;
}
$status_code = wp_remote_retrieve_response_code($response);
$body_response = json_decode(wp_remote_retrieve_body($response), true);
if (200 !== $status_code) {
$error_msg =
$body_response["error"]["message"] ??
"Unknown error generating image via custom endpoint.";
return new WP_Error("api_error", $error_msg, [
"status" => $status_code,
]);
}
if (empty($body_response["data"][0]["url"])) {
return new WP_Error(
"api_error",
"No image URL returned by custom endpoint.",
);
}
return [
"url" => $body_response["data"][0]["url"],
"cost" => 0,
"generation_time" => $generation_time,
"model" => $model,
"prompt" => $prompt,
"input_tokens" => 0,
"output_tokens" => 0,
];
}
/**
* Check if provider is configured
*
* @return bool True if base URL is set.
*/
public function is_configured()
{
return !empty($this->base_url);
}
/**
* Test connection to local backend
*
* @return array|WP_Error Success array or error.
*/
public function test_connection()
{
if (!$this->is_configured()) {
return new WP_Error(
"not_configured",
__("Custom endpoint URL not configured", "wp-agentic-writer"),
);
}
// Best-effort reachability checks. Do not hard-fail here; inference test below is authoritative.
$reachable = false;
$health_endpoints = ["/ping", "/health", "/"];
foreach ($health_endpoints as $endpoint) {
$health_response = wp_remote_get($this->base_url . $endpoint, [
"timeout" => 5,
"sslverify" => false,
]);
if (is_wp_error($health_response)) {
continue;
}
$health_body = trim(
(string) wp_remote_retrieve_body($health_response),
);
$health_code = (int) wp_remote_retrieve_response_code(
$health_response,
);
$health_json = json_decode($health_body, true);
// Any 2xx indicates proxy process is reachable.
if ($health_code >= 200 && $health_code < 300) {
$reachable = true;
}
// Stronger signal for known proxy responses.
if (strcasecmp($health_body, "pong") === 0) {
$reachable = true;
break;
}
if (is_array($health_json)) {
$ok_flag =
$health_json["ok"] ?? ($health_json["success"] ?? null);
$status = strtolower((string) ($health_json["status"] ?? ""));
if (
true === $ok_flag ||
in_array($status, ["ok", "healthy", "pong"], true)
) {
$reachable = true;
break;
}
}
}
// Test actual inference with simple prompt
$test_response = wp_remote_post(
$this->get_endpoint_url("/chat/completions"),
[
"headers" => [
"Content-Type" => "application/json",
"Authorization" => "Bearer " . $this->api_key,
],
"body" => wp_json_encode([
"model" => $this->model,
"messages" => [
[
"role" => "user",
"content" =>
"Reply with exactly: Connection test successful",
],
],
"stream" => false,
]),
"timeout" => 30,
"sslverify" => false,
],
);
if (is_wp_error($test_response)) {
// If both health and inference are unreachable, report connection issue.
if (!$reachable) {
return new WP_Error(
"ping_failed",
sprintf(
/* translators: %s: error message */
__(
"Cannot reach endpoint: %s. Is it running and reachable from this server?",
"wp-agentic-writer",
),
$test_response->get_error_message(),
),
);
}
return new WP_Error(
"inference_failed",
sprintf(
/* translators: %s: error message */
__("Inference test failed: %s", "wp-agentic-writer"),
$test_response->get_error_message(),
),
);
}
$code = wp_remote_retrieve_response_code($test_response);
$raw_body = wp_remote_retrieve_body($test_response);
$test_body = json_decode($raw_body, true);
if ($code >= 400) {
$error_msg =
$test_body["error"]["message"] ?? substr($raw_body, 0, 150);
return new WP_Error(
"api_error",
sprintf(
/* translators: %1$d: HTTP status code, %2$s: error message */
__('API Error (HTTP %1$d): %2$s', "wp-agentic-writer"),
$code,
esc_html($error_msg),
),
);
}
if (!isset($test_body["choices"][0]["message"]["content"])) {
// Some endpoints might still ignore stream=false and return SSE chunks.
// If the response starts with "data: ", try to parse the first chunk's content.
if (strpos($raw_body, "data: ") === 0) {
$lines = explode("\n", $raw_body);
$content = "";
foreach ($lines as $line) {
if (strpos($line, "data: ") === 0) {
$json_str = substr($line, 6);
if ($json_str === "[DONE]") {
continue;
}
$chunk = json_decode($json_str, true);
if (isset($chunk["choices"][0]["delta"]["content"])) {
$content .=
$chunk["choices"][0]["delta"]["content"];
}
}
}
if (!empty($content)) {
return [
"success" => true,
"message" => __(
"Connected! Endpoint responding correctly.",
"wp-agentic-writer",
),
"sample_response" => $content,
];
}
}
return new WP_Error(
"invalid_response",
__(
"Endpoint not responding with expected OpenAI-compatible format. Check your URL and API key.",
"wp-agentic-writer",
) .
" Response preview: " .
substr($raw_body, 0, 100),
);
}
return [
"success" => true,
"message" => __(
"Connected! Endpoint responding correctly.",
"wp-agentic-writer",
),
"sample_response" => $test_body["choices"][0]["message"]["content"],
];
}
/**
* Check if provider supports task type
*
* @param string $type Task type.
* @return bool True if supported.
*/
public function supports_task_type($type)
{
// Custom endpoint supports both text and image tasks
return in_array(
$type,
["chat", "clarity", "planning", "writing", "refinement", "image"],
true,
);
}
}