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