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