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