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
1083 lines
39 KiB
PHP
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,
|
|
);
|
|
}
|
|
}
|