xref: /plugin/aichat/Model/AbstractModel.php (revision 294a9eaf76b94a3f99dceca7f1750a7898de3dd9)
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 */
14abstract class AbstractModel
15{
16    /** @var int total tokens used by this instance */
17    protected $tokensUsed = 0;
18    /** @var int total time spent in requests by this instance */
19    protected $timeUsed = 0;
20    /** @var int total number of requests made by this instance */
21    protected $requestsMade = 0;
22    /** @var int How often to retry a request if it fails */
23    public const MAX_RETRIES = 3;
24    /** @var DokuHTTPClient */
25    protected $http;
26    /** @var int start time of the current request chain (may be multiple when retries needed) */
27    protected $requestStart = 0;
28
29    /**
30     * This initializes a HTTP client
31     *
32     * Implementors should override this and authenticate the client.
33     *
34     * @param array $config The plugin configuration
35     */
36    public function __construct()
37    {
38        $this->http = new DokuHTTPClient();
39        $this->http->timeout = 60;
40        $this->http->headers['Content-Type'] = 'application/json';
41    }
42
43    /**
44     * The name as used by the LLM provider
45     *
46     * @return string
47     */
48    abstract public function getModelName();
49
50    /**
51     * Get the price for 1000 tokens
52     *
53     * @return float
54     */
55    abstract public function get1kTokenPrice();
56
57
58    /**
59     * This method should check the response for any errors. If the API singalled an error,
60     * this method should throw an Exception with a meaningful error message.
61     *
62     * If the response returned any info on used tokens, they should be added to $this->tokensUsed
63     *
64     * The method should return the parsed response, which will be passed to the calling method.
65     *
66     * @param mixed $response the parsed JSON response from the API
67     * @return mixed
68     * @throws \Exception when the response indicates an error
69     */
70    abstract protected function parseAPIResponse($response);
71
72    /**
73     * Send a request to the API
74     *
75     * Model classes should use this method to send requests to the API.
76     *
77     * This method will take care of retrying and logging basic statistics.
78     *
79     * It is assumed that all APIs speak JSON.
80     *
81     * @param string $method The HTTP method to use (GET, POST, PUT, DELETE, etc.)
82     * @param string $url The full URL to send the request to
83     * @param array $data Payload to send, will be encoded to JSON
84     * @param int $retry How often this request has been retried, do not set externally
85     * @return array API response as returned by parseAPIResponse
86     * @throws \Exception when anything goes wrong
87     */
88    protected function sendAPIRequest($method, $url, $data, $retry = 0)
89    {
90        // init statistics
91        if ($retry === 0) {
92            $this->requestStart = microtime(true);
93        } else {
94            sleep($retry); // wait a bit between retries
95        }
96        $this->requestsMade++;
97
98        // encode payload data
99        try {
100            $json = json_encode($data, JSON_THROW_ON_ERROR);
101        } catch (\JsonException $e) {
102            $this->timeUsed += $this->requestStart - microtime(true);
103            throw new \Exception('Failed to encode JSON for API:' . $e->getMessage(), $e->getCode(), $e);
104        }
105
106        // send request and handle retries
107        $this->http->sendRequest($url, $json, $method);
108        $response = $this->http->resp_body;
109        if ($response === false || $this->http->error) {
110            if ($retry < self::MAX_RETRIES) {
111                return $this->sendAPIRequest($method, $url, $data, $retry + 1);
112            }
113            $this->timeUsed += microtime(true) - $this->requestStart;
114            throw new \Exception('API returned no response. ' . $this->http->error);
115        }
116
117        // decode the response
118        try {
119            $result = json_decode((string)$response, true, 512, JSON_THROW_ON_ERROR);
120        } catch (\JsonException $e) {
121            $this->timeUsed += microtime(true) - $this->requestStart;
122            throw new \Exception('API returned invalid JSON: ' . $response, 0, $e);
123        }
124
125        // parse the response, retry on error
126        try {
127            $result = $this->parseAPIResponse($result);
128        } catch (\Exception $e) {
129            if ($retry < self::MAX_RETRIES) {
130                return $this->sendAPIRequest($method, $url, $data, $retry + 1);
131            }
132            $this->timeUsed += microtime(true) - $this->requestStart;
133            throw $e;
134        }
135
136        $this->timeUsed += microtime(true) - $this->requestStart;
137        return $result;
138    }
139
140    /**
141     * Reset the usage statistics
142     *
143     * Usually not needed when only handling one operation per request, but useful in CLI
144     */
145    public function resetUsageStats()
146    {
147        $this->tokensUsed = 0;
148        $this->timeUsed = 0;
149        $this->requestsMade = 0;
150    }
151
152    /**
153     * Get the usage statistics for this instance
154     *
155     * @return string[]
156     */
157    public function getUsageStats()
158    {
159        return [
160            'tokens' => $this->tokensUsed,
161            'cost' => round($this->tokensUsed * $this->get1kTokenPrice() / 1000, 4), // FIXME handle float precision
162            'time' => round($this->timeUsed, 2),
163            'requests' => $this->requestsMade,
164        ];
165    }
166}
167