* @author Maximum Software Inc * @license GPL-3.0-only * @link https://agenistrator.com/ * * SPDX-FileCopyrightText: 2026 Alex Ruban * SPDX-FileCopyrightText: 2026 Maximum Software Inc * SPDX-License-Identifier: GPL-3.0-only */ namespace Agenistrator; // Direct access prevention if ( ! defined( 'ABSPATH' ) ) { exit; } /** * LLM_Chat_Ollama_Cloud class. */ class LLM_Chat_Ollama_Cloud extends LLM_Chat_OpenAI { /** * Parse error response from API * * Ollama Cloud returns errors as a plain string (e.g. {"error": "unauthorized"}) * rather than the OpenAI object format ({"error": {"message": "...", "type": "..."}}). * This override normalizes the string error into the object format expected by the * parent implementation. * * @see https://docs.ollama.com/api/errors Ollama error response format * * @param array $response Decoded response array * @throws \Exception If response contains an error * @since 1.0.4 */ protected function parse_error_response( $response ) { if ( isset( $response['error'] ) && is_string( $response['error'] ) ) { $response['error'] = array( 'message' => $response['error'], 'type' => 'api_error', ); } parent::parse_error_response( $response ); } /** * Create a new context object specific to Ollama Cloud * * @param string $system_message Optional custom system message * @return Context_Ollama_Cloud Ollama Cloud-specific context object * @since 1.0.1 */ public function create_context( $system_message = null ) { return new Context_Ollama_Cloud( $system_message ); } /** * Validate that the context is the correct type for this provider * * @param Context $context Context object to validate * @throws \Exception If context is not the correct type * @since 1.0.1 */ protected function validate_context( $context ) { if ( ! ( $context instanceof Context_Ollama_Cloud ) ) { // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are not HTML output throw new \Exception( String_Utils::replace_tags( __( 'Invalid context type: expected {expected}, got {actual}', 'agenistrator' ), array( 'expected' => 'Context_Ollama_Cloud', 'actual' => get_class( $context ), ) ) ); // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped } } /** * Generate a streaming chat completion * * When the context carries tools, force a synchronous completion so that * tool_calls are parsed reliably. Ollama's OpenAI-compatible /v1 endpoint * garbles streamed tool_call deltas (they arrive as plain text fragments * inside `content` instead of structured `tool_calls` chunks), so the * synchronous result is re-emitted through the streaming-callback contract * used by the agent loop. Streaming tool_calls are only reliable on the * native /api/chat endpoint, which this provider does not use. * * @see https://github.com/ollama/ollama/issues/9084 Upstream tracking issue for /v1 tool+stream breakage * @see https://docs.ollama.com/api/openai-compatibility OpenAI compatibility surface (no streamed tool_calls) * @see https://www.ollama.ai/blog/streaming-tool Native /api/chat streaming tool calls (not /v1) * * @param Context $context Context object containing all request information * @param callable $stream_callback Callback function for streaming. Should return bool: true to continue, false to stop. * @return bool Success flag (true if streaming completed successfully) * @since 1.0.1 */ public function generate_streaming_completion( $context, $stream_callback = null ) { if ( empty( $context->get_available_tools() ) ) { return parent::generate_streaming_completion( $context, $stream_callback ); } $this->validate_context( $context ); try { $response = $this->generate_sync_completion( $context ); } catch ( \Throwable $e ) { call_user_func( $stream_callback, array( 'type' => 'error', 'message' => $e->getMessage(), ) ); return false; } // Emit accumulated content as a single content event - the synchronous // path has no token-by-token stream by design. if ( ! empty( $response['content'] ) ) { call_user_func( $stream_callback, array( 'type' => 'content', 'content' => $response['content'], ) ); } // Send done event before tool calls, mirroring LLM_Chat_OpenAI's order. call_user_func( $stream_callback, array( 'type' => 'done' ) ); if ( ! empty( $response['tool_calls'] ) ) { foreach ( $response['tool_calls'] as $tool_call ) { if ( call_user_func( $stream_callback, array( 'type' => 'tool_call_complete', 'tool_call' => $tool_call, ) ) === false ) { break; } } } return true; } }