base_url = $settings['local_backend_url'] ?? ''; $this->api_key = $settings['local_backend_key'] ?? 'dummy'; $this->model = $settings['local_backend_model'] ?? 'claude-local'; } /** * 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 = array(), $type = 'planning' ) { if ( ! $this->is_configured() ) { return new WP_Error( 'not_configured', __( 'Local Backend URL not configured.', 'wp-agentic-writer' ) ); } $start_time = microtime( true ); $response = wp_remote_post( $this->base_url . '/v1/messages', array( 'headers' => array( 'Content-Type' => 'application/json', 'Authorization' => 'Bearer ' . $this->api_key, ), 'body' => wp_json_encode( array( 'messages' => $messages, ) ), '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 */ __( 'Local Backend 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 ); return new WP_Error( 'api_error', sprintf( /* translators: %1$d: HTTP status code, %2$s: response body */ __( 'Local Backend error (%1$d): %2$s', 'wp-agentic-writer' ), $code, $body ) ); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( ! isset( $body['choices'][0]['message']['content'] ) ) { return new WP_Error( 'invalid_response', __( 'Invalid response format from Local Backend', 'wp-agentic-writer' ) ); } $content = $body['choices'][0]['message']['content']; return array( 'content' => $content, 'model' => $this->model, '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 = array(), $type = 'planning', $callback = null ) { if ( ! $this->is_configured() ) { return new WP_Error( 'not_configured', __( 'Local Backend URL not configured.', 'wp-agentic-writer' ) ); } $body = array( 'messages' => $messages, 'stream' => true, ); $accumulated_content = ''; $accumulated_usage = array(); $buffer = ''; $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->base_url . '/v1/messages' ); curl_setopt_array( $ch, array( CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => false, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, CURLOPT_WRITEFUNCTION => function( $curl, $data ) use ( &$buffer, $accumulating_callback, &$accumulated_usage ) { $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 ) || ! str_starts_with( $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 ); } // 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 ); } if ( isset( $chunk['usage'] ) ) { $accumulated_usage = $chunk['usage']; } } return strlen( $data ); }, CURLOPT_HTTPHEADER => array( 'Content-Type: application/json', 'Authorization: Bearer ' . $this->api_key, ), 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 ) { return new WP_Error( 'api_error', sprintf( 'API error (%d): %s', $http_code, $buffer ) ); } // 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 ) ) { // 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 ) ); } } return array( 'content' => $accumulated_content, 'model' => $this->model, '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 (not supported by local backend) * * @param string $prompt Image prompt. * @param string $model Model to use. * @param array $options Optional parameters. * @return WP_Error Error indicating not supported. */ public function generate_image( $prompt, $model = null, $options = array() ) { return new WP_Error( 'not_supported', __( 'Image generation not supported by Local Backend. Use OpenRouter for images.', 'wp-agentic-writer' ) ); } /** * 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', __( 'Local Backend URL not configured', 'wp-agentic-writer' ) ); } // Test /ping endpoint $ping_response = wp_remote_get( $this->base_url . '/ping', array( 'timeout' => 5, 'sslverify' => false, ) ); if ( is_wp_error( $ping_response ) ) { return new WP_Error( 'ping_failed', sprintf( /* translators: %s: error message */ __( 'Cannot reach proxy: %s. Is it running?', 'wp-agentic-writer' ), $ping_response->get_error_message() ) ); } $ping_body = wp_remote_retrieve_body( $ping_response ); if ( 'pong' !== $ping_body ) { return new WP_Error( 'invalid_ping', __( 'Proxy responded but with unexpected format', 'wp-agentic-writer' ) ); } // Test actual inference with simple prompt $test_response = wp_remote_post( $this->base_url . '/v1/messages', array( 'headers' => array( 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( array( 'messages' => array( array( 'role' => 'user', 'content' => 'Reply with exactly: Connection test successful', ), ), ) ), 'timeout' => 30, 'sslverify' => false, ) ); if ( is_wp_error( $test_response ) ) { return new WP_Error( 'inference_failed', sprintf( /* translators: %s: error message */ __( 'Inference test failed: %s', 'wp-agentic-writer' ), $test_response->get_error_message() ) ); } $test_body = json_decode( wp_remote_retrieve_body( $test_response ), true ); if ( ! isset( $test_body['choices'][0]['message']['content'] ) ) { return new WP_Error( 'invalid_response', __( 'Claude CLI not responding correctly. Check proxy logs.', 'wp-agentic-writer' ) ); } return array( 'success' => true, 'message' => __( 'Connected! Proxy 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 (all text tasks). */ public function supports_task_type( $type ) { // Local backend supports all text tasks, but not images return in_array( $type, array( 'chat', 'clarity', 'planning', 'writing', 'refinement' ), true ); } }