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