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, ); } }