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.
- mykitt-0.1.0/.gitignore +20 -0
- mykitt-0.1.0/LICENSE +21 -0
- mykitt-0.1.0/PKG-INFO +94 -0
- mykitt-0.1.0/README.md +77 -0
- mykitt-0.1.0/pyproject.toml +28 -0
- mykitt-0.1.0/src/mykitt/__init__.py +18 -0
- mykitt-0.1.0/src/mykitt/_client.py +187 -0
- mykitt-0.1.0/src/mykitt/py.typed +0 -0
- mykitt-0.1.0/tests/test_client.py +121 -0
mykitt-0.1.0/.gitignore
ADDED
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()
|