1<?php 2 3namespace dokuwiki\plugin\aichat\Model; 4 5use dokuwiki\HTTP\DokuHTTPClient; 6 7/** 8 * Base class for all models 9 * 10 * Model classes also need to implement one of the following interfaces: 11 * - ChatInterface 12 * - EmbeddingInterface 13 */ 14abstract class AbstractModel 15{ 16 /** @var bool debug API communication */ 17 protected $debug = false; 18 19 20 protected $inputTokensUsed = 0; 21 protected $outputTokensUsed = 0; 22 protected $tokensUsed = 0; 23 24 /** @var int total time spent in requests by this instance */ 25 protected $timeUsed = 0; 26 /** @var int total number of requests made by this instance */ 27 protected $requestsMade = 0; 28 /** @var int How often to retry a request if it fails */ 29 public const MAX_RETRIES = 3; 30 /** @var DokuHTTPClient */ 31 protected $http; 32 /** @var int start time of the current request chain (may be multiple when retries needed) */ 33 protected $requestStart = 0; 34 35 /** 36 * This initializes a HTTP client 37 * 38 * Implementors should override this and authenticate the client. 39 * 40 * @param array $config The plugin configuration 41 */ 42 public function __construct(array $config) 43 { 44 $this->http = new DokuHTTPClient(); 45 $this->http->timeout = 60; 46 $this->http->headers['Content-Type'] = 'application/json'; 47 } 48 49 /** 50 * When enabled, the input/output of the API will be printed to STDOUT 51 * 52 * @param bool $debug 53 */ 54 public function setDebug($debug = true) 55 { 56 $this->debug = $debug; 57 } 58 59 /** 60 * This method should check the response for any errors. If the API singalled an error, 61 * this method should throw an Exception with a meaningful error message. 62 * 63 * If the response returned any info on used tokens, they should be added to $this->tokensUsed 64 * 65 * The method should return the parsed response, which will be passed to the calling method. 66 * 67 * @param mixed $response the parsed JSON response from the API 68 * @return mixed 69 * @throws \Exception when the response indicates an error 70 */ 71 abstract protected function parseAPIResponse($response); 72 73 /** 74 * Send a request to the API 75 * 76 * Model classes should use this method to send requests to the API. 77 * 78 * This method will take care of retrying and logging basic statistics. 79 * 80 * It is assumed that all APIs speak JSON. 81 * 82 * @param string $method The HTTP method to use (GET, POST, PUT, DELETE, etc.) 83 * @param string $url The full URL to send the request to 84 * @param array $data Payload to send, will be encoded to JSON 85 * @param int $retry How often this request has been retried, do not set externally 86 * @return array API response as returned by parseAPIResponse 87 * @throws \Exception when anything goes wrong 88 */ 89 protected function sendAPIRequest($method, $url, $data, $retry = 0) 90 { 91 // init statistics 92 if ($retry === 0) { 93 $this->requestStart = microtime(true); 94 } else { 95 sleep($retry); // wait a bit between retries 96 } 97 $this->requestsMade++; 98 99 // encode payload data 100 try { 101 $json = json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); 102 } catch (\JsonException $e) { 103 $this->timeUsed += $this->requestStart - microtime(true); 104 throw new \Exception('Failed to encode JSON for API:' . $e->getMessage(), $e->getCode(), $e); 105 } 106 107 if ($this->debug) { 108 echo 'Sending ' . $method . ' request to ' . $url . ' with payload:' . "\n"; 109 print_r($json); 110 } 111 112 // send request and handle retries 113 $this->http->sendRequest($url, $json, $method); 114 $response = $this->http->resp_body; 115 if ($response === false || $this->http->error) { 116 if ($retry < self::MAX_RETRIES) { 117 return $this->sendAPIRequest($method, $url, $data, $retry + 1); 118 } 119 $this->timeUsed += microtime(true) - $this->requestStart; 120 throw new \Exception('API returned no response. ' . $this->http->error); 121 } 122 123 if ($this->debug) { 124 echo 'Received response:' . "\n"; 125 print_r($response); 126 } 127 128 // decode the response 129 try { 130 $result = json_decode((string)$response, true, 512, JSON_THROW_ON_ERROR); 131 } catch (\JsonException $e) { 132 $this->timeUsed += microtime(true) - $this->requestStart; 133 throw new \Exception('API returned invalid JSON: ' . $response, 0, $e); 134 } 135 136 // parse the response, retry on error 137 try { 138 $result = $this->parseAPIResponse($result); 139 } catch (\Exception $e) { 140 if ($retry < self::MAX_RETRIES) { 141 return $this->sendAPIRequest($method, $url, $data, $retry + 1); 142 } 143 $this->timeUsed += microtime(true) - $this->requestStart; 144 throw $e; 145 } 146 147 $this->timeUsed += microtime(true) - $this->requestStart; 148 return $result; 149 } 150 151 /** 152 * Reset the usage statistics 153 * 154 * Usually not needed when only handling one operation per request, but useful in CLI 155 */ 156 public function resetUsageStats() 157 { 158 $this->tokensUsed = 0; 159 $this->timeUsed = 0; 160 $this->requestsMade = 0; 161 } 162 163 /** 164 * Get the usage statistics for this instance 165 * 166 * @return string[] 167 */ 168 public function getUsageStats() 169 { 170 171 $cost = 0; 172 $cost += $this->inputTokensUsed * $this->getInputTokenPrice(); 173 if ($this instanceof ChatInterface) { 174 $cost += $this->outputTokensUsed * $this->getOutputTokenPrice(); 175 } 176 177 return [ 178 'tokens' => $this->tokensUsed + $this->inputTokensUsed + $this->outputTokensUsed, 179 'cost' => round($cost / 1_000_000, 4), 180 'time' => round($this->timeUsed, 2), 181 'requests' => $this->requestsMade, 182 ]; 183 } 184} 185