xref: /plugin/aichat/Model/AbstractModel.php (revision 7775eee7bc2214cbd8bc6b5c95ff60d44e95c8c2)
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