1f6ef2e50SAndreas Gohr<?php 2f6ef2e50SAndreas Gohr 3f6ef2e50SAndreas Gohrnamespace dokuwiki\plugin\aichat\Model; 4f6ef2e50SAndreas Gohr 5294a9eafSAndreas Gohruse dokuwiki\HTTP\DokuHTTPClient; 6294a9eafSAndreas Gohr 7294a9eafSAndreas Gohr/** 8294a9eafSAndreas Gohr * Base class for all models 9294a9eafSAndreas Gohr * 10294a9eafSAndreas Gohr * Model classes also need to implement one of the following interfaces: 11294a9eafSAndreas Gohr * - ChatInterface 12294a9eafSAndreas Gohr * - EmbeddingInterface 13*dce0dee5SAndreas Gohr * 14*dce0dee5SAndreas Gohr * This class already implements most of the requirements for these interfaces. 15*dce0dee5SAndreas Gohr * 16*dce0dee5SAndreas Gohr * In addition to any missing interface methods, model implementations will need to 17*dce0dee5SAndreas Gohr * extend the constructor to handle the plugin configuration and implement the 18*dce0dee5SAndreas Gohr * parseAPIResponse() method to handle the specific API response. 19294a9eafSAndreas Gohr */ 20*dce0dee5SAndreas Gohrabstract class AbstractModel implements ModelInterface 217ebc7895Ssplitbrain{ 22*dce0dee5SAndreas Gohr /** @var string The model name */ 23*dce0dee5SAndreas Gohr protected $modelName; 24*dce0dee5SAndreas Gohr /** @var array The model info from the model.json file */ 25*dce0dee5SAndreas Gohr protected $modelInfo; 2634a1c478SAndreas Gohr 27*dce0dee5SAndreas Gohr /** @var int input tokens used since last reset */ 2834a1c478SAndreas Gohr protected $inputTokensUsed = 0; 29*dce0dee5SAndreas Gohr /** @var int output tokens used since last reset */ 3034a1c478SAndreas Gohr protected $outputTokensUsed = 0; 31*dce0dee5SAndreas Gohr /** @var int total time spent in requests since last reset */ 32f6ef2e50SAndreas Gohr protected $timeUsed = 0; 33*dce0dee5SAndreas Gohr /** @var int total number of requests made since last reset */ 34f6ef2e50SAndreas Gohr protected $requestsMade = 0; 35294a9eafSAndreas Gohr /** @var int start time of the current request chain (may be multiple when retries needed) */ 36294a9eafSAndreas Gohr protected $requestStart = 0; 37f6ef2e50SAndreas Gohr 38*dce0dee5SAndreas Gohr /** @var int How often to retry a request if it fails */ 39*dce0dee5SAndreas Gohr public const MAX_RETRIES = 3; 40*dce0dee5SAndreas Gohr 41*dce0dee5SAndreas Gohr /** @var DokuHTTPClient */ 42*dce0dee5SAndreas Gohr protected $http; 43*dce0dee5SAndreas Gohr /** @var bool debug API communication */ 44*dce0dee5SAndreas Gohr protected $debug = false; 45*dce0dee5SAndreas Gohr 46*dce0dee5SAndreas Gohr // region ModelInterface 47*dce0dee5SAndreas Gohr 48*dce0dee5SAndreas Gohr /** @inheritdoc */ 49*dce0dee5SAndreas Gohr public function __construct(string $name, array $config) 50294a9eafSAndreas Gohr { 51*dce0dee5SAndreas Gohr $this->modelName = $name; 52294a9eafSAndreas Gohr $this->http = new DokuHTTPClient(); 53294a9eafSAndreas Gohr $this->http->timeout = 60; 54294a9eafSAndreas Gohr $this->http->headers['Content-Type'] = 'application/json'; 55*dce0dee5SAndreas Gohr 56*dce0dee5SAndreas Gohr $reflect = new \ReflectionClass($this); 57*dce0dee5SAndreas Gohr $json = dirname($reflect->getFileName()) . '/models.json'; 58*dce0dee5SAndreas Gohr if (!file_exists($json)) { 59*dce0dee5SAndreas Gohr throw new \Exception('Model info file not found at ' . $json); 60294a9eafSAndreas Gohr } 61*dce0dee5SAndreas Gohr try { 62*dce0dee5SAndreas Gohr $modelinfos = json_decode(file_get_contents($json), true, 512, JSON_THROW_ON_ERROR); 63*dce0dee5SAndreas Gohr } catch (\JsonException $e) { 64*dce0dee5SAndreas Gohr throw new \Exception('Failed to parse model info file: ' . $e->getMessage(), $e->getCode(), $e); 65*dce0dee5SAndreas Gohr } 66*dce0dee5SAndreas Gohr 67*dce0dee5SAndreas Gohr if ($this instanceof ChatInterface) { 68*dce0dee5SAndreas Gohr if (!isset($modelinfos['chat'][$name])) { 69*dce0dee5SAndreas Gohr throw new \Exception('Invalid chat model configured: ' . $name); 70*dce0dee5SAndreas Gohr } 71*dce0dee5SAndreas Gohr $this->modelInfo = $modelinfos['chat'][$name]; 72*dce0dee5SAndreas Gohr } 73*dce0dee5SAndreas Gohr 74*dce0dee5SAndreas Gohr if ($this instanceof EmbeddingInterface) { 75*dce0dee5SAndreas Gohr if (!isset($modelinfos['embedding'][$name])) { 76*dce0dee5SAndreas Gohr throw new \Exception('Invalid embedding model configured: ' . $name); 77*dce0dee5SAndreas Gohr } 78*dce0dee5SAndreas Gohr $this->modelInfo = $modelinfos['embedding'][$name]; 79*dce0dee5SAndreas Gohr } 80*dce0dee5SAndreas Gohr } 81*dce0dee5SAndreas Gohr 82*dce0dee5SAndreas Gohr /** @inheritdoc */ 83*dce0dee5SAndreas Gohr public function getModelName() 84*dce0dee5SAndreas Gohr { 85*dce0dee5SAndreas Gohr return $this->modelName; 86*dce0dee5SAndreas Gohr } 87*dce0dee5SAndreas Gohr 88*dce0dee5SAndreas Gohr /** 89*dce0dee5SAndreas Gohr * Reset the usage statistics 90*dce0dee5SAndreas Gohr * 91*dce0dee5SAndreas Gohr * Usually not needed when only handling one operation per request, but useful in CLI 92*dce0dee5SAndreas Gohr */ 93*dce0dee5SAndreas Gohr public function resetUsageStats() 94*dce0dee5SAndreas Gohr { 95*dce0dee5SAndreas Gohr $this->tokensUsed = 0; 96*dce0dee5SAndreas Gohr $this->timeUsed = 0; 97*dce0dee5SAndreas Gohr $this->requestsMade = 0; 98*dce0dee5SAndreas Gohr } 99*dce0dee5SAndreas Gohr 100*dce0dee5SAndreas Gohr /** 101*dce0dee5SAndreas Gohr * Get the usage statistics for this instance 102*dce0dee5SAndreas Gohr * 103*dce0dee5SAndreas Gohr * @return string[] 104*dce0dee5SAndreas Gohr */ 105*dce0dee5SAndreas Gohr public function getUsageStats() 106*dce0dee5SAndreas Gohr { 107*dce0dee5SAndreas Gohr 108*dce0dee5SAndreas Gohr $cost = 0; 109*dce0dee5SAndreas Gohr $cost += $this->inputTokensUsed * $this->getInputTokenPrice(); 110*dce0dee5SAndreas Gohr if ($this instanceof ChatInterface) { 111*dce0dee5SAndreas Gohr $cost += $this->outputTokensUsed * $this->getOutputTokenPrice(); 112*dce0dee5SAndreas Gohr } 113*dce0dee5SAndreas Gohr 114*dce0dee5SAndreas Gohr return [ 115*dce0dee5SAndreas Gohr 'tokens' => $this->inputTokensUsed + $this->outputTokensUsed, 116*dce0dee5SAndreas Gohr 'cost' => round($cost / 1_000_000, 4), 117*dce0dee5SAndreas Gohr 'time' => round($this->timeUsed, 2), 118*dce0dee5SAndreas Gohr 'requests' => $this->requestsMade, 119*dce0dee5SAndreas Gohr ]; 120*dce0dee5SAndreas Gohr } 121*dce0dee5SAndreas Gohr 122*dce0dee5SAndreas Gohr /** @inheritdoc */ 123*dce0dee5SAndreas Gohr public function getMaxInputTokenLength(): int 124*dce0dee5SAndreas Gohr { 125*dce0dee5SAndreas Gohr return $this->modelInfo['inputTokens']; 126*dce0dee5SAndreas Gohr } 127*dce0dee5SAndreas Gohr 128*dce0dee5SAndreas Gohr /** @inheritdoc */ 129*dce0dee5SAndreas Gohr public function getInputTokenPrice(): float 130*dce0dee5SAndreas Gohr { 131*dce0dee5SAndreas Gohr return $this->modelInfo['inputTokenPrice']; 132*dce0dee5SAndreas Gohr } 133*dce0dee5SAndreas Gohr 134*dce0dee5SAndreas Gohr // endregion 135*dce0dee5SAndreas Gohr 136*dce0dee5SAndreas Gohr // region EmbeddingInterface 137*dce0dee5SAndreas Gohr 138*dce0dee5SAndreas Gohr /** @inheritdoc */ 139*dce0dee5SAndreas Gohr public function getDimensions(): int 140*dce0dee5SAndreas Gohr { 141*dce0dee5SAndreas Gohr return $this->modelInfo['dimensions']; 142*dce0dee5SAndreas Gohr } 143*dce0dee5SAndreas Gohr 144*dce0dee5SAndreas Gohr // endregion 145*dce0dee5SAndreas Gohr 146*dce0dee5SAndreas Gohr // region ChatInterface 147*dce0dee5SAndreas Gohr 148*dce0dee5SAndreas Gohr public function getMaxOutputTokenLength(): int 149*dce0dee5SAndreas Gohr { 150*dce0dee5SAndreas Gohr return $this->modelInfo['outputTokens']; 151*dce0dee5SAndreas Gohr } 152*dce0dee5SAndreas Gohr 153*dce0dee5SAndreas Gohr public function getOutputTokenPrice(): float 154*dce0dee5SAndreas Gohr { 155*dce0dee5SAndreas Gohr return $this->modelInfo['outputTokenPrice']; 156*dce0dee5SAndreas Gohr } 157*dce0dee5SAndreas Gohr 158*dce0dee5SAndreas Gohr // endregion 159*dce0dee5SAndreas Gohr 160*dce0dee5SAndreas Gohr // region API communication 161f6ef2e50SAndreas Gohr 162f6ef2e50SAndreas Gohr /** 16334a1c478SAndreas Gohr * When enabled, the input/output of the API will be printed to STDOUT 16434a1c478SAndreas Gohr * 16534a1c478SAndreas Gohr * @param bool $debug 16634a1c478SAndreas Gohr */ 16734a1c478SAndreas Gohr public function setDebug($debug = true) 16834a1c478SAndreas Gohr { 16934a1c478SAndreas Gohr $this->debug = $debug; 17034a1c478SAndreas Gohr } 17134a1c478SAndreas Gohr 17234a1c478SAndreas Gohr /** 173294a9eafSAndreas Gohr * This method should check the response for any errors. If the API singalled an error, 174294a9eafSAndreas Gohr * this method should throw an Exception with a meaningful error message. 175294a9eafSAndreas Gohr * 176294a9eafSAndreas Gohr * If the response returned any info on used tokens, they should be added to $this->tokensUsed 177294a9eafSAndreas Gohr * 178294a9eafSAndreas Gohr * The method should return the parsed response, which will be passed to the calling method. 179294a9eafSAndreas Gohr * 180294a9eafSAndreas Gohr * @param mixed $response the parsed JSON response from the API 181294a9eafSAndreas Gohr * @return mixed 182294a9eafSAndreas Gohr * @throws \Exception when the response indicates an error 183294a9eafSAndreas Gohr */ 184294a9eafSAndreas Gohr abstract protected function parseAPIResponse($response); 185294a9eafSAndreas Gohr 186294a9eafSAndreas Gohr /** 187294a9eafSAndreas Gohr * Send a request to the API 188294a9eafSAndreas Gohr * 189294a9eafSAndreas Gohr * Model classes should use this method to send requests to the API. 190294a9eafSAndreas Gohr * 191294a9eafSAndreas Gohr * This method will take care of retrying and logging basic statistics. 192294a9eafSAndreas Gohr * 193294a9eafSAndreas Gohr * It is assumed that all APIs speak JSON. 194294a9eafSAndreas Gohr * 195294a9eafSAndreas Gohr * @param string $method The HTTP method to use (GET, POST, PUT, DELETE, etc.) 196294a9eafSAndreas Gohr * @param string $url The full URL to send the request to 197294a9eafSAndreas Gohr * @param array $data Payload to send, will be encoded to JSON 198294a9eafSAndreas Gohr * @param int $retry How often this request has been retried, do not set externally 199294a9eafSAndreas Gohr * @return array API response as returned by parseAPIResponse 200294a9eafSAndreas Gohr * @throws \Exception when anything goes wrong 201294a9eafSAndreas Gohr */ 202294a9eafSAndreas Gohr protected function sendAPIRequest($method, $url, $data, $retry = 0) 203294a9eafSAndreas Gohr { 204294a9eafSAndreas Gohr // init statistics 205294a9eafSAndreas Gohr if ($retry === 0) { 206294a9eafSAndreas Gohr $this->requestStart = microtime(true); 207294a9eafSAndreas Gohr } else { 208294a9eafSAndreas Gohr sleep($retry); // wait a bit between retries 209294a9eafSAndreas Gohr } 210294a9eafSAndreas Gohr $this->requestsMade++; 211294a9eafSAndreas Gohr 212294a9eafSAndreas Gohr // encode payload data 213294a9eafSAndreas Gohr try { 21434a1c478SAndreas Gohr $json = json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); 215294a9eafSAndreas Gohr } catch (\JsonException $e) { 216294a9eafSAndreas Gohr $this->timeUsed += $this->requestStart - microtime(true); 217294a9eafSAndreas Gohr throw new \Exception('Failed to encode JSON for API:' . $e->getMessage(), $e->getCode(), $e); 218294a9eafSAndreas Gohr } 219294a9eafSAndreas Gohr 22034a1c478SAndreas Gohr if ($this->debug) { 22134a1c478SAndreas Gohr echo 'Sending ' . $method . ' request to ' . $url . ' with payload:' . "\n"; 22234a1c478SAndreas Gohr print_r($json); 22334a1c478SAndreas Gohr } 22434a1c478SAndreas Gohr 225294a9eafSAndreas Gohr // send request and handle retries 226294a9eafSAndreas Gohr $this->http->sendRequest($url, $json, $method); 227294a9eafSAndreas Gohr $response = $this->http->resp_body; 228294a9eafSAndreas Gohr if ($response === false || $this->http->error) { 229294a9eafSAndreas Gohr if ($retry < self::MAX_RETRIES) { 230294a9eafSAndreas Gohr return $this->sendAPIRequest($method, $url, $data, $retry + 1); 231294a9eafSAndreas Gohr } 232294a9eafSAndreas Gohr $this->timeUsed += microtime(true) - $this->requestStart; 233294a9eafSAndreas Gohr throw new \Exception('API returned no response. ' . $this->http->error); 234294a9eafSAndreas Gohr } 235294a9eafSAndreas Gohr 23634a1c478SAndreas Gohr if ($this->debug) { 23734a1c478SAndreas Gohr echo 'Received response:' . "\n"; 23834a1c478SAndreas Gohr print_r($response); 23934a1c478SAndreas Gohr } 24034a1c478SAndreas Gohr 241294a9eafSAndreas Gohr // decode the response 242294a9eafSAndreas Gohr try { 243294a9eafSAndreas Gohr $result = json_decode((string)$response, true, 512, JSON_THROW_ON_ERROR); 244294a9eafSAndreas Gohr } catch (\JsonException $e) { 245294a9eafSAndreas Gohr $this->timeUsed += microtime(true) - $this->requestStart; 246294a9eafSAndreas Gohr throw new \Exception('API returned invalid JSON: ' . $response, 0, $e); 247294a9eafSAndreas Gohr } 248294a9eafSAndreas Gohr 249294a9eafSAndreas Gohr // parse the response, retry on error 250294a9eafSAndreas Gohr try { 251294a9eafSAndreas Gohr $result = $this->parseAPIResponse($result); 252294a9eafSAndreas Gohr } catch (\Exception $e) { 253294a9eafSAndreas Gohr if ($retry < self::MAX_RETRIES) { 254294a9eafSAndreas Gohr return $this->sendAPIRequest($method, $url, $data, $retry + 1); 255294a9eafSAndreas Gohr } 256294a9eafSAndreas Gohr $this->timeUsed += microtime(true) - $this->requestStart; 257294a9eafSAndreas Gohr throw $e; 258294a9eafSAndreas Gohr } 259294a9eafSAndreas Gohr 260294a9eafSAndreas Gohr $this->timeUsed += microtime(true) - $this->requestStart; 261294a9eafSAndreas Gohr return $result; 262294a9eafSAndreas Gohr } 263294a9eafSAndreas Gohr 264*dce0dee5SAndreas Gohr // endregion 265f6ef2e50SAndreas Gohr} 266