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