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 13dce0dee5SAndreas Gohr * 14dce0dee5SAndreas Gohr * This class already implements most of the requirements for these interfaces. 15dce0dee5SAndreas Gohr * 16dce0dee5SAndreas Gohr * In addition to any missing interface methods, model implementations will need to 17dce0dee5SAndreas Gohr * extend the constructor to handle the plugin configuration and implement the 18dce0dee5SAndreas Gohr * parseAPIResponse() method to handle the specific API response. 19294a9eafSAndreas Gohr */ 20dce0dee5SAndreas Gohrabstract class AbstractModel implements ModelInterface 217ebc7895Ssplitbrain{ 22dce0dee5SAndreas Gohr /** @var string The model name */ 23dce0dee5SAndreas Gohr protected $modelName; 24*b446155bSAndreas Gohr /** @var string The full model name */ 25*b446155bSAndreas Gohr protected $modelFullName; 26dce0dee5SAndreas Gohr /** @var array The model info from the model.json file */ 27dce0dee5SAndreas Gohr protected $modelInfo; 2834a1c478SAndreas Gohr 29dce0dee5SAndreas Gohr /** @var int input tokens used since last reset */ 3034a1c478SAndreas Gohr protected $inputTokensUsed = 0; 31dce0dee5SAndreas Gohr /** @var int output tokens used since last reset */ 3234a1c478SAndreas Gohr protected $outputTokensUsed = 0; 33dce0dee5SAndreas Gohr /** @var int total time spent in requests since last reset */ 34f6ef2e50SAndreas Gohr protected $timeUsed = 0; 35dce0dee5SAndreas Gohr /** @var int total number of requests made since last reset */ 36f6ef2e50SAndreas Gohr protected $requestsMade = 0; 37294a9eafSAndreas Gohr /** @var int start time of the current request chain (may be multiple when retries needed) */ 38294a9eafSAndreas Gohr protected $requestStart = 0; 39f6ef2e50SAndreas Gohr 40dce0dee5SAndreas Gohr /** @var int How often to retry a request if it fails */ 41dce0dee5SAndreas Gohr public const MAX_RETRIES = 3; 42dce0dee5SAndreas Gohr 43dce0dee5SAndreas Gohr /** @var DokuHTTPClient */ 44dce0dee5SAndreas Gohr protected $http; 45dce0dee5SAndreas Gohr /** @var bool debug API communication */ 46dce0dee5SAndreas Gohr protected $debug = false; 47dce0dee5SAndreas Gohr 48dce0dee5SAndreas Gohr // region ModelInterface 49dce0dee5SAndreas Gohr 50dce0dee5SAndreas Gohr /** @inheritdoc */ 51dce0dee5SAndreas Gohr public function __construct(string $name, array $config) 52294a9eafSAndreas Gohr { 53dce0dee5SAndreas Gohr $this->modelName = $name; 54294a9eafSAndreas Gohr $this->http = new DokuHTTPClient(); 55294a9eafSAndreas Gohr $this->http->timeout = 60; 56294a9eafSAndreas Gohr $this->http->headers['Content-Type'] = 'application/json'; 57cfd76f4aSAndreas Gohr $this->http->headers['Accept'] = 'application/json'; 58dce0dee5SAndreas Gohr 59dce0dee5SAndreas Gohr $reflect = new \ReflectionClass($this); 60dce0dee5SAndreas Gohr $json = dirname($reflect->getFileName()) . '/models.json'; 61dce0dee5SAndreas Gohr if (!file_exists($json)) { 62dce0dee5SAndreas Gohr throw new \Exception('Model info file not found at ' . $json); 63294a9eafSAndreas Gohr } 64dce0dee5SAndreas Gohr try { 65dce0dee5SAndreas Gohr $modelinfos = json_decode(file_get_contents($json), true, 512, JSON_THROW_ON_ERROR); 66dce0dee5SAndreas Gohr } catch (\JsonException $e) { 67dce0dee5SAndreas Gohr throw new \Exception('Failed to parse model info file: ' . $e->getMessage(), $e->getCode(), $e); 68dce0dee5SAndreas Gohr } 69dce0dee5SAndreas Gohr 70*b446155bSAndreas Gohr $this->modelFullName = basename(dirname($reflect->getFileName()) . ' ' . $name); 71*b446155bSAndreas Gohr 72dce0dee5SAndreas Gohr if ($this instanceof ChatInterface) { 73dce0dee5SAndreas Gohr if (!isset($modelinfos['chat'][$name])) { 74dce0dee5SAndreas Gohr throw new \Exception('Invalid chat model configured: ' . $name); 75dce0dee5SAndreas Gohr } 76dce0dee5SAndreas Gohr $this->modelInfo = $modelinfos['chat'][$name]; 77dce0dee5SAndreas Gohr } 78dce0dee5SAndreas Gohr 79dce0dee5SAndreas Gohr if ($this instanceof EmbeddingInterface) { 80dce0dee5SAndreas Gohr if (!isset($modelinfos['embedding'][$name])) { 81dce0dee5SAndreas Gohr throw new \Exception('Invalid embedding model configured: ' . $name); 82dce0dee5SAndreas Gohr } 83dce0dee5SAndreas Gohr $this->modelInfo = $modelinfos['embedding'][$name]; 84dce0dee5SAndreas Gohr } 85dce0dee5SAndreas Gohr } 86dce0dee5SAndreas Gohr 87dce0dee5SAndreas Gohr /** @inheritdoc */ 88*b446155bSAndreas Gohr public function __toString(): string 89*b446155bSAndreas Gohr { 90*b446155bSAndreas Gohr return $this->modelFullName; 91*b446155bSAndreas Gohr } 92*b446155bSAndreas Gohr 93*b446155bSAndreas Gohr 94*b446155bSAndreas Gohr /** @inheritdoc */ 95dce0dee5SAndreas Gohr public function getModelName() 96dce0dee5SAndreas Gohr { 97dce0dee5SAndreas Gohr return $this->modelName; 98dce0dee5SAndreas Gohr } 99dce0dee5SAndreas Gohr 100dce0dee5SAndreas Gohr /** 101dce0dee5SAndreas Gohr * Reset the usage statistics 102dce0dee5SAndreas Gohr * 103dce0dee5SAndreas Gohr * Usually not needed when only handling one operation per request, but useful in CLI 104dce0dee5SAndreas Gohr */ 105dce0dee5SAndreas Gohr public function resetUsageStats() 106dce0dee5SAndreas Gohr { 1072071dcedSAndreas Gohr $this->inputTokensUsed = 0; 1082071dcedSAndreas Gohr $this->outputTokensUsed = 0; 109dce0dee5SAndreas Gohr $this->timeUsed = 0; 110dce0dee5SAndreas Gohr $this->requestsMade = 0; 111dce0dee5SAndreas Gohr } 112dce0dee5SAndreas Gohr 113dce0dee5SAndreas Gohr /** 114dce0dee5SAndreas Gohr * Get the usage statistics for this instance 115dce0dee5SAndreas Gohr * 116dce0dee5SAndreas Gohr * @return string[] 117dce0dee5SAndreas Gohr */ 118dce0dee5SAndreas Gohr public function getUsageStats() 119dce0dee5SAndreas Gohr { 120dce0dee5SAndreas Gohr 121dce0dee5SAndreas Gohr $cost = 0; 122dce0dee5SAndreas Gohr $cost += $this->inputTokensUsed * $this->getInputTokenPrice(); 123dce0dee5SAndreas Gohr if ($this instanceof ChatInterface) { 124dce0dee5SAndreas Gohr $cost += $this->outputTokensUsed * $this->getOutputTokenPrice(); 125dce0dee5SAndreas Gohr } 126dce0dee5SAndreas Gohr 127dce0dee5SAndreas Gohr return [ 128dce0dee5SAndreas Gohr 'tokens' => $this->inputTokensUsed + $this->outputTokensUsed, 129c2b7a1f7SAndreas Gohr 'cost' => sprintf("%.6f", $cost / 1_000_000), 130dce0dee5SAndreas Gohr 'time' => round($this->timeUsed, 2), 131dce0dee5SAndreas Gohr 'requests' => $this->requestsMade, 132dce0dee5SAndreas Gohr ]; 133dce0dee5SAndreas Gohr } 134dce0dee5SAndreas Gohr 135dce0dee5SAndreas Gohr /** @inheritdoc */ 136dce0dee5SAndreas Gohr public function getMaxInputTokenLength(): int 137dce0dee5SAndreas Gohr { 138dce0dee5SAndreas Gohr return $this->modelInfo['inputTokens']; 139dce0dee5SAndreas Gohr } 140dce0dee5SAndreas Gohr 141dce0dee5SAndreas Gohr /** @inheritdoc */ 142dce0dee5SAndreas Gohr public function getInputTokenPrice(): float 143dce0dee5SAndreas Gohr { 144dce0dee5SAndreas Gohr return $this->modelInfo['inputTokenPrice']; 145dce0dee5SAndreas Gohr } 146dce0dee5SAndreas Gohr 147dce0dee5SAndreas Gohr // endregion 148dce0dee5SAndreas Gohr 149dce0dee5SAndreas Gohr // region EmbeddingInterface 150dce0dee5SAndreas Gohr 151dce0dee5SAndreas Gohr /** @inheritdoc */ 152dce0dee5SAndreas Gohr public function getDimensions(): int 153dce0dee5SAndreas Gohr { 154dce0dee5SAndreas Gohr return $this->modelInfo['dimensions']; 155dce0dee5SAndreas Gohr } 156dce0dee5SAndreas Gohr 157dce0dee5SAndreas Gohr // endregion 158dce0dee5SAndreas Gohr 159dce0dee5SAndreas Gohr // region ChatInterface 160dce0dee5SAndreas Gohr 161dce0dee5SAndreas Gohr public function getMaxOutputTokenLength(): int 162dce0dee5SAndreas Gohr { 163dce0dee5SAndreas Gohr return $this->modelInfo['outputTokens']; 164dce0dee5SAndreas Gohr } 165dce0dee5SAndreas Gohr 166dce0dee5SAndreas Gohr public function getOutputTokenPrice(): float 167dce0dee5SAndreas Gohr { 168dce0dee5SAndreas Gohr return $this->modelInfo['outputTokenPrice']; 169dce0dee5SAndreas Gohr } 170dce0dee5SAndreas Gohr 171dce0dee5SAndreas Gohr // endregion 172dce0dee5SAndreas Gohr 173dce0dee5SAndreas Gohr // region API communication 174f6ef2e50SAndreas Gohr 175f6ef2e50SAndreas Gohr /** 17634a1c478SAndreas Gohr * When enabled, the input/output of the API will be printed to STDOUT 17734a1c478SAndreas Gohr * 17834a1c478SAndreas Gohr * @param bool $debug 17934a1c478SAndreas Gohr */ 18034a1c478SAndreas Gohr public function setDebug($debug = true) 18134a1c478SAndreas Gohr { 18234a1c478SAndreas Gohr $this->debug = $debug; 18334a1c478SAndreas Gohr } 18434a1c478SAndreas Gohr 18534a1c478SAndreas Gohr /** 186294a9eafSAndreas Gohr * This method should check the response for any errors. If the API singalled an error, 187294a9eafSAndreas Gohr * this method should throw an Exception with a meaningful error message. 188294a9eafSAndreas Gohr * 189294a9eafSAndreas Gohr * If the response returned any info on used tokens, they should be added to $this->tokensUsed 190294a9eafSAndreas Gohr * 191294a9eafSAndreas Gohr * The method should return the parsed response, which will be passed to the calling method. 192294a9eafSAndreas Gohr * 193294a9eafSAndreas Gohr * @param mixed $response the parsed JSON response from the API 194294a9eafSAndreas Gohr * @return mixed 195294a9eafSAndreas Gohr * @throws \Exception when the response indicates an error 196294a9eafSAndreas Gohr */ 197294a9eafSAndreas Gohr abstract protected function parseAPIResponse($response); 198294a9eafSAndreas Gohr 199294a9eafSAndreas Gohr /** 200294a9eafSAndreas Gohr * Send a request to the API 201294a9eafSAndreas Gohr * 202294a9eafSAndreas Gohr * Model classes should use this method to send requests to the API. 203294a9eafSAndreas Gohr * 204294a9eafSAndreas Gohr * This method will take care of retrying and logging basic statistics. 205294a9eafSAndreas Gohr * 206294a9eafSAndreas Gohr * It is assumed that all APIs speak JSON. 207294a9eafSAndreas Gohr * 208294a9eafSAndreas Gohr * @param string $method The HTTP method to use (GET, POST, PUT, DELETE, etc.) 209294a9eafSAndreas Gohr * @param string $url The full URL to send the request to 210294a9eafSAndreas Gohr * @param array $data Payload to send, will be encoded to JSON 211294a9eafSAndreas Gohr * @param int $retry How often this request has been retried, do not set externally 212294a9eafSAndreas Gohr * @return array API response as returned by parseAPIResponse 213294a9eafSAndreas Gohr * @throws \Exception when anything goes wrong 214294a9eafSAndreas Gohr */ 215294a9eafSAndreas Gohr protected function sendAPIRequest($method, $url, $data, $retry = 0) 216294a9eafSAndreas Gohr { 217294a9eafSAndreas Gohr // init statistics 218294a9eafSAndreas Gohr if ($retry === 0) { 219294a9eafSAndreas Gohr $this->requestStart = microtime(true); 220294a9eafSAndreas Gohr } else { 221294a9eafSAndreas Gohr sleep($retry); // wait a bit between retries 222294a9eafSAndreas Gohr } 223294a9eafSAndreas Gohr $this->requestsMade++; 224294a9eafSAndreas Gohr 225294a9eafSAndreas Gohr // encode payload data 226294a9eafSAndreas Gohr try { 22734a1c478SAndreas Gohr $json = json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); 228294a9eafSAndreas Gohr } catch (\JsonException $e) { 229294a9eafSAndreas Gohr $this->timeUsed += $this->requestStart - microtime(true); 230294a9eafSAndreas Gohr throw new \Exception('Failed to encode JSON for API:' . $e->getMessage(), $e->getCode(), $e); 231294a9eafSAndreas Gohr } 232294a9eafSAndreas Gohr 23334a1c478SAndreas Gohr if ($this->debug) { 23434a1c478SAndreas Gohr echo 'Sending ' . $method . ' request to ' . $url . ' with payload:' . "\n"; 23534a1c478SAndreas Gohr print_r($json); 23651aa8517SAndreas Gohr echo "\n"; 23734a1c478SAndreas Gohr } 23834a1c478SAndreas Gohr 239294a9eafSAndreas Gohr // send request and handle retries 240294a9eafSAndreas Gohr $this->http->sendRequest($url, $json, $method); 241294a9eafSAndreas Gohr $response = $this->http->resp_body; 242294a9eafSAndreas Gohr if ($response === false || $this->http->error) { 243294a9eafSAndreas Gohr if ($retry < self::MAX_RETRIES) { 244294a9eafSAndreas Gohr return $this->sendAPIRequest($method, $url, $data, $retry + 1); 245294a9eafSAndreas Gohr } 246294a9eafSAndreas Gohr $this->timeUsed += microtime(true) - $this->requestStart; 247294a9eafSAndreas Gohr throw new \Exception('API returned no response. ' . $this->http->error); 248294a9eafSAndreas Gohr } 249294a9eafSAndreas Gohr 25034a1c478SAndreas Gohr if ($this->debug) { 25134a1c478SAndreas Gohr echo 'Received response:' . "\n"; 25234a1c478SAndreas Gohr print_r($response); 25351aa8517SAndreas Gohr echo "\n"; 25434a1c478SAndreas Gohr } 25534a1c478SAndreas Gohr 256294a9eafSAndreas Gohr // decode the response 257294a9eafSAndreas Gohr try { 258294a9eafSAndreas Gohr $result = json_decode((string)$response, true, 512, JSON_THROW_ON_ERROR); 259294a9eafSAndreas Gohr } catch (\JsonException $e) { 260294a9eafSAndreas Gohr $this->timeUsed += microtime(true) - $this->requestStart; 261294a9eafSAndreas Gohr throw new \Exception('API returned invalid JSON: ' . $response, 0, $e); 262294a9eafSAndreas Gohr } 263294a9eafSAndreas Gohr 264294a9eafSAndreas Gohr // parse the response, retry on error 265294a9eafSAndreas Gohr try { 266294a9eafSAndreas Gohr $result = $this->parseAPIResponse($result); 267294a9eafSAndreas Gohr } catch (\Exception $e) { 268294a9eafSAndreas Gohr if ($retry < self::MAX_RETRIES) { 269294a9eafSAndreas Gohr return $this->sendAPIRequest($method, $url, $data, $retry + 1); 270294a9eafSAndreas Gohr } 271294a9eafSAndreas Gohr $this->timeUsed += microtime(true) - $this->requestStart; 272294a9eafSAndreas Gohr throw $e; 273294a9eafSAndreas Gohr } 274294a9eafSAndreas Gohr 275294a9eafSAndreas Gohr $this->timeUsed += microtime(true) - $this->requestStart; 276294a9eafSAndreas Gohr return $result; 277294a9eafSAndreas Gohr } 278294a9eafSAndreas Gohr 279dce0dee5SAndreas Gohr // endregion 280f6ef2e50SAndreas Gohr} 281