locaith 1.0.0__py3-none-any.whl
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.
- locaith/__init__.py +33 -0
- locaith/_client.py +185 -0
- locaith/_errors.py +63 -0
- locaith/_resources.py +199 -0
- locaith/_types.py +143 -0
- locaith/_version.py +1 -0
- locaith/py.typed +0 -0
- locaith-1.0.0.dist-info/METADATA +100 -0
- locaith-1.0.0.dist-info/RECORD +11 -0
- locaith-1.0.0.dist-info/WHEEL +4 -0
- locaith-1.0.0.dist-info/licenses/LICENSE +9 -0
locaith/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from ._client import AsyncLocaith, Locaith
|
|
2
|
+
from ._errors import (
|
|
3
|
+
APIConnectionError,
|
|
4
|
+
APIStatusError,
|
|
5
|
+
APITimeoutError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
BadRequestError,
|
|
8
|
+
InternalServerError,
|
|
9
|
+
LocaithError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
RateLimitError,
|
|
12
|
+
)
|
|
13
|
+
from ._types import AsyncStream, ChatCompletion, ChatCompletionChunk, Stream
|
|
14
|
+
from ._version import __version__
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"Locaith",
|
|
18
|
+
"AsyncLocaith",
|
|
19
|
+
"ChatCompletion",
|
|
20
|
+
"ChatCompletionChunk",
|
|
21
|
+
"Stream",
|
|
22
|
+
"AsyncStream",
|
|
23
|
+
"LocaithError",
|
|
24
|
+
"APIConnectionError",
|
|
25
|
+
"APITimeoutError",
|
|
26
|
+
"APIStatusError",
|
|
27
|
+
"BadRequestError",
|
|
28
|
+
"AuthenticationError",
|
|
29
|
+
"NotFoundError",
|
|
30
|
+
"RateLimitError",
|
|
31
|
+
"InternalServerError",
|
|
32
|
+
"__version__",
|
|
33
|
+
]
|
locaith/_client.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, Mapping, Optional, Union
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._errors import APIConnectionError, APITimeoutError, LocaithError, make_status_error
|
|
11
|
+
from ._resources import AsyncChat, AsyncMemory, AsyncModels, Chat, Memory, Models
|
|
12
|
+
from ._version import __version__
|
|
13
|
+
|
|
14
|
+
DEFAULT_BASE_URL = "https://memory-api.locaith.com/v1"
|
|
15
|
+
DEFAULT_TIMEOUT = 900.0
|
|
16
|
+
DEFAULT_MAX_RETRIES = 2
|
|
17
|
+
_RETRY_STATUS = {502, 503, 504}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _BaseClient:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
api_key: Optional[str],
|
|
25
|
+
base_url: Optional[str],
|
|
26
|
+
timeout: Union[float, httpx.Timeout],
|
|
27
|
+
max_retries: int,
|
|
28
|
+
user: Optional[str],
|
|
29
|
+
default_headers: Optional[Mapping[str, str]],
|
|
30
|
+
) -> None:
|
|
31
|
+
api_key = api_key or os.environ.get("LOCAITH_API_KEY")
|
|
32
|
+
if not api_key:
|
|
33
|
+
raise LocaithError(
|
|
34
|
+
"Thiếu API key. Truyền Locaith(api_key=...) hoặc đặt biến môi trường LOCAITH_API_KEY."
|
|
35
|
+
)
|
|
36
|
+
self.api_key = api_key
|
|
37
|
+
self.base_url = (base_url or os.environ.get("LOCAITH_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
38
|
+
self.timeout = timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(timeout, connect=10.0)
|
|
39
|
+
self.max_retries = max_retries
|
|
40
|
+
self.user = user
|
|
41
|
+
self._headers: Dict[str, str] = {
|
|
42
|
+
"Authorization": f"Bearer {api_key}",
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
"Accept": "application/json",
|
|
45
|
+
"User-Agent": f"locaith-python/{__version__}",
|
|
46
|
+
**(default_headers or {}),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
def _url(self, path: str) -> str:
|
|
50
|
+
return self.base_url + path
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _delay(attempt: int) -> float:
|
|
54
|
+
return min(0.5 * (2**attempt), 8.0)
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _body(response: httpx.Response) -> Any:
|
|
58
|
+
try:
|
|
59
|
+
return response.json()
|
|
60
|
+
except ValueError:
|
|
61
|
+
return {"detail": response.text[:500]}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Locaith(_BaseClient):
|
|
65
|
+
"""Client đồng bộ.
|
|
66
|
+
|
|
67
|
+
from locaith import Locaith
|
|
68
|
+
client = Locaith() # đọc LOCAITH_API_KEY
|
|
69
|
+
r = client.chat.completions.create(messages=[{"role": "user", "content": "Xin chào"}])
|
|
70
|
+
print(r.choices[0].message.content)
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
api_key: Optional[str] = None,
|
|
76
|
+
*,
|
|
77
|
+
base_url: Optional[str] = None,
|
|
78
|
+
timeout: Union[float, httpx.Timeout] = DEFAULT_TIMEOUT,
|
|
79
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
80
|
+
user: Optional[str] = None,
|
|
81
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
82
|
+
http_client: Optional[httpx.Client] = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
super().__init__(api_key=api_key, base_url=base_url, timeout=timeout, max_retries=max_retries, user=user,
|
|
85
|
+
default_headers=default_headers)
|
|
86
|
+
self._http = http_client or httpx.Client(timeout=self.timeout)
|
|
87
|
+
self.chat = Chat(self)
|
|
88
|
+
self.models = Models(self)
|
|
89
|
+
self.memory = Memory(self)
|
|
90
|
+
|
|
91
|
+
def _send(self, method: str, path: str, *, json: Any = None, params: Optional[dict] = None,
|
|
92
|
+
stream: bool = False) -> httpx.Response:
|
|
93
|
+
req = self._http.build_request(method, self._url(path), json=json, params=params,
|
|
94
|
+
headers=self._headers, timeout=self.timeout)
|
|
95
|
+
attempt = 0
|
|
96
|
+
while True:
|
|
97
|
+
try:
|
|
98
|
+
resp = self._http.send(req, stream=stream)
|
|
99
|
+
except httpx.TimeoutException as e:
|
|
100
|
+
if attempt >= self.max_retries:
|
|
101
|
+
raise APITimeoutError("Quá thời gian chờ phản hồi.") from e
|
|
102
|
+
except httpx.TransportError as e:
|
|
103
|
+
if attempt >= self.max_retries:
|
|
104
|
+
raise APIConnectionError(f"Không kết nối được: {type(e).__name__}") from e
|
|
105
|
+
else:
|
|
106
|
+
if resp.status_code in _RETRY_STATUS and attempt < self.max_retries:
|
|
107
|
+
resp.close()
|
|
108
|
+
elif resp.status_code >= 400:
|
|
109
|
+
if stream:
|
|
110
|
+
resp.read()
|
|
111
|
+
err = make_status_error(resp.status_code, self._body(resp))
|
|
112
|
+
resp.close()
|
|
113
|
+
raise err
|
|
114
|
+
else:
|
|
115
|
+
return resp
|
|
116
|
+
time.sleep(self._delay(attempt))
|
|
117
|
+
attempt += 1
|
|
118
|
+
|
|
119
|
+
def close(self) -> None:
|
|
120
|
+
self._http.close()
|
|
121
|
+
|
|
122
|
+
def __enter__(self) -> "Locaith":
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def __exit__(self, *a: Any) -> None:
|
|
126
|
+
self.close()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class AsyncLocaith(_BaseClient):
|
|
130
|
+
"""Client bất đồng bộ (asyncio), cùng giao diện với Locaith."""
|
|
131
|
+
|
|
132
|
+
def __init__(
|
|
133
|
+
self,
|
|
134
|
+
api_key: Optional[str] = None,
|
|
135
|
+
*,
|
|
136
|
+
base_url: Optional[str] = None,
|
|
137
|
+
timeout: Union[float, httpx.Timeout] = DEFAULT_TIMEOUT,
|
|
138
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
139
|
+
user: Optional[str] = None,
|
|
140
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
141
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
142
|
+
) -> None:
|
|
143
|
+
super().__init__(api_key=api_key, base_url=base_url, timeout=timeout, max_retries=max_retries, user=user,
|
|
144
|
+
default_headers=default_headers)
|
|
145
|
+
self._http = http_client or httpx.AsyncClient(timeout=self.timeout)
|
|
146
|
+
self.chat = AsyncChat(self)
|
|
147
|
+
self.models = AsyncModels(self)
|
|
148
|
+
self.memory = AsyncMemory(self)
|
|
149
|
+
|
|
150
|
+
async def _send(self, method: str, path: str, *, json: Any = None, params: Optional[dict] = None,
|
|
151
|
+
stream: bool = False) -> httpx.Response:
|
|
152
|
+
req = self._http.build_request(method, self._url(path), json=json, params=params,
|
|
153
|
+
headers=self._headers, timeout=self.timeout)
|
|
154
|
+
attempt = 0
|
|
155
|
+
while True:
|
|
156
|
+
try:
|
|
157
|
+
resp = await self._http.send(req, stream=stream)
|
|
158
|
+
except httpx.TimeoutException as e:
|
|
159
|
+
if attempt >= self.max_retries:
|
|
160
|
+
raise APITimeoutError("Quá thời gian chờ phản hồi.") from e
|
|
161
|
+
except httpx.TransportError as e:
|
|
162
|
+
if attempt >= self.max_retries:
|
|
163
|
+
raise APIConnectionError(f"Không kết nối được: {type(e).__name__}") from e
|
|
164
|
+
else:
|
|
165
|
+
if resp.status_code in _RETRY_STATUS and attempt < self.max_retries:
|
|
166
|
+
await resp.aclose()
|
|
167
|
+
elif resp.status_code >= 400:
|
|
168
|
+
if stream:
|
|
169
|
+
await resp.aread()
|
|
170
|
+
err = make_status_error(resp.status_code, self._body(resp))
|
|
171
|
+
await resp.aclose()
|
|
172
|
+
raise err
|
|
173
|
+
else:
|
|
174
|
+
return resp
|
|
175
|
+
await asyncio.sleep(self._delay(attempt))
|
|
176
|
+
attempt += 1
|
|
177
|
+
|
|
178
|
+
async def close(self) -> None:
|
|
179
|
+
await self._http.aclose()
|
|
180
|
+
|
|
181
|
+
async def __aenter__(self) -> "AsyncLocaith":
|
|
182
|
+
return self
|
|
183
|
+
|
|
184
|
+
async def __aexit__(self, *a: Any) -> None:
|
|
185
|
+
await self.close()
|
locaith/_errors.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LocaithError(Exception):
|
|
7
|
+
"""Lỗi gốc của SDK."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APIConnectionError(LocaithError):
|
|
11
|
+
"""Không kết nối được tới máy chủ."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APITimeoutError(APIConnectionError):
|
|
15
|
+
"""Quá thời gian chờ."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class APIStatusError(LocaithError):
|
|
19
|
+
"""Máy chủ trả mã lỗi HTTP."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str, *, status_code: int, body: Any = None) -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
self.message = message
|
|
24
|
+
self.status_code = status_code
|
|
25
|
+
self.body = body
|
|
26
|
+
|
|
27
|
+
def __str__(self) -> str:
|
|
28
|
+
return f"Error code: {self.status_code} - {self.message}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BadRequestError(APIStatusError):
|
|
32
|
+
"""400"""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AuthenticationError(APIStatusError):
|
|
36
|
+
"""401: sai hoặc thiếu API key"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NotFoundError(APIStatusError):
|
|
40
|
+
"""404"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RateLimitError(APIStatusError):
|
|
44
|
+
"""429"""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class InternalServerError(APIStatusError):
|
|
48
|
+
"""5xx"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def make_status_error(status: int, body: Any) -> APIStatusError:
|
|
52
|
+
msg: Optional[str] = None
|
|
53
|
+
if isinstance(body, dict):
|
|
54
|
+
err = body.get("error")
|
|
55
|
+
if isinstance(err, dict):
|
|
56
|
+
msg = err.get("message")
|
|
57
|
+
elif isinstance(body.get("detail"), str):
|
|
58
|
+
msg = body["detail"]
|
|
59
|
+
msg = msg or f"HTTP {status}"
|
|
60
|
+
cls = {400: BadRequestError, 401: AuthenticationError, 404: NotFoundError, 429: RateLimitError}.get(status)
|
|
61
|
+
if cls is None:
|
|
62
|
+
cls = InternalServerError if status >= 500 else APIStatusError
|
|
63
|
+
return cls(msg, status_code=status, body=body)
|
locaith/_resources.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Union
|
|
4
|
+
|
|
5
|
+
from ._types import AsyncStream, AttrDict, ChatCompletion, Stream
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ._client import AsyncLocaith, Locaith
|
|
9
|
+
|
|
10
|
+
DEFAULT_MODEL = "Locaith-4-flash"
|
|
11
|
+
MEMORY_TYPES = ("user", "feedback", "project", "reference")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _chat_body(c: Any, *, messages: Union[str, Iterable[Dict[str, Any]]], model: str, stream: bool,
|
|
15
|
+
web_search: bool, memory: bool, user: Optional[str], extra_body: Optional[Dict[str, Any]],
|
|
16
|
+
params: Dict[str, Any]) -> Dict[str, Any]:
|
|
17
|
+
if isinstance(messages, str):
|
|
18
|
+
messages = [{"role": "user", "content": messages}]
|
|
19
|
+
body: Dict[str, Any] = {"model": model, "messages": list(messages), "web_search": web_search, "memory": memory}
|
|
20
|
+
u = user or c.user
|
|
21
|
+
if u:
|
|
22
|
+
body["user"] = u
|
|
23
|
+
body.update({k: v for k, v in params.items() if v is not None})
|
|
24
|
+
if stream:
|
|
25
|
+
body["stream"] = True
|
|
26
|
+
if extra_body:
|
|
27
|
+
body.update(extra_body)
|
|
28
|
+
return body
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _qs(c: Any, user: Optional[str]) -> Dict[str, str]:
|
|
32
|
+
u = user or c.user
|
|
33
|
+
return {"user": u} if u else {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _check_type(type: str) -> None:
|
|
37
|
+
if type not in MEMORY_TYPES:
|
|
38
|
+
raise ValueError(f"type phải là một trong {MEMORY_TYPES}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ------------------------------------------------------------------ đồng bộ
|
|
42
|
+
class Completions:
|
|
43
|
+
def __init__(self, client: "Locaith") -> None:
|
|
44
|
+
self._c = client
|
|
45
|
+
|
|
46
|
+
def create(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
messages: Union[str, Iterable[Dict[str, Any]]],
|
|
50
|
+
model: str = DEFAULT_MODEL,
|
|
51
|
+
stream: bool = False,
|
|
52
|
+
web_search: bool = True,
|
|
53
|
+
memory: bool = False,
|
|
54
|
+
user: Optional[str] = None,
|
|
55
|
+
temperature: Optional[float] = None,
|
|
56
|
+
top_p: Optional[float] = None,
|
|
57
|
+
max_tokens: Optional[int] = None,
|
|
58
|
+
stop: Union[str, List[str], None] = None,
|
|
59
|
+
seed: Optional[int] = None,
|
|
60
|
+
presence_penalty: Optional[float] = None,
|
|
61
|
+
frequency_penalty: Optional[float] = None,
|
|
62
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
63
|
+
tool_choice: Union[str, Dict[str, Any], None] = None,
|
|
64
|
+
response_format: Optional[Dict[str, Any]] = None,
|
|
65
|
+
reasoning_effort: Optional[str] = None,
|
|
66
|
+
extra_body: Optional[Dict[str, Any]] = None,
|
|
67
|
+
) -> Union[ChatCompletion, Stream]:
|
|
68
|
+
"""Tạo câu trả lời. Luôn suy luận. web_search=True (mặc định) tự tìm web;
|
|
69
|
+
memory=True dùng Bio-Memory (ký ức dài hạn + điểm lưu khi hết ngữ cảnh).
|
|
70
|
+
stream=True trả về Stream."""
|
|
71
|
+
params = dict(temperature=temperature, top_p=top_p, max_tokens=max_tokens, stop=stop, seed=seed,
|
|
72
|
+
presence_penalty=presence_penalty, frequency_penalty=frequency_penalty, tools=tools,
|
|
73
|
+
tool_choice=tool_choice, response_format=response_format, reasoning_effort=reasoning_effort)
|
|
74
|
+
body = _chat_body(self._c, messages=messages, model=model, stream=stream, web_search=web_search,
|
|
75
|
+
memory=memory, user=user, extra_body=extra_body, params=params)
|
|
76
|
+
if stream:
|
|
77
|
+
return Stream(self._c._send("POST", "/chat/completions", json=body, stream=True))
|
|
78
|
+
return ChatCompletion(self._c._send("POST", "/chat/completions", json=body).json())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Chat:
|
|
82
|
+
def __init__(self, client: "Locaith") -> None:
|
|
83
|
+
self.completions = Completions(client)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Models:
|
|
87
|
+
def __init__(self, client: "Locaith") -> None:
|
|
88
|
+
self._c = client
|
|
89
|
+
|
|
90
|
+
def list(self) -> List[AttrDict]:
|
|
91
|
+
return [AttrDict(m) for m in self._c._send("GET", "/models").json()["data"]]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Memory:
|
|
95
|
+
"""Bio-Memory: ký ức dài hạn, mỗi API key (và mỗi user) một kho riêng."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, client: "Locaith") -> None:
|
|
98
|
+
self._c = client
|
|
99
|
+
|
|
100
|
+
def list(self, *, user: Optional[str] = None) -> List[AttrDict]:
|
|
101
|
+
return [AttrDict(m) for m in self._c._send("GET", "/memory", params=_qs(self._c, user)).json()["items"]]
|
|
102
|
+
|
|
103
|
+
def add(self, title: str, content: str, *, type: str = "project", user: Optional[str] = None) -> str:
|
|
104
|
+
"""type: user | feedback | project | reference. Trả về 'đã lưu' / 'trùng' / 'bị từ chối'."""
|
|
105
|
+
_check_type(type)
|
|
106
|
+
body = {"title": title, "content": content, "type": type, "user": user or self._c.user}
|
|
107
|
+
return self._c._send("POST", "/memory", json=body).json()["result"]
|
|
108
|
+
|
|
109
|
+
def delete(self, memory_id: str, *, user: Optional[str] = None) -> None:
|
|
110
|
+
self._c._send("DELETE", f"/memory/{memory_id}", params=_qs(self._c, user))
|
|
111
|
+
|
|
112
|
+
def export(self, path: Optional[str] = None, *, format: str = "zip", user: Optional[str] = None) -> Any:
|
|
113
|
+
"""Xuất ký ức ra file. format: zip (mỗi ký ức một .md + MEMORY.md) | md | json.
|
|
114
|
+
Có path thì ghi file và trả về path; không có thì trả về bytes (json trả về dict)."""
|
|
115
|
+
r = self._c._send("GET", "/memory/export", params={"format": format, **_qs(self._c, user)})
|
|
116
|
+
if format == "json" and path is None:
|
|
117
|
+
return r.json()
|
|
118
|
+
if path is None:
|
|
119
|
+
return r.content
|
|
120
|
+
with open(path, "wb") as f:
|
|
121
|
+
f.write(r.content)
|
|
122
|
+
return path
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ------------------------------------------------------------------ bất đồng bộ
|
|
126
|
+
class AsyncCompletions:
|
|
127
|
+
def __init__(self, client: "AsyncLocaith") -> None:
|
|
128
|
+
self._c = client
|
|
129
|
+
|
|
130
|
+
async def create(
|
|
131
|
+
self,
|
|
132
|
+
*,
|
|
133
|
+
messages: Union[str, Iterable[Dict[str, Any]]],
|
|
134
|
+
model: str = DEFAULT_MODEL,
|
|
135
|
+
stream: bool = False,
|
|
136
|
+
web_search: bool = True,
|
|
137
|
+
memory: bool = False,
|
|
138
|
+
user: Optional[str] = None,
|
|
139
|
+
temperature: Optional[float] = None,
|
|
140
|
+
top_p: Optional[float] = None,
|
|
141
|
+
max_tokens: Optional[int] = None,
|
|
142
|
+
stop: Union[str, List[str], None] = None,
|
|
143
|
+
seed: Optional[int] = None,
|
|
144
|
+
presence_penalty: Optional[float] = None,
|
|
145
|
+
frequency_penalty: Optional[float] = None,
|
|
146
|
+
tools: Optional[List[Dict[str, Any]]] = None,
|
|
147
|
+
tool_choice: Union[str, Dict[str, Any], None] = None,
|
|
148
|
+
response_format: Optional[Dict[str, Any]] = None,
|
|
149
|
+
reasoning_effort: Optional[str] = None,
|
|
150
|
+
extra_body: Optional[Dict[str, Any]] = None,
|
|
151
|
+
) -> Union[ChatCompletion, AsyncStream]:
|
|
152
|
+
params = dict(temperature=temperature, top_p=top_p, max_tokens=max_tokens, stop=stop, seed=seed,
|
|
153
|
+
presence_penalty=presence_penalty, frequency_penalty=frequency_penalty, tools=tools,
|
|
154
|
+
tool_choice=tool_choice, response_format=response_format, reasoning_effort=reasoning_effort)
|
|
155
|
+
body = _chat_body(self._c, messages=messages, model=model, stream=stream, web_search=web_search,
|
|
156
|
+
memory=memory, user=user, extra_body=extra_body, params=params)
|
|
157
|
+
if stream:
|
|
158
|
+
return AsyncStream(await self._c._send("POST", "/chat/completions", json=body, stream=True))
|
|
159
|
+
return ChatCompletion((await self._c._send("POST", "/chat/completions", json=body)).json())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class AsyncChat:
|
|
163
|
+
def __init__(self, client: "AsyncLocaith") -> None:
|
|
164
|
+
self.completions = AsyncCompletions(client)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class AsyncModels:
|
|
168
|
+
def __init__(self, client: "AsyncLocaith") -> None:
|
|
169
|
+
self._c = client
|
|
170
|
+
|
|
171
|
+
async def list(self) -> List[AttrDict]:
|
|
172
|
+
return [AttrDict(m) for m in (await self._c._send("GET", "/models")).json()["data"]]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class AsyncMemory:
|
|
176
|
+
def __init__(self, client: "AsyncLocaith") -> None:
|
|
177
|
+
self._c = client
|
|
178
|
+
|
|
179
|
+
async def list(self, *, user: Optional[str] = None) -> List[AttrDict]:
|
|
180
|
+
r = await self._c._send("GET", "/memory", params=_qs(self._c, user))
|
|
181
|
+
return [AttrDict(m) for m in r.json()["items"]]
|
|
182
|
+
|
|
183
|
+
async def add(self, title: str, content: str, *, type: str = "project", user: Optional[str] = None) -> str:
|
|
184
|
+
_check_type(type)
|
|
185
|
+
body = {"title": title, "content": content, "type": type, "user": user or self._c.user}
|
|
186
|
+
return (await self._c._send("POST", "/memory", json=body)).json()["result"]
|
|
187
|
+
|
|
188
|
+
async def delete(self, memory_id: str, *, user: Optional[str] = None) -> None:
|
|
189
|
+
await self._c._send("DELETE", f"/memory/{memory_id}", params=_qs(self._c, user))
|
|
190
|
+
|
|
191
|
+
async def export(self, path: Optional[str] = None, *, format: str = "zip", user: Optional[str] = None) -> Any:
|
|
192
|
+
r = await self._c._send("GET", "/memory/export", params={"format": format, **_qs(self._c, user)})
|
|
193
|
+
if format == "json" and path is None:
|
|
194
|
+
return r.json()
|
|
195
|
+
if path is None:
|
|
196
|
+
return r.content
|
|
197
|
+
with open(path, "wb") as f:
|
|
198
|
+
f.write(r.content)
|
|
199
|
+
return path
|
locaith/_types.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional
|
|
5
|
+
|
|
6
|
+
from ._errors import APIStatusError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _wrap(v: Any) -> Any:
|
|
10
|
+
if isinstance(v, dict) and not isinstance(v, AttrDict):
|
|
11
|
+
return AttrDict(v)
|
|
12
|
+
if isinstance(v, list):
|
|
13
|
+
return [_wrap(i) for i in v]
|
|
14
|
+
return v
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AttrDict(dict):
|
|
18
|
+
"""dict truy cập được bằng thuộc tính, giống đối tượng phản hồi của openai:
|
|
19
|
+
resp.choices[0].message.content. Trường không có trả về None."""
|
|
20
|
+
|
|
21
|
+
def __getattr__(self, name: str) -> Any:
|
|
22
|
+
if name.startswith("__"):
|
|
23
|
+
raise AttributeError(name)
|
|
24
|
+
return _wrap(self.get(name))
|
|
25
|
+
|
|
26
|
+
def __getitem__(self, key: str) -> Any:
|
|
27
|
+
return _wrap(super().__getitem__(key))
|
|
28
|
+
|
|
29
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
30
|
+
return dict(self)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ChatCompletion(AttrDict):
|
|
34
|
+
"""Phản hồi chat. Ngoài chuẩn OpenAI còn có .sources (nguồn web), .memory_hits, .checkpoint."""
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def text(self) -> str:
|
|
38
|
+
c = self.get("choices") or []
|
|
39
|
+
return ((c[0].get("message") or {}).get("content") or "") if c else ""
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def reasoning(self) -> str:
|
|
43
|
+
c = self.get("choices") or []
|
|
44
|
+
return ((c[0].get("message") or {}).get("reasoning") or "") if c else ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ChatCompletionChunk(AttrDict):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _StreamState:
|
|
52
|
+
def __init__(self) -> None:
|
|
53
|
+
self.sources: List[AttrDict] = []
|
|
54
|
+
self.memory_hits: int = 0
|
|
55
|
+
self.checkpoint: bool = False
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def parse_sse_line(line: str, state: _StreamState) -> Optional[ChatCompletionChunk]:
|
|
59
|
+
line = line.strip()
|
|
60
|
+
if not line.startswith("data:"):
|
|
61
|
+
return None
|
|
62
|
+
data = line[5:].strip()
|
|
63
|
+
if not data or data == "[DONE]":
|
|
64
|
+
return None
|
|
65
|
+
d = json.loads(data)
|
|
66
|
+
if "error" in d and not d.get("choices"):
|
|
67
|
+
raise APIStatusError(str((d["error"] or {}).get("message", "stream error")), status_code=500, body=d)
|
|
68
|
+
if not d.get("choices"): # chunk đầu: nguồn web, số ký ức nhớ lại, điểm lưu
|
|
69
|
+
state.sources = [AttrDict(s) for s in d.get("sources") or []]
|
|
70
|
+
state.memory_hits = int(d.get("memory_hits") or 0)
|
|
71
|
+
state.checkpoint = bool(d.get("checkpoint"))
|
|
72
|
+
return None
|
|
73
|
+
return ChatCompletionChunk(d)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class _StreamBase:
|
|
77
|
+
sources = property(lambda self: self._state.sources)
|
|
78
|
+
memory_hits = property(lambda self: self._state.memory_hits)
|
|
79
|
+
checkpoint = property(lambda self: self._state.checkpoint)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Stream(_StreamBase):
|
|
83
|
+
"""Luồng chunk. for chunk in stream: ... hoặc for t in stream.text(): ...
|
|
84
|
+
Sau chunk đầu, stream.sources / .memory_hits / .checkpoint có giá trị."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, response: Any) -> None:
|
|
87
|
+
self._r = response
|
|
88
|
+
self._state = _StreamState()
|
|
89
|
+
|
|
90
|
+
def __iter__(self) -> Iterator[ChatCompletionChunk]:
|
|
91
|
+
try:
|
|
92
|
+
for line in self._r.iter_lines():
|
|
93
|
+
ch = parse_sse_line(line, self._state)
|
|
94
|
+
if ch is not None:
|
|
95
|
+
yield ch
|
|
96
|
+
finally:
|
|
97
|
+
self.close()
|
|
98
|
+
|
|
99
|
+
def text(self) -> Iterator[str]:
|
|
100
|
+
"""Chỉ các đoạn chữ của câu trả lời."""
|
|
101
|
+
for ch in self:
|
|
102
|
+
t = ch.choices[0].delta.content
|
|
103
|
+
if t:
|
|
104
|
+
yield t
|
|
105
|
+
|
|
106
|
+
def close(self) -> None:
|
|
107
|
+
self._r.close()
|
|
108
|
+
|
|
109
|
+
def __enter__(self) -> "Stream":
|
|
110
|
+
return self
|
|
111
|
+
|
|
112
|
+
def __exit__(self, *a: Any) -> None:
|
|
113
|
+
self.close()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class AsyncStream(_StreamBase):
|
|
117
|
+
def __init__(self, response: Any) -> None:
|
|
118
|
+
self._r = response
|
|
119
|
+
self._state = _StreamState()
|
|
120
|
+
|
|
121
|
+
async def __aiter__(self) -> AsyncIterator[ChatCompletionChunk]:
|
|
122
|
+
try:
|
|
123
|
+
async for line in self._r.aiter_lines():
|
|
124
|
+
ch = parse_sse_line(line, self._state)
|
|
125
|
+
if ch is not None:
|
|
126
|
+
yield ch
|
|
127
|
+
finally:
|
|
128
|
+
await self.close()
|
|
129
|
+
|
|
130
|
+
async def text(self) -> AsyncIterator[str]:
|
|
131
|
+
async for ch in self:
|
|
132
|
+
t = ch.choices[0].delta.content
|
|
133
|
+
if t:
|
|
134
|
+
yield t
|
|
135
|
+
|
|
136
|
+
async def close(self) -> None:
|
|
137
|
+
await self._r.aclose()
|
|
138
|
+
|
|
139
|
+
async def __aenter__(self) -> "AsyncStream":
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
async def __aexit__(self, *a: Any) -> None:
|
|
143
|
+
await self.close()
|
locaith/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
locaith/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: locaith
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python SDK cho Locaith-4-flash: tìm web, suy luận, ngữ cảnh 64K và Bio-Memory.
|
|
5
|
+
Project-URL: Homepage, https://locaith.com
|
|
6
|
+
Author: Locaith
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: ai,llm,locaith,openai-compatible,sdk,vietnamese
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Natural Language :: Vietnamese
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Requires-Dist: httpx<1,>=0.24
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# locaith
|
|
22
|
+
|
|
23
|
+
Python SDK cho **Locaith-4-flash**: tìm web, suy luận, ngữ cảnh 64K, Bio-Memory.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install locaith
|
|
27
|
+
export LOCAITH_API_KEY="LocAI..."
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from locaith import Locaith
|
|
32
|
+
|
|
33
|
+
client = Locaith()
|
|
34
|
+
|
|
35
|
+
r = client.chat.completions.create(
|
|
36
|
+
messages=[{"role": "user", "content": "Giá vàng SJC hôm nay?"}],
|
|
37
|
+
)
|
|
38
|
+
print(r.choices[0].message.content)
|
|
39
|
+
print([s.url for s in r.sources])
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Streaming:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
stream = client.chat.completions.create(
|
|
46
|
+
messages=[{"role": "user", "content": "Viết bài 500 chữ về AI tại Việt Nam"}],
|
|
47
|
+
stream=True,
|
|
48
|
+
)
|
|
49
|
+
for text in stream.text():
|
|
50
|
+
print(text, end="", flush=True)
|
|
51
|
+
print(stream.sources)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Bio-Memory:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
client = Locaith(user="khach_123")
|
|
58
|
+
|
|
59
|
+
client.chat.completions.create(
|
|
60
|
+
messages=[{"role": "user", "content": "Tôi là kế toán trưởng công ty ABC."}],
|
|
61
|
+
memory=True,
|
|
62
|
+
)
|
|
63
|
+
client.chat.completions.create(
|
|
64
|
+
messages=[{"role": "user", "content": "Bạn nhớ tôi làm nghề gì?"}],
|
|
65
|
+
memory=True,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
client.memory.list()
|
|
69
|
+
client.memory.add("Quy ước báo cáo", "Đơn vị triệu đồng.", type="feedback")
|
|
70
|
+
client.memory.export("ky-uc.zip")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Async:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
import asyncio
|
|
77
|
+
from locaith import AsyncLocaith
|
|
78
|
+
|
|
79
|
+
async def main():
|
|
80
|
+
async with AsyncLocaith() as client:
|
|
81
|
+
r = await client.chat.completions.create(messages="Xin chào")
|
|
82
|
+
print(r.text)
|
|
83
|
+
|
|
84
|
+
asyncio.run(main())
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Lỗi:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
import locaith
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
client.chat.completions.create(messages="Xin chào")
|
|
94
|
+
except locaith.AuthenticationError as e:
|
|
95
|
+
print(e.status_code, e.message)
|
|
96
|
+
except locaith.APIConnectionError:
|
|
97
|
+
...
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Tương thích OpenAI: có thể dùng thẳng SDK `openai` với `base_url="https://memory-api.locaith.com/v1"`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
locaith/__init__.py,sha256=qqgykytuj6wQ-RRZLHZfpy5ZO9r7Q5PuSueip-SKR6E,734
|
|
2
|
+
locaith/_client.py,sha256=jqYIw9JoELaYKzUu7t-mejg1FVXXbG8owNI7mZwF6k0,7062
|
|
3
|
+
locaith/_errors.py,sha256=bHY68V-S292P0Ee5ghvGL1iAxvVM5Cc_tKqKEpzgD_U,1615
|
|
4
|
+
locaith/_resources.py,sha256=j4HltzIX1cJSn61c0vC4HD2Vd0QQ85K5fJ6mnAuPYNA,8715
|
|
5
|
+
locaith/_types.py,sha256=AQ2mJaPuYhcte15XHvkwBqu5ght-oI2ZqiOQ_QOzbug,4330
|
|
6
|
+
locaith/_version.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
|
|
7
|
+
locaith/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
locaith-1.0.0.dist-info/METADATA,sha256=RiRIM7DQkv4oBFoHsoJI7hPiQkeNsZOe40356gTA2nM,2475
|
|
9
|
+
locaith-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
locaith-1.0.0.dist-info/licenses/LICENSE,sha256=z83f5q00e6uNKQaJaLX2ExOKzKSu8Wdfq_YUoQFileU,1064
|
|
11
|
+
locaith-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Locaith
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|