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 * 14 * This class already implements most of the requirements for these interfaces. 15 * 16 * In addition to any missing interface methods, model implementations will need to 17 * extend the constructor to handle the plugin configuration and implement the 18 * parseAPIResponse() method to handle the specific API response. 19 */ 20abstract class AbstractModel implements ModelInterface 21{ 22 /** @var string The model name */ 23 protected $modelName; 24 /** @var array The model info from the model.json file */ 25 protected $modelInfo; 26 27 /** @var int input tokens used since last reset */ 28 protected $inputTokensUsed = 0; 29 /** @var int output tokens used since last reset */ 30 protected $outputTokensUsed = 0; 31 /** @var int total time spent in requests since last reset */ 32 protected $timeUsed = 0; 33 /** @var int total number of requests made since last reset */ 34 protected $requestsMade = 0; 35 /** @var int start time of the current request chain (may be multiple when retries needed) */ 36 protected $requestStart = 0; 37 38 /** @var int How often to retry a request if it fails */ 39 public const MAX_RETRIES = 3; 40 41 /** @var DokuHTTPClient */ 42 protected $http; 43 /** @var bool debug API communication */ 44 protected $debug = false; 45 46 // region ModelInterface 47 48 /** @inheritdoc */ 49 public function __construct(string $name, array $config) 50 { 51 $this->modelName = $name; 52 $this->http = new DokuHTTPClient(); 53 $this->http->timeout = 60; 54 $this->http->headers['Content-Type'] = 'application/json'; 55 $this->http->headers['Accept'] = 'application/json'; 56 57 $reflect = new \ReflectionClass($this); 58 $json = dirname($reflect->getFileName()) . '/models.json'; 59 if (!file_exists($json)) { 60 throw new \Exception('Model info file not found at ' . $json); 61 } 62 try { 63 $modelinfos = json_decode(file_get_contents($json), true, 512, JSON_THROW_ON_ERROR); 64 } catch (\JsonException $e) { 65 throw new \Exception('Failed to parse model info file: ' . $e->getMessage(), $e->getCode(), $e); 66 } 67 68 if ($this instanceof ChatInterface) { 69 if (!isset($modelinfos['chat'][$name])) { 70 throw new \Exception('Invalid chat model configured: ' . $name); 71 } 72 $this->modelInfo = $modelinfos['chat'][$name]; 73 } 74 75 if ($this instanceof EmbeddingInterface) { 76 if (!isset($modelinfos['embedding'][$name])) { 77 throw new \Exception('Invalid embedding model configured: ' . $name); 78 } 79 $this->modelInfo = $modelinfos['embedding'][$name]; 80 } 81 } 82 83 /** @inheritdoc */ 84 public function getModelName() 85 { 86 return $this->modelName; 87 } 88 89 /** 90 * Reset the usage statistics 91 * 92 * Usually not needed when only handling one operation per request, but useful in CLI 93 */ 94 public function resetUsageStats() 95 { 96 $this->inputTokensUsed = 0; 97 $this->outputTokensUsed = 0; 98 $this->timeUsed = 0; 99 $this->requestsMade = 0; 100 } 101 102 /** 103 * Get the usage statistics for this instance 104 * 105 * @return string[] 106 */ 107 public function getUsageStats() 108 { 109 110 $cost = 0; 111 $cost += $this->inputTokensUsed * $this->getInputTokenPrice(); 112 if ($this instanceof ChatInterface) { 113 $cost += $this->outputTokensUsed * $this->getOutputTokenPrice(); 114 } 115 116 return [ 117 'tokens' => $this->inputTokensUsed + $this->outputTokensUsed, 118 'cost' => sprintf("%.6f", $cost / 1_000_000), 119 'time' => round($this->timeUsed, 2), 120 'requests' => $this->requestsMade, 121 ]; 122 } 123 124 /** @inheritdoc */ 125 public function getMaxInputTokenLength(): int 126 { 127 return $this->modelInfo['inputTokens']; 128 } 129 130 /** @inheritdoc */ 131 public function getInputTokenPrice(): float 132 { 133 return $this->modelInfo['inputTokenPrice']; 134 } 135 136 // endregion 137 138 // region EmbeddingInterface 139 140 /** @inheritdoc */ 141 public function getDimensions(): int 142 { 143 return $this->modelInfo['dimensions']; 144 } 145 146 // endregion 147 148 // region ChatInterface 149 150 public function getMaxOutputTokenLength(): int 151 { 152 return $this->modelInfo['outputTokens']; 153 } 154 155 public function getOutputTokenPrice(): float 156 { 157 return $this->modelInfo['outputTokenPrice']; 158 } 159 160 // endregion 161 162 // region API communication 163 164 /** 165 * When enabled, the input/output of the API will be printed to STDOUT 166 * 167 * @param bool $debug 168 */ 169 public function setDebug($debug = true) 170 { 171 $this->debug = $debug; 172 } 173 174 /** 175 * This method should check the response for any errors. If the API singalled an error, 176 * this method should throw an Exception with a meaningful error message. 177 * 178 * If the response returned any info on used tokens, they should be added to $this->tokensUsed 179 * 180 * The method should return the parsed response, which will be passed to the calling method. 181 * 182 * @param mixed $response the parsed JSON response from the API 183 * @return mixed 184 * @throws \Exception when the response indicates an error 185 */ 186 abstract protected function parseAPIResponse($response); 187 188 /** 189 * Send a request to the API 190 * 191 * Model classes should use this method to send requests to the API. 192 * 193 * This method will take care of retrying and logging basic statistics. 194 * 195 * It is assumed that all APIs speak JSON. 196 * 197 * @param string $method The HTTP method to use (GET, POST, PUT, DELETE, etc.) 198 * @param string $url The full URL to send the request to 199 * @param array $data Payload to send, will be encoded to JSON 200 * @param int $retry How often this request has been retried, do not set externally 201 * @return array API response as returned by parseAPIResponse 202 * @throws \Exception when anything goes wrong 203 */ 204 protected function sendAPIRequest($method, $url, $data, $retry = 0) 205 { 206 // init statistics 207 if ($retry === 0) { 208 $this->requestStart = microtime(true); 209 } else { 210 sleep($retry); // wait a bit between retries 211 } 212 $this->requestsMade++; 213 214 // encode payload data 215 try { 216 $json = json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); 217 } catch (\JsonException $e) { 218 $this->timeUsed += $this->requestStart - microtime(true); 219 throw new \Exception('Failed to encode JSON for API:' . $e->getMessage(), $e->getCode(), $e); 220 } 221 222 if ($this->debug) { 223 echo 'Sending ' . $method . ' request to ' . $url . ' with payload:' . "\n"; 224 print_r($json); 225 echo "\n"; 226 } 227 228 // send request and handle retries 229 $this->http->sendRequest($url, $json, $method); 230 $response = $this->http->resp_body; 231 if ($response === false || $this->http->error) { 232 if ($retry < self::MAX_RETRIES) { 233 return $this->sendAPIRequest($method, $url, $data, $retry + 1); 234 } 235 $this->timeUsed += microtime(true) - $this->requestStart; 236 throw new \Exception('API returned no response. ' . $this->http->error); 237 } 238 239 if ($this->debug) { 240 echo 'Received response:' . "\n"; 241 print_r($response); 242 echo "\n"; 243 } 244 245 // decode the response 246 try { 247 $result = json_decode((string)$response, true, 512, JSON_THROW_ON_ERROR); 248 } catch (\JsonException $e) { 249 $this->timeUsed += microtime(true) - $this->requestStart; 250 throw new \Exception('API returned invalid JSON: ' . $response, 0, $e); 251 } 252 253 // parse the response, retry on error 254 try { 255 $result = $this->parseAPIResponse($result); 256 } catch (\Exception $e) { 257 if ($retry < self::MAX_RETRIES) { 258 return $this->sendAPIRequest($method, $url, $data, $retry + 1); 259 } 260 $this->timeUsed += microtime(true) - $this->requestStart; 261 throw $e; 262 } 263 264 $this->timeUsed += microtime(true) - $this->requestStart; 265 return $result; 266 } 267 268 // endregion 269} 270