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; 24b446155bSAndreas Gohr /** @var string The full model name */ 25b446155bSAndreas Gohr protected $modelFullName; 26dce0dee5SAndreas Gohr /** @var array The model info from the model.json file */ 27dce0dee5SAndreas Gohr protected $modelInfo; 282e22aefbSAndreas Gohr /** @var string The provider name */ 292e22aefbSAndreas Gohr protected $selfIdent; 3034a1c478SAndreas Gohr 31dce0dee5SAndreas Gohr /** @var int input tokens used since last reset */ 3234a1c478SAndreas Gohr protected $inputTokensUsed = 0; 33dce0dee5SAndreas Gohr /** @var int output tokens used since last reset */ 3434a1c478SAndreas Gohr protected $outputTokensUsed = 0; 35dce0dee5SAndreas Gohr /** @var int total time spent in requests since last reset */ 36f6ef2e50SAndreas Gohr protected $timeUsed = 0; 37dce0dee5SAndreas Gohr /** @var int total number of requests made since last reset */ 38f6ef2e50SAndreas Gohr protected $requestsMade = 0; 39294a9eafSAndreas Gohr /** @var int start time of the current request chain (may be multiple when retries needed) */ 40294a9eafSAndreas Gohr protected $requestStart = 0; 41f6ef2e50SAndreas Gohr 42dce0dee5SAndreas Gohr /** @var int How often to retry a request if it fails */ 43dce0dee5SAndreas Gohr public const MAX_RETRIES = 3; 44dce0dee5SAndreas Gohr 45dce0dee5SAndreas Gohr /** @var DokuHTTPClient */ 46dce0dee5SAndreas Gohr protected $http; 47dce0dee5SAndreas Gohr /** @var bool debug API communication */ 48dce0dee5SAndreas Gohr protected $debug = false; 49*d72a84c5SAndreas Gohr /** @var string The base API URL. Configurable for some models */ 50*d72a84c5SAndreas Gohr protected $apiurl = ''; 51dce0dee5SAndreas Gohr 527c3b69cbSAndreas Gohr /** @var array The plugin configuration */ 537c3b69cbSAndreas Gohr protected $config; 547c3b69cbSAndreas Gohr 55dce0dee5SAndreas Gohr // region ModelInterface 56dce0dee5SAndreas Gohr 57dce0dee5SAndreas Gohr /** @inheritdoc */ 58dce0dee5SAndreas Gohr public function __construct(string $name, array $config) 59294a9eafSAndreas Gohr { 60dce0dee5SAndreas Gohr $this->modelName = $name; 617c3b69cbSAndreas Gohr $this->config = $config; 62dce0dee5SAndreas Gohr 63dce0dee5SAndreas Gohr $reflect = new \ReflectionClass($this); 64dce0dee5SAndreas Gohr $json = dirname($reflect->getFileName()) . '/models.json'; 65dce0dee5SAndreas Gohr if (!file_exists($json)) { 6642b2c6e8SAndreas Gohr throw new \Exception('Model info file not found at ' . $json, 2001); 67294a9eafSAndreas Gohr } 68dce0dee5SAndreas Gohr try { 69dce0dee5SAndreas Gohr $modelinfos = json_decode(file_get_contents($json), true, 512, JSON_THROW_ON_ERROR); 70dce0dee5SAndreas Gohr } catch (\JsonException $e) { 7142b2c6e8SAndreas Gohr throw new \Exception('Failed to parse model info file: ' . $e->getMessage(), 2002, $e); 72dce0dee5SAndreas Gohr } 73dce0dee5SAndreas Gohr 742e22aefbSAndreas Gohr $this->selfIdent = basename(dirname($reflect->getFileName())); 75e3b34a2bSAndreas Gohr $this->modelFullName = basename(dirname($reflect->getFileName())) . ' ' . $name; 76b446155bSAndreas Gohr 77*d72a84c5SAndreas Gohr if ($this->apiurl === '') { 78*d72a84c5SAndreas Gohr // we use an empty default here, since some models may not use this property 79*d72a84c5SAndreas Gohr $this->apiurl = $this->getFromConf('apiurl', ''); 80*d72a84c5SAndreas Gohr } 81*d72a84c5SAndreas Gohr $this->apiurl = rtrim($this->apiurl, '/'); 82*d72a84c5SAndreas Gohr 83dce0dee5SAndreas Gohr if ($this instanceof ChatInterface) { 844dd0657eSAndreas Gohr if (isset($modelinfos['chat'][$name])) { 85dce0dee5SAndreas Gohr $this->modelInfo = $modelinfos['chat'][$name]; 864dd0657eSAndreas Gohr } else { 874dd0657eSAndreas Gohr $this->modelInfo = $this->loadUnknownModelInfo(); 884dd0657eSAndreas Gohr } 894dd0657eSAndreas Gohr 90dce0dee5SAndreas Gohr } 91dce0dee5SAndreas Gohr 92dce0dee5SAndreas Gohr if ($this instanceof EmbeddingInterface) { 934dd0657eSAndreas Gohr if (isset($modelinfos['embedding'][$name])) { 94dce0dee5SAndreas Gohr $this->modelInfo = $modelinfos['embedding'][$name]; 954dd0657eSAndreas Gohr } else { 964dd0657eSAndreas Gohr $this->modelInfo = $this->loadUnknownModelInfo(); 974dd0657eSAndreas Gohr } 98dce0dee5SAndreas Gohr } 99dce0dee5SAndreas Gohr } 100dce0dee5SAndreas Gohr 101dce0dee5SAndreas Gohr /** @inheritdoc */ 102b446155bSAndreas Gohr public function __toString(): string 103b446155bSAndreas Gohr { 104b446155bSAndreas Gohr return $this->modelFullName; 105b446155bSAndreas Gohr } 106b446155bSAndreas Gohr 107b446155bSAndreas Gohr /** @inheritdoc */ 108dce0dee5SAndreas Gohr public function getModelName() 109dce0dee5SAndreas Gohr { 110dce0dee5SAndreas Gohr return $this->modelName; 111dce0dee5SAndreas Gohr } 112dce0dee5SAndreas Gohr 113dce0dee5SAndreas Gohr /** 114dce0dee5SAndreas Gohr * Reset the usage statistics 115dce0dee5SAndreas Gohr * 116dce0dee5SAndreas Gohr * Usually not needed when only handling one operation per request, but useful in CLI 117dce0dee5SAndreas Gohr */ 118dce0dee5SAndreas Gohr public function resetUsageStats() 119dce0dee5SAndreas Gohr { 1202071dcedSAndreas Gohr $this->inputTokensUsed = 0; 1212071dcedSAndreas Gohr $this->outputTokensUsed = 0; 122dce0dee5SAndreas Gohr $this->timeUsed = 0; 123dce0dee5SAndreas Gohr $this->requestsMade = 0; 124dce0dee5SAndreas Gohr } 125dce0dee5SAndreas Gohr 126dce0dee5SAndreas Gohr /** 127dce0dee5SAndreas Gohr * Get the usage statistics for this instance 128dce0dee5SAndreas Gohr * 129dce0dee5SAndreas Gohr * @return string[] 130dce0dee5SAndreas Gohr */ 131dce0dee5SAndreas Gohr public function getUsageStats() 132dce0dee5SAndreas Gohr { 133dce0dee5SAndreas Gohr 134dce0dee5SAndreas Gohr $cost = 0; 135dce0dee5SAndreas Gohr $cost += $this->inputTokensUsed * $this->getInputTokenPrice(); 136dce0dee5SAndreas Gohr if ($this instanceof ChatInterface) { 137dce0dee5SAndreas Gohr $cost += $this->outputTokensUsed * $this->getOutputTokenPrice(); 138dce0dee5SAndreas Gohr } 139dce0dee5SAndreas Gohr 140dce0dee5SAndreas Gohr return [ 141dce0dee5SAndreas Gohr 'tokens' => $this->inputTokensUsed + $this->outputTokensUsed, 142c2b7a1f7SAndreas Gohr 'cost' => sprintf("%.6f", $cost / 1_000_000), 143dce0dee5SAndreas Gohr 'time' => round($this->timeUsed, 2), 144dce0dee5SAndreas Gohr 'requests' => $this->requestsMade, 145dce0dee5SAndreas Gohr ]; 146dce0dee5SAndreas Gohr } 147dce0dee5SAndreas Gohr 148dce0dee5SAndreas Gohr /** @inheritdoc */ 149dce0dee5SAndreas Gohr public function getMaxInputTokenLength(): int 150dce0dee5SAndreas Gohr { 1517be8078eSAndreas Gohr return $this->modelInfo['inputTokens'] ?? 0; 152dce0dee5SAndreas Gohr } 153dce0dee5SAndreas Gohr 154dce0dee5SAndreas Gohr /** @inheritdoc */ 155dce0dee5SAndreas Gohr public function getInputTokenPrice(): float 156dce0dee5SAndreas Gohr { 1577be8078eSAndreas Gohr return $this->modelInfo['inputTokenPrice'] ?? 0; 158dce0dee5SAndreas Gohr } 159dce0dee5SAndreas Gohr 1604dd0657eSAndreas Gohr /** @inheritdoc */ 1614dd0657eSAndreas Gohr function loadUnknownModelInfo(): array 1624dd0657eSAndreas Gohr { 1634dd0657eSAndreas Gohr $info = [ 1644dd0657eSAndreas Gohr 'description' => $this->modelFullName, 1657be8078eSAndreas Gohr 'inputTokens' => 0, 1664dd0657eSAndreas Gohr 'inputTokenPrice' => 0, 1674dd0657eSAndreas Gohr ]; 1684dd0657eSAndreas Gohr 1694dd0657eSAndreas Gohr if ($this instanceof ChatInterface) { 1707be8078eSAndreas Gohr $info['outputTokens'] = 0; 1714dd0657eSAndreas Gohr $info['outputTokenPrice'] = 0; 1724dd0657eSAndreas Gohr } elseif ($this instanceof EmbeddingInterface) { 1734dd0657eSAndreas Gohr $info['dimensions'] = 512; 1744dd0657eSAndreas Gohr } 1754dd0657eSAndreas Gohr 1764dd0657eSAndreas Gohr return $info; 1774dd0657eSAndreas Gohr } 1784dd0657eSAndreas Gohr 179dce0dee5SAndreas Gohr // endregion 180dce0dee5SAndreas Gohr 181dce0dee5SAndreas Gohr // region EmbeddingInterface 182dce0dee5SAndreas Gohr 183dce0dee5SAndreas Gohr /** @inheritdoc */ 184dce0dee5SAndreas Gohr public function getDimensions(): int 185dce0dee5SAndreas Gohr { 186dce0dee5SAndreas Gohr return $this->modelInfo['dimensions']; 187dce0dee5SAndreas Gohr } 188dce0dee5SAndreas Gohr 189dce0dee5SAndreas Gohr // endregion 190dce0dee5SAndreas Gohr 191dce0dee5SAndreas Gohr // region ChatInterface 192dce0dee5SAndreas Gohr 193dce0dee5SAndreas Gohr public function getMaxOutputTokenLength(): int 194dce0dee5SAndreas Gohr { 195dce0dee5SAndreas Gohr return $this->modelInfo['outputTokens']; 196dce0dee5SAndreas Gohr } 197dce0dee5SAndreas Gohr 198dce0dee5SAndreas Gohr public function getOutputTokenPrice(): float 199dce0dee5SAndreas Gohr { 200dce0dee5SAndreas Gohr return $this->modelInfo['outputTokenPrice']; 201dce0dee5SAndreas Gohr } 202dce0dee5SAndreas Gohr 203dce0dee5SAndreas Gohr // endregion 204dce0dee5SAndreas Gohr 205dce0dee5SAndreas Gohr // region API communication 206f6ef2e50SAndreas Gohr 207f6ef2e50SAndreas Gohr /** 20834a1c478SAndreas Gohr * When enabled, the input/output of the API will be printed to STDOUT 20934a1c478SAndreas Gohr * 21034a1c478SAndreas Gohr * @param bool $debug 21134a1c478SAndreas Gohr */ 21234a1c478SAndreas Gohr public function setDebug($debug = true) 21334a1c478SAndreas Gohr { 21434a1c478SAndreas Gohr $this->debug = $debug; 21534a1c478SAndreas Gohr } 21634a1c478SAndreas Gohr 21734a1c478SAndreas Gohr /** 2187c3b69cbSAndreas Gohr * Get the HTTP client used for API requests 2197c3b69cbSAndreas Gohr * 2207c3b69cbSAndreas Gohr * This method will create a new DokuHTTPClient instance if it does not exist yet. 2217c3b69cbSAndreas Gohr * The client will be configured with a timeout and the appropriate headers for JSON communication. 2227c3b69cbSAndreas Gohr * Inheriting models should override this method if they need to add additional headers or configuration 2237c3b69cbSAndreas Gohr * to the HTTP client. 2247c3b69cbSAndreas Gohr * 2257c3b69cbSAndreas Gohr * @return DokuHTTPClient 2267c3b69cbSAndreas Gohr */ 2277c3b69cbSAndreas Gohr protected function getHttpClient() 2287c3b69cbSAndreas Gohr { 2297c3b69cbSAndreas Gohr if ($this->http === null) { 2307c3b69cbSAndreas Gohr $this->http = new DokuHTTPClient(); 2317c3b69cbSAndreas Gohr $this->http->timeout = 60; 2327c3b69cbSAndreas Gohr $this->http->headers['Content-Type'] = 'application/json'; 2337c3b69cbSAndreas Gohr $this->http->headers['Accept'] = 'application/json'; 2347c3b69cbSAndreas Gohr } 2357c3b69cbSAndreas Gohr 2367c3b69cbSAndreas Gohr return $this->http; 2377c3b69cbSAndreas Gohr } 2387c3b69cbSAndreas Gohr 2397c3b69cbSAndreas Gohr /** 240294a9eafSAndreas Gohr * This method should check the response for any errors. If the API singalled an error, 241294a9eafSAndreas Gohr * this method should throw an Exception with a meaningful error message. 242294a9eafSAndreas Gohr * 243294a9eafSAndreas Gohr * If the response returned any info on used tokens, they should be added to $this->tokensUsed 244294a9eafSAndreas Gohr * 245294a9eafSAndreas Gohr * The method should return the parsed response, which will be passed to the calling method. 246294a9eafSAndreas Gohr * 247294a9eafSAndreas Gohr * @param mixed $response the parsed JSON response from the API 248294a9eafSAndreas Gohr * @return mixed 249294a9eafSAndreas Gohr * @throws \Exception when the response indicates an error 250294a9eafSAndreas Gohr */ 251294a9eafSAndreas Gohr abstract protected function parseAPIResponse($response); 252294a9eafSAndreas Gohr 253294a9eafSAndreas Gohr /** 254294a9eafSAndreas Gohr * Send a request to the API 255294a9eafSAndreas Gohr * 256294a9eafSAndreas Gohr * Model classes should use this method to send requests to the API. 257294a9eafSAndreas Gohr * 258294a9eafSAndreas Gohr * This method will take care of retrying and logging basic statistics. 259294a9eafSAndreas Gohr * 260294a9eafSAndreas Gohr * It is assumed that all APIs speak JSON. 261294a9eafSAndreas Gohr * 262294a9eafSAndreas Gohr * @param string $method The HTTP method to use (GET, POST, PUT, DELETE, etc.) 263294a9eafSAndreas Gohr * @param string $url The full URL to send the request to 2644dd0657eSAndreas Gohr * @param array|string $data Payload to send, will be encoded to JSON 265294a9eafSAndreas Gohr * @param int $retry How often this request has been retried, do not set externally 266294a9eafSAndreas Gohr * @return array API response as returned by parseAPIResponse 267294a9eafSAndreas Gohr * @throws \Exception when anything goes wrong 268294a9eafSAndreas Gohr */ 269294a9eafSAndreas Gohr protected function sendAPIRequest($method, $url, $data, $retry = 0) 270294a9eafSAndreas Gohr { 271294a9eafSAndreas Gohr // init statistics 272294a9eafSAndreas Gohr if ($retry === 0) { 273294a9eafSAndreas Gohr $this->requestStart = microtime(true); 274294a9eafSAndreas Gohr } else { 275294a9eafSAndreas Gohr sleep($retry); // wait a bit between retries 276294a9eafSAndreas Gohr } 277294a9eafSAndreas Gohr $this->requestsMade++; 278294a9eafSAndreas Gohr 279294a9eafSAndreas Gohr // encode payload data 280294a9eafSAndreas Gohr try { 28134a1c478SAndreas Gohr $json = json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT); 282294a9eafSAndreas Gohr } catch (\JsonException $e) { 283294a9eafSAndreas Gohr $this->timeUsed += $this->requestStart - microtime(true); 28442b2c6e8SAndreas Gohr throw new \Exception('Failed to encode JSON for API:' . $e->getMessage(), 2003, $e); 285294a9eafSAndreas Gohr } 286294a9eafSAndreas Gohr 28734a1c478SAndreas Gohr if ($this->debug) { 28834a1c478SAndreas Gohr echo 'Sending ' . $method . ' request to ' . $url . ' with payload:' . "\n"; 28934a1c478SAndreas Gohr print_r($json); 29051aa8517SAndreas Gohr echo "\n"; 29134a1c478SAndreas Gohr } 29234a1c478SAndreas Gohr 293294a9eafSAndreas Gohr // send request and handle retries 2947c3b69cbSAndreas Gohr $http = $this->getHttpClient(); 2957c3b69cbSAndreas Gohr $http->sendRequest($url, $json, $method); 2967c3b69cbSAndreas Gohr $response = $http->resp_body; 2977c3b69cbSAndreas Gohr if ($response === false || $http->error) { 298294a9eafSAndreas Gohr if ($retry < self::MAX_RETRIES) { 299294a9eafSAndreas Gohr return $this->sendAPIRequest($method, $url, $data, $retry + 1); 300294a9eafSAndreas Gohr } 301294a9eafSAndreas Gohr $this->timeUsed += microtime(true) - $this->requestStart; 3027c3b69cbSAndreas Gohr throw new \Exception('API returned no response. ' . $http->error, 2004); 303294a9eafSAndreas Gohr } 304294a9eafSAndreas Gohr 30534a1c478SAndreas Gohr if ($this->debug) { 30634a1c478SAndreas Gohr echo 'Received response:' . "\n"; 30734a1c478SAndreas Gohr print_r($response); 30851aa8517SAndreas Gohr echo "\n"; 30934a1c478SAndreas Gohr } 31034a1c478SAndreas Gohr 311294a9eafSAndreas Gohr // decode the response 312294a9eafSAndreas Gohr try { 313294a9eafSAndreas Gohr $result = json_decode((string)$response, true, 512, JSON_THROW_ON_ERROR); 314294a9eafSAndreas Gohr } catch (\JsonException $e) { 315294a9eafSAndreas Gohr $this->timeUsed += microtime(true) - $this->requestStart; 31642b2c6e8SAndreas Gohr throw new \Exception('API returned invalid JSON: ' . $response, 2005, $e); 317294a9eafSAndreas Gohr } 318294a9eafSAndreas Gohr 319294a9eafSAndreas Gohr // parse the response, retry on error 320294a9eafSAndreas Gohr try { 321294a9eafSAndreas Gohr $result = $this->parseAPIResponse($result); 322294a9eafSAndreas Gohr } catch (\Exception $e) { 323294a9eafSAndreas Gohr if ($retry < self::MAX_RETRIES) { 324294a9eafSAndreas Gohr return $this->sendAPIRequest($method, $url, $data, $retry + 1); 325294a9eafSAndreas Gohr } 326294a9eafSAndreas Gohr $this->timeUsed += microtime(true) - $this->requestStart; 327294a9eafSAndreas Gohr throw $e; 328294a9eafSAndreas Gohr } 329294a9eafSAndreas Gohr 330294a9eafSAndreas Gohr $this->timeUsed += microtime(true) - $this->requestStart; 331294a9eafSAndreas Gohr return $result; 332294a9eafSAndreas Gohr } 333294a9eafSAndreas Gohr 334dce0dee5SAndreas Gohr // endregion 3352e22aefbSAndreas Gohr 3362e22aefbSAndreas Gohr // region Tools 3372e22aefbSAndreas Gohr 3382e22aefbSAndreas Gohr /** 3392e22aefbSAndreas Gohr * Get a configuration value 3402e22aefbSAndreas Gohr * 3412e22aefbSAndreas Gohr * The given key is prefixed by the model namespace 3422e22aefbSAndreas Gohr * 3432e22aefbSAndreas Gohr * @param string $key 3442e22aefbSAndreas Gohr * @param mixed $default The default to return if the key is not found. When set to null an Exception is thrown. 3452e22aefbSAndreas Gohr * @return mixed 3462e22aefbSAndreas Gohr * @throws ModelException when the key is not found and no default is given 3472e22aefbSAndreas Gohr */ 3487c3b69cbSAndreas Gohr public function getFromConf(string $key, $default = null) 3492e22aefbSAndreas Gohr { 3507c3b69cbSAndreas Gohr $config = $this->config; 3517c3b69cbSAndreas Gohr 3522e22aefbSAndreas Gohr $key = strtolower($this->selfIdent) . '_' . $key; 3532e22aefbSAndreas Gohr if (isset($config[$key])) { 3542e22aefbSAndreas Gohr return $config[$key]; 3552e22aefbSAndreas Gohr } 3562e22aefbSAndreas Gohr if ($default !== null) { 3572e22aefbSAndreas Gohr return $default; 3582e22aefbSAndreas Gohr } 3592e22aefbSAndreas Gohr throw new ModelException('Key ' . $key . ' not found in configuration', 3001); 3602e22aefbSAndreas Gohr } 3612e22aefbSAndreas Gohr 3622e22aefbSAndreas Gohr // endregion 363f6ef2e50SAndreas Gohr} 364