mykitt 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ node_modules
2
+ dist
3
+ dist-ssr
4
+ *.local
5
+ .DS_Store
6
+ .vite
7
+ *.log
8
+
9
+ # Local secrets
10
+ .env
11
+ .env.local
12
+ .env.*.local
13
+
14
+ # Test results
15
+ test-results
16
+ playwright-report
17
+
18
+ # Python
19
+ __pycache__
20
+ *.egg-info
mykitt-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PT Aplikasi Naratifs Indonesia
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
mykitt-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,94 @@
1
+ Metadata-Version: 2.5
2
+ Name: mykitt
3
+ Version: 0.1.0
4
+ Summary: Kitt's own library for Kitt Platform: Kitt 1 Mini, Kitt 1 and Kitt Auto from Python.
5
+ Project-URL: Homepage, https://platform.mykitt.id
6
+ Project-URL: Documentation, https://platform.mykitt.id/#docs
7
+ Project-URL: Source, https://github.com/Naratifs/My-Kitt/tree/main/sdk/python
8
+ Author-email: PT Aplikasi Naratifs Indonesia <corporate@naratifs.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai,chat completions,kitt,llm,mykitt,naratifs
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+
18
+ # mykitt
19
+
20
+ Kitt's own library for [Kitt Platform](https://platform.mykitt.id): Kitt 1 Mini, Kitt 1 and Kitt Auto from Python.
21
+ It uses only Python's own library (3.8+).
22
+
23
+ ```sh
24
+ pip install mykitt
25
+ ```
26
+
27
+ ```python
28
+ from mykitt import Kitt
29
+
30
+ kitt = Kitt() # reads KITT_API_KEY
31
+
32
+ answer = kitt.chat.completions.create(
33
+ model="kitt-1-mini",
34
+ messages=[{"role": "user", "content": "Halo, Kitt!"}],
35
+ )
36
+ print(answer.choices[0].message.content)
37
+ ```
38
+
39
+ Make a key under API Keys at [platform.mykitt.id](https://platform.mykitt.id) and keep it on your server, never in a
40
+ web page or a phone app.
41
+
42
+ ## Streaming
43
+
44
+ ```python
45
+ stream = kitt.chat.completions.create(
46
+ model="kitt-1-mini",
47
+ messages=[{"role": "user", "content": "Tell me about Naratifs."}],
48
+ stream=True,
49
+ )
50
+ for chunk in stream:
51
+ if chunk.choices:
52
+ print(chunk.choices[0].delta.get("content") or "", end="")
53
+ ```
54
+
55
+ The last chunk carries the usage. `stream.close()` stops it.
56
+
57
+ ## Models
58
+
59
+ ```python
60
+ for model in kitt.models.list().data:
61
+ print(model.id, model.name)
62
+ ```
63
+
64
+ | Model | ID | What it is for |
65
+ | ----------- | ------------- | -------------------------------------------------------------------- |
66
+ | Kitt 1 Mini | `kitt-1-mini` | Does everything Kitt does, quickly and at the lowest price. |
67
+ | Kitt 1 | `kitt-1` | Reasons more for heavy work; an answer takes about 20 times the credits. |
68
+ | Kitt Auto | `kitt-auto` | Picks the model for each request; the answer names the one it ran on. |
69
+
70
+ Prices are listed by `models.list()` and on [platform.mykitt.id](https://platform.mykitt.id), taken from your Kitt credits.
71
+
72
+ ## Errors
73
+
74
+ A failed call raises `KittError` with `status`, `code` (such as `invalid_api_key` or `insufficient_credits`) and
75
+ `request_id`, to quote when you write to us. A 429 or 5xx is tried again twice, waiting longer each time
76
+ (`max_retries`).
77
+
78
+ ```python
79
+ from mykitt import Kitt, KittError
80
+
81
+ try:
82
+ kitt.chat.completions.create(model="kitt-1-mini", messages=messages)
83
+ except KittError as e:
84
+ print(e.status, e.code, e.request_id, e.message)
85
+ ```
86
+
87
+ ## Options
88
+
89
+ `Kitt(api_key=None, base_url=None, timeout=600, max_retries=2, headers=None)`. The base URL is
90
+ `https://api.mykitt.id/v1`. Answers read as attributes or as dicts (`answer.to_dict()`).
91
+
92
+ Already using another library that speaks Chat Completions? Point it at the same base URL with your Kitt key.
93
+
94
+ MIT licence, © PT Aplikasi Naratifs Indonesia.
mykitt-0.1.0/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # mykitt
2
+
3
+ Kitt's own library for [Kitt Platform](https://platform.mykitt.id): Kitt 1 Mini, Kitt 1 and Kitt Auto from Python.
4
+ It uses only Python's own library (3.8+).
5
+
6
+ ```sh
7
+ pip install mykitt
8
+ ```
9
+
10
+ ```python
11
+ from mykitt import Kitt
12
+
13
+ kitt = Kitt() # reads KITT_API_KEY
14
+
15
+ answer = kitt.chat.completions.create(
16
+ model="kitt-1-mini",
17
+ messages=[{"role": "user", "content": "Halo, Kitt!"}],
18
+ )
19
+ print(answer.choices[0].message.content)
20
+ ```
21
+
22
+ Make a key under API Keys at [platform.mykitt.id](https://platform.mykitt.id) and keep it on your server, never in a
23
+ web page or a phone app.
24
+
25
+ ## Streaming
26
+
27
+ ```python
28
+ stream = kitt.chat.completions.create(
29
+ model="kitt-1-mini",
30
+ messages=[{"role": "user", "content": "Tell me about Naratifs."}],
31
+ stream=True,
32
+ )
33
+ for chunk in stream:
34
+ if chunk.choices:
35
+ print(chunk.choices[0].delta.get("content") or "", end="")
36
+ ```
37
+
38
+ The last chunk carries the usage. `stream.close()` stops it.
39
+
40
+ ## Models
41
+
42
+ ```python
43
+ for model in kitt.models.list().data:
44
+ print(model.id, model.name)
45
+ ```
46
+
47
+ | Model | ID | What it is for |
48
+ | ----------- | ------------- | -------------------------------------------------------------------- |
49
+ | Kitt 1 Mini | `kitt-1-mini` | Does everything Kitt does, quickly and at the lowest price. |
50
+ | Kitt 1 | `kitt-1` | Reasons more for heavy work; an answer takes about 20 times the credits. |
51
+ | Kitt Auto | `kitt-auto` | Picks the model for each request; the answer names the one it ran on. |
52
+
53
+ Prices are listed by `models.list()` and on [platform.mykitt.id](https://platform.mykitt.id), taken from your Kitt credits.
54
+
55
+ ## Errors
56
+
57
+ A failed call raises `KittError` with `status`, `code` (such as `invalid_api_key` or `insufficient_credits`) and
58
+ `request_id`, to quote when you write to us. A 429 or 5xx is tried again twice, waiting longer each time
59
+ (`max_retries`).
60
+
61
+ ```python
62
+ from mykitt import Kitt, KittError
63
+
64
+ try:
65
+ kitt.chat.completions.create(model="kitt-1-mini", messages=messages)
66
+ except KittError as e:
67
+ print(e.status, e.code, e.request_id, e.message)
68
+ ```
69
+
70
+ ## Options
71
+
72
+ `Kitt(api_key=None, base_url=None, timeout=600, max_retries=2, headers=None)`. The base URL is
73
+ `https://api.mykitt.id/v1`. Answers read as attributes or as dicts (`answer.to_dict()`).
74
+
75
+ Already using another library that speaks Chat Completions? Point it at the same base URL with your Kitt key.
76
+
77
+ MIT licence, © PT Aplikasi Naratifs Indonesia.
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mykitt"
7
+ version = "0.1.0"
8
+ description = "Kitt's own library for Kitt Platform: Kitt 1 Mini, Kitt 1 and Kitt Auto from Python."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.8"
13
+ authors = [{ name = "PT Aplikasi Naratifs Indonesia", email = "corporate@naratifs.com" }]
14
+ keywords = ["kitt", "mykitt", "naratifs", "ai", "llm", "chat completions"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ "Intended Audience :: Developers",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Homepage = "https://platform.mykitt.id"
24
+ Documentation = "https://platform.mykitt.id/#docs"
25
+ Source = "https://github.com/Naratifs/My-Kitt/tree/main/sdk/python"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/mykitt"]
@@ -0,0 +1,18 @@
1
+ """mykitt: Kitt's own library for Kitt Platform (https://platform.mykitt.id).
2
+
3
+ from mykitt import Kitt
4
+
5
+ kitt = Kitt() # reads KITT_API_KEY
6
+ answer = kitt.chat.completions.create(
7
+ model="kitt-1-mini",
8
+ messages=[{"role": "user", "content": "Halo, Kitt!"}],
9
+ )
10
+ print(answer.choices[0].message.content)
11
+
12
+ Only Python's own library is used.
13
+ """
14
+
15
+ from ._client import DEFAULT_BASE_URL, Kitt, KittError, Stream, KittObject
16
+
17
+ __version__ = "0.1.0"
18
+ __all__ = ["Kitt", "KittError", "Stream", "KittObject", "DEFAULT_BASE_URL", "__version__"]
@@ -0,0 +1,187 @@
1
+ """The client: Chat Completions and the models, over Python's own urllib."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import time
8
+ import urllib.error
9
+ import urllib.request
10
+ from typing import Any, Dict, Iterator, List, Optional
11
+
12
+ DEFAULT_BASE_URL = "https://api.mykitt.id/v1"
13
+ _VERSION = "0.1.0"
14
+
15
+
16
+ class KittObject(dict):
17
+ """An answer from the API: read it as attributes (answer.choices[0].message.content) or as a dict."""
18
+
19
+ def __getattr__(self, name: str) -> Any:
20
+ try:
21
+ return self[name]
22
+ except KeyError:
23
+ raise AttributeError(name) from None
24
+
25
+ def to_dict(self) -> Dict[str, Any]:
26
+ return json.loads(json.dumps(self))
27
+
28
+
29
+ def _wrap(value: Any) -> Any:
30
+ if isinstance(value, dict):
31
+ return KittObject({k: _wrap(v) for k, v in value.items()})
32
+ if isinstance(value, list):
33
+ return [_wrap(v) for v in value]
34
+ return value
35
+
36
+
37
+ class KittError(Exception):
38
+ """What the API said went wrong, with its status, code and the request's id to quote."""
39
+
40
+ def __init__(self, status: int, message: str, code: str, request_id: Optional[str]) -> None:
41
+ super().__init__(message)
42
+ self.status = status
43
+ self.code = code
44
+ self.request_id = request_id
45
+ self.message = message
46
+
47
+
48
+ def _error_of(status: int, text: str, request_id: Optional[str]) -> KittError:
49
+ message = text or f"Kitt's API answered {status}."
50
+ code = "api_error"
51
+ try:
52
+ said = json.loads(text)
53
+ error = said.get("error") if isinstance(said, dict) else None
54
+ if isinstance(error, str):
55
+ message = error
56
+ elif isinstance(error, dict):
57
+ message = error.get("message") or message
58
+ code = error.get("code") or error.get("type") or code
59
+ except ValueError:
60
+ pass
61
+ return KittError(status, message, code, request_id)
62
+
63
+
64
+ class Stream:
65
+ """A streamed answer: read it with a for loop, or stop it with close()."""
66
+
67
+ def __init__(self, response: Any) -> None:
68
+ self._response = response
69
+ self.request_id: Optional[str] = response.headers.get("x-request-id")
70
+ # The model that answers: for kitt-auto, the one it picked.
71
+ self.model: Optional[str] = response.headers.get("x-kitt-model")
72
+
73
+ def __iter__(self) -> Iterator[KittObject]:
74
+ try:
75
+ for raw in self._response:
76
+ line = raw.decode("utf-8").rstrip("\r\n")
77
+ if not line.startswith("data:"):
78
+ continue
79
+ event = line[5:].strip()
80
+ if event == "[DONE]":
81
+ return
82
+ yield _wrap(json.loads(event))
83
+ finally:
84
+ self.close()
85
+
86
+ def close(self) -> None:
87
+ self._response.close()
88
+
89
+ def __enter__(self) -> "Stream":
90
+ return self
91
+
92
+ def __exit__(self, *_: Any) -> None:
93
+ self.close()
94
+
95
+
96
+ class Kitt:
97
+ """Kitt Platform's client.
98
+
99
+ api_key: your key from platform.mykitt.id; read from KITT_API_KEY when left out.
100
+ timeout: seconds before a request gives up (default 600).
101
+ max_retries: tries after a 429 or a 5xx, waiting longer each time (default 2).
102
+ """
103
+
104
+ def __init__(
105
+ self,
106
+ api_key: Optional[str] = None,
107
+ base_url: Optional[str] = None,
108
+ timeout: float = 600.0,
109
+ max_retries: int = 2,
110
+ headers: Optional[Dict[str, str]] = None,
111
+ ) -> None:
112
+ key = api_key or os.environ.get("KITT_API_KEY")
113
+ if not key:
114
+ raise KittError(401, "No API key: pass api_key or set KITT_API_KEY. Make one at https://platform.mykitt.id.", "invalid_api_key", None)
115
+ self._key = key
116
+ self.base_url = (base_url or os.environ.get("KITT_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
117
+ self.timeout = timeout
118
+ self.max_retries = max_retries
119
+ self._headers = headers or {}
120
+ self.chat = _Chat(self)
121
+ self.models = _Models(self)
122
+
123
+ def _request(self, method: str, path: str, body: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None) -> Any:
124
+ """One call to the API, tried again after a 429 or a 5xx; answers the open response once it is ok."""
125
+ data = None if body is None else json.dumps(body).encode("utf-8")
126
+ attempt = 0
127
+ while True:
128
+ request = urllib.request.Request(
129
+ self.base_url + path,
130
+ data=data,
131
+ method=method,
132
+ headers={
133
+ "Authorization": f"Bearer {self._key}",
134
+ "Content-Type": "application/json",
135
+ "User-Agent": f"mykitt-python/{_VERSION}",
136
+ **self._headers,
137
+ },
138
+ )
139
+ try:
140
+ return urllib.request.urlopen(request, timeout=timeout or self.timeout)
141
+ except urllib.error.HTTPError as e:
142
+ if (e.code == 429 or e.code >= 500) and attempt < self.max_retries:
143
+ after = e.headers.get("retry-after")
144
+ e.close()
145
+ time.sleep(min(float(after), 30.0) if after and after.replace(".", "", 1).isdigit() and float(after) > 0 else 0.5 * 2**attempt)
146
+ attempt += 1
147
+ continue
148
+ text = e.read().decode("utf-8", "replace")
149
+ raise _error_of(e.code, text, e.headers.get("x-request-id")) from None
150
+ except (urllib.error.URLError, TimeoutError, OSError) as e:
151
+ if attempt < self.max_retries:
152
+ time.sleep(0.5 * 2**attempt)
153
+ attempt += 1
154
+ continue
155
+ raise KittError(0, f"Kitt's API could not be reached: {e}", "connection_error", None) from None
156
+
157
+
158
+ class _Completions:
159
+ def __init__(self, kitt: Kitt) -> None:
160
+ self._kitt = kitt
161
+
162
+ def create(self, *, model: str, messages: List[Dict[str, Any]], stream: bool = False, timeout: Optional[float] = None, **fields: Any) -> Any:
163
+ """Ask a Kitt model. With stream=True, a Stream of chunks, its usage on the last one."""
164
+ body: Dict[str, Any] = {"model": model, "messages": messages, **fields}
165
+ if stream:
166
+ body["stream"] = True
167
+ body.setdefault("stream_options", {"include_usage": True})
168
+ response = self._kitt._request("POST", "/chat/completions", body, timeout)
169
+ if stream:
170
+ return Stream(response)
171
+ with response:
172
+ return _wrap(json.loads(response.read().decode("utf-8")))
173
+
174
+
175
+ class _Chat:
176
+ def __init__(self, kitt: Kitt) -> None:
177
+ self.completions = _Completions(kitt)
178
+
179
+
180
+ class _Models:
181
+ def __init__(self, kitt: Kitt) -> None:
182
+ self._kitt = kitt
183
+
184
+ def list(self) -> KittObject:
185
+ """Kitt's models, with what each is for and its price."""
186
+ with self._kitt._request("GET", "/models") as response:
187
+ return _wrap(json.loads(response.read().decode("utf-8")))
File without changes
@@ -0,0 +1,121 @@
1
+ """The library against a stand-in for Kitt's API on this machine."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+ import threading
7
+ import unittest
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+
10
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
11
+
12
+ from mykitt import Kitt, KittError # noqa: E402
13
+
14
+ SEEN = []
15
+ BUSY = {"left": 1}
16
+
17
+
18
+ class FakeApi(BaseHTTPRequestHandler):
19
+ def log_message(self, *_):
20
+ pass
21
+
22
+ def send(self, status, body, kind="application/json", extra=None):
23
+ self.send_response(status)
24
+ self.send_header("Content-Type", kind)
25
+ self.send_header("x-request-id", "req_test")
26
+ for k, v in (extra or {}).items():
27
+ self.send_header(k, v)
28
+ self.end_headers()
29
+ self.wfile.write(body.encode("utf-8"))
30
+
31
+ def do_GET(self):
32
+ SEEN.append({"path": self.path, "auth": self.headers.get("Authorization")})
33
+ self.send(200, json.dumps({"object": "list", "data": [{"id": "kitt-1-mini", "object": "model", "name": "Kitt 1 Mini"}]}))
34
+
35
+ def do_POST(self):
36
+ ask = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
37
+ SEEN.append({"path": self.path, "auth": self.headers.get("Authorization"), "body": ask})
38
+ if self.headers.get("Authorization") != "Bearer sk-kitt-good":
39
+ return self.send(401, json.dumps({"error": {"message": "The key is missing, wrong or revoked.", "type": "invalid_api_key", "code": "invalid_api_key"}}))
40
+ if ask["messages"][0]["content"] == "busy" and BUSY["left"] > 0:
41
+ BUSY["left"] -= 1
42
+ return self.send(503, '{"error":{"message":"busy","code":"upstream_error"}}', extra={"retry-after": "0"})
43
+ if ask.get("stream"):
44
+ chunks = [
45
+ {"id": "c1", "object": "chat.completion.chunk", "model": "kitt-1-mini", "choices": [{"index": 0, "delta": {"content": w}, "finish_reason": None}]}
46
+ for w in ("Ha", "lo")
47
+ ]
48
+ chunks.append({"id": "c1", "object": "chat.completion.chunk", "model": "kitt-1-mini", "choices": [], "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5}})
49
+ body = "".join(f"data: {json.dumps(c)}\n\n" for c in chunks) + "data: [DONE]\n\n"
50
+ return self.send(200, body, "text/event-stream", {"x-kitt-model": "kitt-1-mini"})
51
+ model = "kitt-1-mini" if ask["model"] == "kitt-auto" else ask["model"]
52
+ self.send(200, json.dumps({
53
+ "id": "c1", "object": "chat.completion", "created": 1, "model": model,
54
+ "choices": [{"index": 0, "message": {"role": "assistant", "content": "Halo!"}, "finish_reason": "stop"}],
55
+ "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
56
+ }))
57
+
58
+
59
+ class ClientTest(unittest.TestCase):
60
+ @classmethod
61
+ def setUpClass(cls):
62
+ cls.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeApi)
63
+ threading.Thread(target=cls.server.serve_forever, daemon=True).start()
64
+ cls.base = f"http://127.0.0.1:{cls.server.server_address[1]}/v1"
65
+
66
+ @classmethod
67
+ def tearDownClass(cls):
68
+ cls.server.shutdown()
69
+
70
+ def kitt(self, key="sk-kitt-good"):
71
+ return Kitt(api_key=key, base_url=self.base)
72
+
73
+ def test_answers_a_message(self):
74
+ answer = self.kitt().chat.completions.create(model="kitt-auto", messages=[{"role": "user", "content": "Halo, Kitt!"}])
75
+ self.assertEqual(answer.choices[0].message.content, "Halo!")
76
+ self.assertEqual(answer["model"], "kitt-1-mini")
77
+ self.assertEqual(SEEN[-1]["path"], "/v1/chat/completions")
78
+ self.assertEqual(SEEN[-1]["auth"], "Bearer sk-kitt-good")
79
+
80
+ def test_streams_an_answer_with_its_usage(self):
81
+ stream = self.kitt().chat.completions.create(model="kitt-1-mini", messages=[{"role": "user", "content": "Halo"}], stream=True)
82
+ self.assertEqual(stream.model, "kitt-1-mini")
83
+ text, usage = "", None
84
+ for chunk in stream:
85
+ if chunk.choices:
86
+ text += chunk.choices[0].delta.get("content", "")
87
+ if chunk.get("usage"):
88
+ usage = chunk.usage
89
+ self.assertEqual(text, "Halo")
90
+ self.assertEqual(usage.total_tokens, 5)
91
+ self.assertEqual(SEEN[-1]["body"]["stream_options"], {"include_usage": True})
92
+
93
+ def test_lists_the_models(self):
94
+ self.assertEqual(self.kitt().models.list().data[0].id, "kitt-1-mini")
95
+
96
+ def test_says_what_went_wrong(self):
97
+ with self.assertRaises(KittError) as caught:
98
+ self.kitt("sk-kitt-bad").chat.completions.create(model="kitt-1-mini", messages=[{"role": "user", "content": "x"}])
99
+ self.assertEqual(caught.exception.status, 401)
100
+ self.assertEqual(caught.exception.code, "invalid_api_key")
101
+ self.assertEqual(caught.exception.request_id, "req_test")
102
+
103
+ def test_tries_again_when_busy(self):
104
+ answer = self.kitt().chat.completions.create(model="kitt-1-mini", messages=[{"role": "user", "content": "busy"}])
105
+ self.assertEqual(answer.choices[0].message.content, "Halo!")
106
+
107
+ def test_needs_a_key(self):
108
+ kept = os.environ.pop("KITT_API_KEY", None)
109
+ try:
110
+ with self.assertRaises(KittError):
111
+ Kitt(base_url=self.base)
112
+ os.environ["KITT_API_KEY"] = "sk-kitt-good"
113
+ Kitt(base_url=self.base)
114
+ finally:
115
+ os.environ.pop("KITT_API_KEY", None)
116
+ if kept is not None:
117
+ os.environ["KITT_API_KEY"] = kept
118
+
119
+
120
+ if __name__ == "__main__":
121
+ unittest.main()