xref: /plugin/aichat/Model/AbstractModel.php (revision dce0dee5ef27bcbbc5570fc278f3e75f426c19c5)
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
13*dce0dee5SAndreas Gohr *
14*dce0dee5SAndreas Gohr * This class already implements most of the requirements for these interfaces.
15*dce0dee5SAndreas Gohr *
16*dce0dee5SAndreas Gohr * In addition to any missing interface methods, model implementations will need to
17*dce0dee5SAndreas Gohr * extend the constructor to handle the plugin configuration and implement the
18*dce0dee5SAndreas Gohr * parseAPIResponse() method to handle the specific API response.
19294a9eafSAndreas Gohr */
20*dce0dee5SAndreas Gohrabstract class AbstractModel implements ModelInterface
217ebc7895Ssplitbrain{
22*dce0dee5SAndreas Gohr    /** @var string The model name */
23*dce0dee5SAndreas Gohr    protected $modelName;
24*dce0dee5SAndreas Gohr    /** @var array The model info from the model.json file */
25*dce0dee5SAndreas Gohr    protected $modelInfo;
2634a1c478SAndreas Gohr
27*dce0dee5SAndreas Gohr    /** @var int input tokens used since last reset */
2834a1c478SAndreas Gohr    protected $inputTokensUsed = 0;
29*dce0dee5SAndreas Gohr    /** @var int output tokens used since last reset */
3034a1c478SAndreas Gohr    protected $outputTokensUsed = 0;
31*dce0dee5SAndreas Gohr    /** @var int total time spent in requests since last reset */
32f6ef2e50SAndreas Gohr    protected $timeUsed = 0;
33*dce0dee5SAndreas Gohr    /** @var int total number of requests made since last reset */
34f6ef2e50SAndreas Gohr    protected $requestsMade = 0;
35294a9eafSAndreas Gohr    /** @var int start time of the current request chain (may be multiple when retries needed) */
36294a9eafSAndreas Gohr    protected $requestStart = 0;
37f6ef2e50SAndreas Gohr
38*dce0dee5SAndreas Gohr    /** @var int How often to retry a request if it fails */
39*dce0dee5SAndreas Gohr    public const MAX_RETRIES = 3;
40*dce0dee5SAndreas Gohr
41*dce0dee5SAndreas Gohr    /** @var DokuHTTPClient */
42*dce0dee5SAndreas Gohr    protected $http;
43*dce0dee5SAndreas Gohr    /** @var bool debug API communication */
44*dce0dee5SAndreas Gohr    protected $debug = false;
45*dce0dee5SAndreas Gohr
46*dce0dee5SAndreas Gohr    // region ModelInterface
47*dce0dee5SAndreas Gohr
48*dce0dee5SAndreas Gohr    /** @inheritdoc */
49*dce0dee5SAndreas Gohr    public function __construct(string $name, array $config)
50294a9eafSAndreas Gohr    {
51*dce0dee5SAndreas Gohr        $this->modelName = $name;
52294a9eafSAndreas Gohr        $this->http = new DokuHTTPClient();
53294a9eafSAndreas Gohr        $this->http->timeout = 60;
54294a9eafSAndreas Gohr        $this->http->headers['Content-Type'] = 'application/json';
55*dce0dee5SAndreas Gohr
56*dce0dee5SAndreas Gohr        $reflect = new \ReflectionClass($this);
57*dce0dee5SAndreas Gohr        $json = dirname($reflect->getFileName()) . '/models.json';
58*dce0dee5SAndreas Gohr        if (!file_exists($json)) {
59*dce0dee5SAndreas Gohr            throw new \Exception('Model info file not found at ' . $json);
60294a9eafSAndreas Gohr        }
61*dce0dee5SAndreas Gohr        try {
62*dce0dee5SAndreas Gohr            $modelinfos = json_decode(file_get_contents($json), true, 512, JSON_THROW_ON_ERROR);
63*dce0dee5SAndreas Gohr        } catch (\JsonException $e) {
64*dce0dee5SAndreas Gohr            throw new \Exception('Failed to parse model info file: ' . $e->getMessage(), $e->getCode(), $e);
65*dce0dee5SAndreas Gohr        }
66*dce0dee5SAndreas Gohr
67*dce0dee5SAndreas Gohr        if ($this instanceof ChatInterface) {
68*dce0dee5SAndreas Gohr            if (!isset($modelinfos['chat'][$name])) {
69*dce0dee5SAndreas Gohr                throw new \Exception('Invalid chat model configured: ' . $name);
70*dce0dee5SAndreas Gohr            }
71*dce0dee5SAndreas Gohr            $this->modelInfo = $modelinfos['chat'][$name];
72*dce0dee5SAndreas Gohr        }
73*dce0dee5SAndreas Gohr
74*dce0dee5SAndreas Gohr        if ($this instanceof EmbeddingInterface) {
75*dce0dee5SAndreas Gohr            if (!isset($modelinfos['embedding'][$name])) {
76*dce0dee5SAndreas Gohr                throw new \Exception('Invalid embedding model configured: ' . $name);
77*dce0dee5SAndreas Gohr            }
78*dce0dee5SAndreas Gohr            $this->modelInfo = $modelinfos['embedding'][$name];
79*dce0dee5SAndreas Gohr        }
80*dce0dee5SAndreas Gohr    }
81*dce0dee5SAndreas Gohr
82*dce0dee5SAndreas Gohr    /** @inheritdoc */
83*dce0dee5SAndreas Gohr    public function getModelName()
84*dce0dee5SAndreas Gohr    {
85*dce0dee5SAndreas Gohr        return $this->modelName;
86*dce0dee5SAndreas Gohr    }
87*dce0dee5SAndreas Gohr
88*dce0dee5SAndreas Gohr    /**
89*dce0dee5SAndreas Gohr     * Reset the usage statistics
90*dce0dee5SAndreas Gohr     *
91*dce0dee5SAndreas Gohr     * Usually not needed when only handling one operation per request, but useful in CLI
92*dce0dee5SAndreas Gohr     */
93*dce0dee5SAndreas Gohr    public function resetUsageStats()
94*dce0dee5SAndreas Gohr    {
95*dce0dee5SAndreas Gohr        $this->tokensUsed = 0;
96*dce0dee5SAndreas Gohr        $this->timeUsed = 0;
97*dce0dee5SAndreas Gohr        $this->requestsMade = 0;
98*dce0dee5SAndreas Gohr    }
99*dce0dee5SAndreas Gohr
100*dce0dee5SAndreas Gohr    /**
101*dce0dee5SAndreas Gohr     * Get the usage statistics for this instance
102*dce0dee5SAndreas Gohr     *
103*dce0dee5SAndreas Gohr     * @return string[]
104*dce0dee5SAndreas Gohr     */
105*dce0dee5SAndreas Gohr    public function getUsageStats()
106*dce0dee5SAndreas Gohr    {
107*dce0dee5SAndreas Gohr
108*dce0dee5SAndreas Gohr        $cost = 0;
109*dce0dee5SAndreas Gohr        $cost += $this->inputTokensUsed * $this->getInputTokenPrice();
110*dce0dee5SAndreas Gohr        if ($this instanceof ChatInterface) {
111*dce0dee5SAndreas Gohr            $cost += $this->outputTokensUsed * $this->getOutputTokenPrice();
112*dce0dee5SAndreas Gohr        }
113*dce0dee5SAndreas Gohr
114*dce0dee5SAndreas Gohr        return [
115*dce0dee5SAndreas Gohr            'tokens' => $this->inputTokensUsed + $this->outputTokensUsed,
116*dce0dee5SAndreas Gohr            'cost' => round($cost / 1_000_000, 4),
117*dce0dee5SAndreas Gohr            'time' => round($this->timeUsed, 2),
118*dce0dee5SAndreas Gohr            'requests' => $this->requestsMade,
119*dce0dee5SAndreas Gohr        ];
120*dce0dee5SAndreas Gohr    }
121*dce0dee5SAndreas Gohr
122*dce0dee5SAndreas Gohr    /** @inheritdoc */
123*dce0dee5SAndreas Gohr    public function getMaxInputTokenLength(): int
124*dce0dee5SAndreas Gohr    {
125*dce0dee5SAndreas Gohr        return $this->modelInfo['inputTokens'];
126*dce0dee5SAndreas Gohr    }
127*dce0dee5SAndreas Gohr
128*dce0dee5SAndreas Gohr    /** @inheritdoc */
129*dce0dee5SAndreas Gohr    public function getInputTokenPrice(): float
130*dce0dee5SAndreas Gohr    {
131*dce0dee5SAndreas Gohr        return $this->modelInfo['inputTokenPrice'];
132*dce0dee5SAndreas Gohr    }
133*dce0dee5SAndreas Gohr
134*dce0dee5SAndreas Gohr    // endregion
135*dce0dee5SAndreas Gohr
136*dce0dee5SAndreas Gohr    // region EmbeddingInterface
137*dce0dee5SAndreas Gohr
138*dce0dee5SAndreas Gohr    /** @inheritdoc */
139*dce0dee5SAndreas Gohr    public function getDimensions(): int
140*dce0dee5SAndreas Gohr    {
141*dce0dee5SAndreas Gohr        return $this->modelInfo['dimensions'];
142*dce0dee5SAndreas Gohr    }
143*dce0dee5SAndreas Gohr
144*dce0dee5SAndreas Gohr    // endregion
145*dce0dee5SAndreas Gohr
146*dce0dee5SAndreas Gohr    // region ChatInterface
147*dce0dee5SAndreas Gohr
148*dce0dee5SAndreas Gohr    public function getMaxOutputTokenLength(): int
149*dce0dee5SAndreas Gohr    {
150*dce0dee5SAndreas Gohr        return $this->modelInfo['outputTokens'];
151*dce0dee5SAndreas Gohr    }
152*dce0dee5SAndreas Gohr
153*dce0dee5SAndreas Gohr    public function getOutputTokenPrice(): float
154*dce0dee5SAndreas Gohr    {
155*dce0dee5SAndreas Gohr        return $this->modelInfo['outputTokenPrice'];
156*dce0dee5SAndreas Gohr    }
157*dce0dee5SAndreas Gohr
158*dce0dee5SAndreas Gohr    // endregion
159*dce0dee5SAndreas Gohr
160*dce0dee5SAndreas Gohr    // region API communication
161f6ef2e50SAndreas Gohr
162f6ef2e50SAndreas Gohr    /**
16334a1c478SAndreas Gohr     * When enabled, the input/output of the API will be printed to STDOUT
16434a1c478SAndreas Gohr     *
16534a1c478SAndreas Gohr     * @param bool $debug
16634a1c478SAndreas Gohr     */
16734a1c478SAndreas Gohr    public function setDebug($debug = true)
16834a1c478SAndreas Gohr    {
16934a1c478SAndreas Gohr        $this->debug = $debug;
17034a1c478SAndreas Gohr    }
17134a1c478SAndreas Gohr
17234a1c478SAndreas Gohr    /**
173294a9eafSAndreas Gohr     * This method should check the response for any errors. If the API singalled an error,
174294a9eafSAndreas Gohr     * this method should throw an Exception with a meaningful error message.
175294a9eafSAndreas Gohr     *
176294a9eafSAndreas Gohr     * If the response returned any info on used tokens, they should be added to $this->tokensUsed
177294a9eafSAndreas Gohr     *
178294a9eafSAndreas Gohr     * The method should return the parsed response, which will be passed to the calling method.
179294a9eafSAndreas Gohr     *
180294a9eafSAndreas Gohr     * @param mixed $response the parsed JSON response from the API
181294a9eafSAndreas Gohr     * @return mixed
182294a9eafSAndreas Gohr     * @throws \Exception when the response indicates an error
183294a9eafSAndreas Gohr     */
184294a9eafSAndreas Gohr    abstract protected function parseAPIResponse($response);
185294a9eafSAndreas Gohr
186294a9eafSAndreas Gohr    /**
187294a9eafSAndreas Gohr     * Send a request to the API
188294a9eafSAndreas Gohr     *
189294a9eafSAndreas Gohr     * Model classes should use this method to send requests to the API.
190294a9eafSAndreas Gohr     *
191294a9eafSAndreas Gohr     * This method will take care of retrying and logging basic statistics.
192294a9eafSAndreas Gohr     *
193294a9eafSAndreas Gohr     * It is assumed that all APIs speak JSON.
194294a9eafSAndreas Gohr     *
195294a9eafSAndreas Gohr     * @param string $method The HTTP method to use (GET, POST, PUT, DELETE, etc.)
196294a9eafSAndreas Gohr     * @param string $url The full URL to send the request to
197294a9eafSAndreas Gohr     * @param array $data Payload to send, will be encoded to JSON
198294a9eafSAndreas Gohr     * @param int $retry How often this request has been retried, do not set externally
199294a9eafSAndreas Gohr     * @return array API response as returned by parseAPIResponse
200294a9eafSAndreas Gohr     * @throws \Exception when anything goes wrong
201294a9eafSAndreas Gohr     */
202294a9eafSAndreas Gohr    protected function sendAPIRequest($method, $url, $data, $retry = 0)
203294a9eafSAndreas Gohr    {
204294a9eafSAndreas Gohr        // init statistics
205294a9eafSAndreas Gohr        if ($retry === 0) {
206294a9eafSAndreas Gohr            $this->requestStart = microtime(true);
207294a9eafSAndreas Gohr        } else {
208294a9eafSAndreas Gohr            sleep($retry); // wait a bit between retries
209294a9eafSAndreas Gohr        }
210294a9eafSAndreas Gohr        $this->requestsMade++;
211294a9eafSAndreas Gohr
212294a9eafSAndreas Gohr        // encode payload data
213294a9eafSAndreas Gohr        try {
21434a1c478SAndreas Gohr            $json = json_encode($data, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT);
215294a9eafSAndreas Gohr        } catch (\JsonException $e) {
216294a9eafSAndreas Gohr            $this->timeUsed += $this->requestStart - microtime(true);
217294a9eafSAndreas Gohr            throw new \Exception('Failed to encode JSON for API:' . $e->getMessage(), $e->getCode(), $e);
218294a9eafSAndreas Gohr        }
219294a9eafSAndreas Gohr
22034a1c478SAndreas Gohr        if ($this->debug) {
22134a1c478SAndreas Gohr            echo 'Sending ' . $method . ' request to ' . $url . ' with payload:' . "\n";
22234a1c478SAndreas Gohr            print_r($json);
22334a1c478SAndreas Gohr        }
22434a1c478SAndreas Gohr
225294a9eafSAndreas Gohr        // send request and handle retries
226294a9eafSAndreas Gohr        $this->http->sendRequest($url, $json, $method);
227294a9eafSAndreas Gohr        $response = $this->http->resp_body;
228294a9eafSAndreas Gohr        if ($response === false || $this->http->error) {
229294a9eafSAndreas Gohr            if ($retry < self::MAX_RETRIES) {
230294a9eafSAndreas Gohr                return $this->sendAPIRequest($method, $url, $data, $retry + 1);
231294a9eafSAndreas Gohr            }
232294a9eafSAndreas Gohr            $this->timeUsed += microtime(true) - $this->requestStart;
233294a9eafSAndreas Gohr            throw new \Exception('API returned no response. ' . $this->http->error);
234294a9eafSAndreas Gohr        }
235294a9eafSAndreas Gohr
23634a1c478SAndreas Gohr        if ($this->debug) {
23734a1c478SAndreas Gohr            echo 'Received response:' . "\n";
23834a1c478SAndreas Gohr            print_r($response);
23934a1c478SAndreas Gohr        }
24034a1c478SAndreas Gohr
241294a9eafSAndreas Gohr        // decode the response
242294a9eafSAndreas Gohr        try {
243294a9eafSAndreas Gohr            $result = json_decode((string)$response, true, 512, JSON_THROW_ON_ERROR);
244294a9eafSAndreas Gohr        } catch (\JsonException $e) {
245294a9eafSAndreas Gohr            $this->timeUsed += microtime(true) - $this->requestStart;
246294a9eafSAndreas Gohr            throw new \Exception('API returned invalid JSON: ' . $response, 0, $e);
247294a9eafSAndreas Gohr        }
248294a9eafSAndreas Gohr
249294a9eafSAndreas Gohr        // parse the response, retry on error
250294a9eafSAndreas Gohr        try {
251294a9eafSAndreas Gohr            $result = $this->parseAPIResponse($result);
252294a9eafSAndreas Gohr        } catch (\Exception $e) {
253294a9eafSAndreas Gohr            if ($retry < self::MAX_RETRIES) {
254294a9eafSAndreas Gohr                return $this->sendAPIRequest($method, $url, $data, $retry + 1);
255294a9eafSAndreas Gohr            }
256294a9eafSAndreas Gohr            $this->timeUsed += microtime(true) - $this->requestStart;
257294a9eafSAndreas Gohr            throw $e;
258294a9eafSAndreas Gohr        }
259294a9eafSAndreas Gohr
260294a9eafSAndreas Gohr        $this->timeUsed += microtime(true) - $this->requestStart;
261294a9eafSAndreas Gohr        return $result;
262294a9eafSAndreas Gohr    }
263294a9eafSAndreas Gohr
264*dce0dee5SAndreas Gohr    // endregion
265f6ef2e50SAndreas Gohr}
266