llmf 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.
- llmf-0.1.0/LICENSE +21 -0
- llmf-0.1.0/PKG-INFO +165 -0
- llmf-0.1.0/README.md +136 -0
- llmf-0.1.0/pyproject.toml +50 -0
- llmf-0.1.0/setup.cfg +4 -0
- llmf-0.1.0/src/llmf/__init__.py +87 -0
- llmf-0.1.0/src/llmf/__main__.py +3 -0
- llmf-0.1.0/src/llmf/_http.py +67 -0
- llmf-0.1.0/src/llmf/_messages.py +60 -0
- llmf-0.1.0/src/llmf/_think.py +84 -0
- llmf-0.1.0/src/llmf/_types.py +114 -0
- llmf-0.1.0/src/llmf/_version.py +1 -0
- llmf-0.1.0/src/llmf/cli.py +150 -0
- llmf-0.1.0/src/llmf/client.py +442 -0
- llmf-0.1.0/src/llmf/discover.py +63 -0
- llmf-0.1.0/src/llmf/errors.py +71 -0
- llmf-0.1.0/src/llmf/providers/__init__.py +16 -0
- llmf-0.1.0/src/llmf/providers/base.py +161 -0
- llmf-0.1.0/src/llmf/providers/builtin.py +171 -0
- llmf-0.1.0/src/llmf/providers/chatjimmy.py +123 -0
- llmf-0.1.0/src/llmf/providers/openai_compat.py +143 -0
- llmf-0.1.0/src/llmf/py.typed +0 -0
- llmf-0.1.0/src/llmf/registry.py +156 -0
- llmf-0.1.0/src/llmf.egg-info/PKG-INFO +165 -0
- llmf-0.1.0/src/llmf.egg-info/SOURCES.txt +27 -0
- llmf-0.1.0/src/llmf.egg-info/dependency_links.txt +1 -0
- llmf-0.1.0/src/llmf.egg-info/entry_points.txt +2 -0
- llmf-0.1.0/src/llmf.egg-info/top_level.txt +1 -0
- llmf-0.1.0/tests/test_llmf.py +328 -0
llmf-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Html
|
|
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.
|
llmf-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llmf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: llmf (llmfree): one dead-simple call for free, keyless LLM endpoints, with automatic fallback.
|
|
5
|
+
Author: Html
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/MoonLite-Br/llmfree
|
|
8
|
+
Project-URL: Issues, https://github.com/MoonLite-Br/llmfree/issues
|
|
9
|
+
Keywords: llm,ai,chat,free,keyless,openai-compatible,fallback,termux
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.8
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# llmf (llmfree)
|
|
31
|
+
|
|
32
|
+
**Một cú gọi duy nhất cho các LLM miễn phí, không cần API key.**
|
|
33
|
+
Không đăng ký, không khai báo nhà cung cấp, không phụ thuộc thư viện ngoài (chỉ Python stdlib, chạy tốt trên Termux).
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import llmf
|
|
37
|
+
|
|
38
|
+
print(llmf.chat("Xin chào, bạn là ai?"))
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Chỉ vậy thôi. `llmf` tự chọn endpoint đang sống, tự chuyển sang nhà cung cấp khác khi lỗi, và luôn trả về cùng một kiểu dữ liệu.
|
|
42
|
+
|
|
43
|
+
## Cài đặt
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install llmf
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Yêu cầu Python ≥ 3.8. Không có dependency.
|
|
50
|
+
|
|
51
|
+
## Cú pháp thống nhất
|
|
52
|
+
|
|
53
|
+
Mọi nhà cung cấp, mọi model đều dùng **cùng một hàm, cùng tham số**:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
llmf.chat("câu hỏi") # tự động chọn + fallback
|
|
57
|
+
llmf.chat("câu hỏi", model="best") # theo mục đích: fast | best | code | vision | reasoning
|
|
58
|
+
llmf.chat("câu hỏi", model="llama") # gõ tên gần đúng, không phân biệt hoa/thường
|
|
59
|
+
llmf.chat("câu hỏi", model="ovh/Qwen3.5-397B-A17B") # chỉ định đích danh: nhà_cung_cấp/model
|
|
60
|
+
llmf.chat("câu hỏi", model=["best", "fast"]) # thử lần lượt
|
|
61
|
+
|
|
62
|
+
llmf.chat("...", system="Trả lời ngắn gọn", max_tokens=300, temperature=0.3)
|
|
63
|
+
llmf.chat("Mô tả ảnh này", images=["anh.jpg"]) # ảnh: đường dẫn / URL / bytes
|
|
64
|
+
llmf.chat("Trả JSON", json=True)
|
|
65
|
+
llmf.chat([{"role": "user", "content": "hi"}]) # hoặc danh sách messages kiểu OpenAI
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Kết quả là `Reply` – **một `str`** kèm metadata:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
r = llmf.chat("2+2=?")
|
|
72
|
+
print(r) # "4" (đã strip, đã bỏ phần reasoning)
|
|
73
|
+
r.provider, r.model # ai đã trả lời
|
|
74
|
+
r.reasoning # chain-of-thought nếu có (không lẫn vào câu trả lời)
|
|
75
|
+
r.usage, r.latency, r.attempts
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Streaming, hội thoại, async
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
for piece in llmf.stream("Đếm từ 1 đến 10"):
|
|
82
|
+
print(piece, end="", flush=True)
|
|
83
|
+
|
|
84
|
+
s = llmf.session(system="Bạn là trợ lý ngắn gọn") # nhớ lịch sử
|
|
85
|
+
s("Tên tôi là An")
|
|
86
|
+
print(s("Tên tôi là gì?"))
|
|
87
|
+
|
|
88
|
+
reply = await llmf.achat("hi") # async
|
|
89
|
+
async for piece in llmf.astream("hi"): ...
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Streaming chỉ fallback **trước khi** có chunk đầu tiên; sau đó lỗi sẽ được raise.
|
|
93
|
+
|
|
94
|
+
## Dòng lệnh
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
llmf "Giải thích GIL trong Python"
|
|
98
|
+
cat code.py | llmf "Review đoạn code này" -m code
|
|
99
|
+
llmf -i # chat tương tác (/reset, /exit)
|
|
100
|
+
llmf --stream "kể chuyện cười"
|
|
101
|
+
llmf models # danh sách model (--live: hỏi trực tiếp từng nhà cung cấp)
|
|
102
|
+
llmf providers
|
|
103
|
+
llmf health # kiểm tra endpoint nào đang sống
|
|
104
|
+
python -m llmf "hi"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Nó hoạt động thế nào
|
|
108
|
+
|
|
109
|
+
- **Chuỗi fallback** theo thứ tự nhanh/sạch nhất: `ovh → kilo-openrouter → chatjimmy → kilo-gateway → pollinations → llm7 → blockrun → freeai`.
|
|
110
|
+
- Endpoint vừa lỗi bị **hạ ưu tiên (cooldown)** một lúc; lỗi 429 thì 60 giây.
|
|
111
|
+
- Pollinations bị giới hạn 1 request/15s nên tự được **giãn nhịp** và xếp cuối.
|
|
112
|
+
- **Làm sạch đầu ra**: bỏ `reasoning`, tách `<think>…</think>`, strip `\n\n`, bóc khối `<|stats|>` của ChatJimmy, coi câu trả lời rỗng / lỗi giấu trong HTTP 200 là thất bại để chuyển nhà khác.
|
|
113
|
+
- `max_tokens` mặc định 1024 (model reasoning cần ≥ 200, nếu không `content` sẽ rỗng).
|
|
114
|
+
- Không retry vô ích: lỗi tạm thời (mạng, 5xx, timeout) thử lại 1 vòng; 4xx / 429 thì bỏ qua đích đó.
|
|
115
|
+
|
|
116
|
+
Khi tất cả đều thất bại sẽ có `llmf.AllProvidersFailed` với danh sách `.errors` chi tiết từng nơi.
|
|
117
|
+
|
|
118
|
+
## Tuỳ biến (không bắt buộc)
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
llmf.configure(timeout=30, exclude=["blockrun"]) # hoặc only=["ovh", "llm7"]
|
|
122
|
+
|
|
123
|
+
# Thêm endpoint OpenAI-compatible của riêng bạn
|
|
124
|
+
llmf.register("lab", "http://localhost:11434/v1", ["llama3"], front=True)
|
|
125
|
+
|
|
126
|
+
# Endpoint hardcode bị chết? Lấy danh sách còn sống từ stillworks (opt-in)
|
|
127
|
+
llmf.discover()
|
|
128
|
+
|
|
129
|
+
c = llmf.Client(timeout=20) # client độc lập, không dùng chung trạng thái
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Biến môi trường: `LLMF_MODEL`, `LLMF_TIMEOUT`. Debug: `logging.getLogger("llmf").setLevel("DEBUG")`.
|
|
133
|
+
|
|
134
|
+
## Lưu ý quan trọng
|
|
135
|
+
|
|
136
|
+
- Đây là các endpoint **công khai, miễn phí, không chính thức**. Chúng có thể đổi, chậm, giới hạn hoặc biến mất bất cứ lúc nào – danh sách trong gói được xác minh ngày 2026-10-22. Hãy dùng `llmf health` và `llmf.discover()`.
|
|
137
|
+
- **Đừng gửi dữ liệu nhạy cảm** (mật khẩu, khoá, dữ liệu cá nhân): prompt đi tới máy chủ bên thứ ba ẩn danh, và `discover()` còn dùng endpoint của người lạ.
|
|
138
|
+
- Tôn trọng ToS và rate limit của từng dịch vụ; không spam. Dùng thương mại cần đọc điều khoản.
|
|
139
|
+
- `llmf` không liên kết với bất kỳ nhà cung cấp nào.
|
|
140
|
+
- Streaming trên phần lớn nhà cung cấp chưa được kiểm chứng trên endpoint thật (nếu họ không hỗ trợ, llmf nhận cả câu trả lời rồi trả một lần).
|
|
141
|
+
|
|
142
|
+
## Phát triển
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
git clone https://github.com/MoonLite-Br/llmfree && cd llmf
|
|
146
|
+
python -m unittest discover -s tests -v # test chạy offline với server giả lập
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Cấu trúc:
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
src/llmf/
|
|
153
|
+
client.py Client, Session, Stream: fallback, cooldown, rate-limit
|
|
154
|
+
registry.py danh mục provider + phân giải tên model (alias/fuzzy)
|
|
155
|
+
providers/ builtin.py (catalogue), openai_compat.py, chatjimmy.py
|
|
156
|
+
_messages.py str/messages/ảnh -> messages chuẩn
|
|
157
|
+
_think.py tách <think> (hỗ trợ streaming)
|
|
158
|
+
cli.py lệnh `llmf`
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Thêm nhà cung cấp mới tương thích OpenAI chỉ cần thêm một `ProviderSpec` vào `providers/builtin.py`.
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT
|
llmf-0.1.0/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# llmf (llmfree)
|
|
2
|
+
|
|
3
|
+
**Một cú gọi duy nhất cho các LLM miễn phí, không cần API key.**
|
|
4
|
+
Không đăng ký, không khai báo nhà cung cấp, không phụ thuộc thư viện ngoài (chỉ Python stdlib, chạy tốt trên Termux).
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
import llmf
|
|
8
|
+
|
|
9
|
+
print(llmf.chat("Xin chào, bạn là ai?"))
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Chỉ vậy thôi. `llmf` tự chọn endpoint đang sống, tự chuyển sang nhà cung cấp khác khi lỗi, và luôn trả về cùng một kiểu dữ liệu.
|
|
13
|
+
|
|
14
|
+
## Cài đặt
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install llmf
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Yêu cầu Python ≥ 3.8. Không có dependency.
|
|
21
|
+
|
|
22
|
+
## Cú pháp thống nhất
|
|
23
|
+
|
|
24
|
+
Mọi nhà cung cấp, mọi model đều dùng **cùng một hàm, cùng tham số**:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
llmf.chat("câu hỏi") # tự động chọn + fallback
|
|
28
|
+
llmf.chat("câu hỏi", model="best") # theo mục đích: fast | best | code | vision | reasoning
|
|
29
|
+
llmf.chat("câu hỏi", model="llama") # gõ tên gần đúng, không phân biệt hoa/thường
|
|
30
|
+
llmf.chat("câu hỏi", model="ovh/Qwen3.5-397B-A17B") # chỉ định đích danh: nhà_cung_cấp/model
|
|
31
|
+
llmf.chat("câu hỏi", model=["best", "fast"]) # thử lần lượt
|
|
32
|
+
|
|
33
|
+
llmf.chat("...", system="Trả lời ngắn gọn", max_tokens=300, temperature=0.3)
|
|
34
|
+
llmf.chat("Mô tả ảnh này", images=["anh.jpg"]) # ảnh: đường dẫn / URL / bytes
|
|
35
|
+
llmf.chat("Trả JSON", json=True)
|
|
36
|
+
llmf.chat([{"role": "user", "content": "hi"}]) # hoặc danh sách messages kiểu OpenAI
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Kết quả là `Reply` – **một `str`** kèm metadata:
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
r = llmf.chat("2+2=?")
|
|
43
|
+
print(r) # "4" (đã strip, đã bỏ phần reasoning)
|
|
44
|
+
r.provider, r.model # ai đã trả lời
|
|
45
|
+
r.reasoning # chain-of-thought nếu có (không lẫn vào câu trả lời)
|
|
46
|
+
r.usage, r.latency, r.attempts
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Streaming, hội thoại, async
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
for piece in llmf.stream("Đếm từ 1 đến 10"):
|
|
53
|
+
print(piece, end="", flush=True)
|
|
54
|
+
|
|
55
|
+
s = llmf.session(system="Bạn là trợ lý ngắn gọn") # nhớ lịch sử
|
|
56
|
+
s("Tên tôi là An")
|
|
57
|
+
print(s("Tên tôi là gì?"))
|
|
58
|
+
|
|
59
|
+
reply = await llmf.achat("hi") # async
|
|
60
|
+
async for piece in llmf.astream("hi"): ...
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Streaming chỉ fallback **trước khi** có chunk đầu tiên; sau đó lỗi sẽ được raise.
|
|
64
|
+
|
|
65
|
+
## Dòng lệnh
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
llmf "Giải thích GIL trong Python"
|
|
69
|
+
cat code.py | llmf "Review đoạn code này" -m code
|
|
70
|
+
llmf -i # chat tương tác (/reset, /exit)
|
|
71
|
+
llmf --stream "kể chuyện cười"
|
|
72
|
+
llmf models # danh sách model (--live: hỏi trực tiếp từng nhà cung cấp)
|
|
73
|
+
llmf providers
|
|
74
|
+
llmf health # kiểm tra endpoint nào đang sống
|
|
75
|
+
python -m llmf "hi"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Nó hoạt động thế nào
|
|
79
|
+
|
|
80
|
+
- **Chuỗi fallback** theo thứ tự nhanh/sạch nhất: `ovh → kilo-openrouter → chatjimmy → kilo-gateway → pollinations → llm7 → blockrun → freeai`.
|
|
81
|
+
- Endpoint vừa lỗi bị **hạ ưu tiên (cooldown)** một lúc; lỗi 429 thì 60 giây.
|
|
82
|
+
- Pollinations bị giới hạn 1 request/15s nên tự được **giãn nhịp** và xếp cuối.
|
|
83
|
+
- **Làm sạch đầu ra**: bỏ `reasoning`, tách `<think>…</think>`, strip `\n\n`, bóc khối `<|stats|>` của ChatJimmy, coi câu trả lời rỗng / lỗi giấu trong HTTP 200 là thất bại để chuyển nhà khác.
|
|
84
|
+
- `max_tokens` mặc định 1024 (model reasoning cần ≥ 200, nếu không `content` sẽ rỗng).
|
|
85
|
+
- Không retry vô ích: lỗi tạm thời (mạng, 5xx, timeout) thử lại 1 vòng; 4xx / 429 thì bỏ qua đích đó.
|
|
86
|
+
|
|
87
|
+
Khi tất cả đều thất bại sẽ có `llmf.AllProvidersFailed` với danh sách `.errors` chi tiết từng nơi.
|
|
88
|
+
|
|
89
|
+
## Tuỳ biến (không bắt buộc)
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
llmf.configure(timeout=30, exclude=["blockrun"]) # hoặc only=["ovh", "llm7"]
|
|
93
|
+
|
|
94
|
+
# Thêm endpoint OpenAI-compatible của riêng bạn
|
|
95
|
+
llmf.register("lab", "http://localhost:11434/v1", ["llama3"], front=True)
|
|
96
|
+
|
|
97
|
+
# Endpoint hardcode bị chết? Lấy danh sách còn sống từ stillworks (opt-in)
|
|
98
|
+
llmf.discover()
|
|
99
|
+
|
|
100
|
+
c = llmf.Client(timeout=20) # client độc lập, không dùng chung trạng thái
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Biến môi trường: `LLMF_MODEL`, `LLMF_TIMEOUT`. Debug: `logging.getLogger("llmf").setLevel("DEBUG")`.
|
|
104
|
+
|
|
105
|
+
## Lưu ý quan trọng
|
|
106
|
+
|
|
107
|
+
- Đây là các endpoint **công khai, miễn phí, không chính thức**. Chúng có thể đổi, chậm, giới hạn hoặc biến mất bất cứ lúc nào – danh sách trong gói được xác minh ngày 2026-10-22. Hãy dùng `llmf health` và `llmf.discover()`.
|
|
108
|
+
- **Đừng gửi dữ liệu nhạy cảm** (mật khẩu, khoá, dữ liệu cá nhân): prompt đi tới máy chủ bên thứ ba ẩn danh, và `discover()` còn dùng endpoint của người lạ.
|
|
109
|
+
- Tôn trọng ToS và rate limit của từng dịch vụ; không spam. Dùng thương mại cần đọc điều khoản.
|
|
110
|
+
- `llmf` không liên kết với bất kỳ nhà cung cấp nào.
|
|
111
|
+
- Streaming trên phần lớn nhà cung cấp chưa được kiểm chứng trên endpoint thật (nếu họ không hỗ trợ, llmf nhận cả câu trả lời rồi trả một lần).
|
|
112
|
+
|
|
113
|
+
## Phát triển
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
git clone https://github.com/MoonLite-Br/llmfree && cd llmf
|
|
117
|
+
python -m unittest discover -s tests -v # test chạy offline với server giả lập
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Cấu trúc:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
src/llmf/
|
|
124
|
+
client.py Client, Session, Stream: fallback, cooldown, rate-limit
|
|
125
|
+
registry.py danh mục provider + phân giải tên model (alias/fuzzy)
|
|
126
|
+
providers/ builtin.py (catalogue), openai_compat.py, chatjimmy.py
|
|
127
|
+
_messages.py str/messages/ảnh -> messages chuẩn
|
|
128
|
+
_think.py tách <think> (hỗ trợ streaming)
|
|
129
|
+
cli.py lệnh `llmf`
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Thêm nhà cung cấp mới tương thích OpenAI chỉ cần thêm một `ProviderSpec` vào `providers/builtin.py`.
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "llmf"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "llmf (llmfree): one dead-simple call for free, keyless LLM endpoints, with automatic fallback."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Html" }]
|
|
13
|
+
keywords = ["llm", "ai", "chat", "free", "keyless", "openai-compatible", "fallback", "termux"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.8",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
28
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
29
|
+
"Typing :: Typed",
|
|
30
|
+
]
|
|
31
|
+
dependencies = []
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/MoonLite-Br/llmfree"
|
|
35
|
+
Issues = "https://github.com/MoonLite-Br/llmfree/issues"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
llmf = "llmf.cli:main"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.dynamic]
|
|
41
|
+
version = { attr = "llmf._version.__version__" }
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
where = ["src"]
|
|
45
|
+
|
|
46
|
+
[tool.setuptools.package-data]
|
|
47
|
+
llmf = ["py.typed"]
|
|
48
|
+
|
|
49
|
+
[tool.pytest.ini_options]
|
|
50
|
+
testpaths = ["tests"]
|
llmf-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""llmf (llmfree) - one simple call for free, keyless LLM endpoints.
|
|
2
|
+
|
|
3
|
+
>>> import llmf
|
|
4
|
+
>>> llmf.chat("Xin chào!") # no key, no setup, automatic fallback
|
|
5
|
+
>>> llmf.chat("Explain GIL", model="best")
|
|
6
|
+
>>> for piece in llmf.stream("Count to 5"): print(piece, end="")
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
from typing import Any, List, Optional
|
|
14
|
+
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
from .client import Client, Session, Stream
|
|
17
|
+
from .errors import AllProvidersFailed, LLMFError, ModelNotFound, ProviderError
|
|
18
|
+
from ._types import Attempt, HealthResult, ModelInfo, ProviderInfo, Reply
|
|
19
|
+
|
|
20
|
+
_lock = threading.Lock()
|
|
21
|
+
_default: Optional[Client] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def default_client() -> Client:
|
|
25
|
+
"""The shared client behind the module-level functions."""
|
|
26
|
+
global _default
|
|
27
|
+
with _lock:
|
|
28
|
+
if _default is None:
|
|
29
|
+
kw: dict = {}
|
|
30
|
+
if os.environ.get("LLMF_TIMEOUT"):
|
|
31
|
+
kw["timeout"] = float(os.environ["LLMF_TIMEOUT"])
|
|
32
|
+
if os.environ.get("LLMF_MODEL"):
|
|
33
|
+
kw["model"] = os.environ["LLMF_MODEL"]
|
|
34
|
+
_default = Client(**kw)
|
|
35
|
+
return _default
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def chat(prompt, **kw: Any) -> Reply:
|
|
39
|
+
return default_client().chat(prompt, **kw)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def stream(prompt, **kw: Any) -> Stream:
|
|
43
|
+
return default_client().stream(prompt, **kw)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def achat(prompt, **kw: Any) -> Reply:
|
|
47
|
+
return await default_client().achat(prompt, **kw)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def astream(prompt, **kw: Any):
|
|
51
|
+
return default_client().astream(prompt, **kw)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def session(system: Optional[str] = None, **defaults: Any) -> Session:
|
|
55
|
+
return default_client().session(system, **defaults)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def models(live: bool = False) -> List[ModelInfo]:
|
|
59
|
+
return default_client().models(live=live)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def providers() -> List[ProviderInfo]:
|
|
63
|
+
return default_client().providers()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def health() -> List[HealthResult]:
|
|
67
|
+
return default_client().health()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def register(*a: Any, **kw: Any):
|
|
71
|
+
return default_client().register(*a, **kw)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def discover(**kw: Any) -> List[str]:
|
|
75
|
+
return default_client().discover(**kw)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def configure(**kw: Any) -> Client:
|
|
79
|
+
return default_client().configure(**kw)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = [
|
|
83
|
+
"__version__", "Client", "Session", "Stream", "Reply", "chat", "stream", "achat", "astream",
|
|
84
|
+
"session", "models", "providers", "health", "register", "discover", "configure",
|
|
85
|
+
"default_client", "LLMFError", "ProviderError", "AllProvidersFailed", "ModelNotFound",
|
|
86
|
+
"Attempt", "HealthResult", "ModelInfo", "ProviderInfo",
|
|
87
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Tiny stdlib HTTP layer (no third-party dependencies)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
import http.client
|
|
7
|
+
import socket
|
|
8
|
+
import ssl
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Dict, Optional
|
|
12
|
+
|
|
13
|
+
from ._version import __version__
|
|
14
|
+
|
|
15
|
+
DEFAULT_UA = (
|
|
16
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
17
|
+
"Chrome/130.0.0.0 Safari/537.36 llmf/%s" % __version__
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
NETWORK_ERRORS = (OSError, http.client.HTTPException, ssl.SSLError, socket.timeout)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class HTTPFailure(Exception):
|
|
24
|
+
"""Transport-level failure. ``status is None`` means no HTTP response."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, status: Optional[int], body: str = "", kind: str = "http") -> None:
|
|
27
|
+
self.status = status
|
|
28
|
+
self.body = body
|
|
29
|
+
self.kind = kind # "http" | "timeout" | "network"
|
|
30
|
+
super().__init__("%s %s" % (status if status is not None else kind, body[:120]))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def decode_body(resp) -> str:
|
|
34
|
+
data = resp.read()
|
|
35
|
+
if resp.headers.get("Content-Encoding", "").lower() == "gzip":
|
|
36
|
+
try:
|
|
37
|
+
data = gzip.decompress(data)
|
|
38
|
+
except OSError:
|
|
39
|
+
pass
|
|
40
|
+
return data.decode("utf-8", "replace")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def open_url(
|
|
44
|
+
url: str,
|
|
45
|
+
*,
|
|
46
|
+
headers: Dict[str, str],
|
|
47
|
+
data: Optional[bytes] = None,
|
|
48
|
+
timeout: float = 60.0,
|
|
49
|
+
):
|
|
50
|
+
"""Open ``url`` and return the response object (caller must close it)."""
|
|
51
|
+
req = urllib.request.Request(url, data=data, headers=headers)
|
|
52
|
+
try:
|
|
53
|
+
return urllib.request.urlopen(req, timeout=timeout)
|
|
54
|
+
except urllib.error.HTTPError as e:
|
|
55
|
+
try:
|
|
56
|
+
body = decode_body(e)
|
|
57
|
+
except Exception: # pragma: no cover - body unreadable
|
|
58
|
+
body = ""
|
|
59
|
+
raise HTTPFailure(e.code, body) from None
|
|
60
|
+
except urllib.error.URLError as e:
|
|
61
|
+
reason = e.reason
|
|
62
|
+
kind = "timeout" if isinstance(reason, (socket.timeout, TimeoutError)) else "network"
|
|
63
|
+
raise HTTPFailure(None, str(reason), kind) from None
|
|
64
|
+
except (socket.timeout, TimeoutError) as e:
|
|
65
|
+
raise HTTPFailure(None, str(e) or "timed out", "timeout") from None
|
|
66
|
+
except NETWORK_ERRORS as e:
|
|
67
|
+
raise HTTPFailure(None, str(e), "network") from None
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Turn friendly inputs (str / messages / images) into OpenAI-style messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import copy
|
|
7
|
+
import mimetypes
|
|
8
|
+
import os
|
|
9
|
+
from typing import Any, List, Optional, Sequence, Union
|
|
10
|
+
|
|
11
|
+
Prompt = Union[str, Sequence[dict]]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _sniff(data: bytes) -> str:
|
|
15
|
+
if data.startswith(b"\x89PNG"):
|
|
16
|
+
return "image/png"
|
|
17
|
+
if data.startswith(b"\xff\xd8"):
|
|
18
|
+
return "image/jpeg"
|
|
19
|
+
if data.startswith(b"GIF8"):
|
|
20
|
+
return "image/gif"
|
|
21
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
22
|
+
return "image/webp"
|
|
23
|
+
return "application/octet-stream"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def image_url(img: Any) -> str:
|
|
27
|
+
"""URL / data-URI / local path / raw bytes -> something an API accepts."""
|
|
28
|
+
if isinstance(img, (bytes, bytearray)):
|
|
29
|
+
data = bytes(img)
|
|
30
|
+
return "data:%s;base64,%s" % (_sniff(data), base64.b64encode(data).decode())
|
|
31
|
+
s = str(img)
|
|
32
|
+
if s.startswith(("http://", "https://", "data:")):
|
|
33
|
+
return s
|
|
34
|
+
if os.path.isfile(s):
|
|
35
|
+
with open(s, "rb") as f:
|
|
36
|
+
data = f.read()
|
|
37
|
+
mime = mimetypes.guess_type(s)[0] or _sniff(data)
|
|
38
|
+
return "data:%s;base64,%s" % (mime, base64.b64encode(data).decode())
|
|
39
|
+
raise FileNotFoundError("image not found: %r" % s)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_messages(prompt: Prompt, system: Optional[str] = None,
|
|
43
|
+
images: Optional[Sequence[Any]] = None) -> List[dict]:
|
|
44
|
+
if isinstance(prompt, str):
|
|
45
|
+
msgs: List[dict] = [{"role": "user", "content": prompt}]
|
|
46
|
+
else:
|
|
47
|
+
msgs = copy.deepcopy([dict(m) for m in prompt])
|
|
48
|
+
if not msgs:
|
|
49
|
+
raise ValueError("messages must not be empty")
|
|
50
|
+
if images:
|
|
51
|
+
idx = next((i for i in range(len(msgs) - 1, -1, -1) if msgs[i].get("role") == "user"), None)
|
|
52
|
+
if idx is None:
|
|
53
|
+
raise ValueError("images need a user message to attach to")
|
|
54
|
+
content = msgs[idx].get("content", "")
|
|
55
|
+
parts = [{"type": "text", "text": content}] if isinstance(content, str) else list(content)
|
|
56
|
+
parts += [{"type": "image_url", "image_url": {"url": image_url(i)}} for i in images]
|
|
57
|
+
msgs[idx]["content"] = parts
|
|
58
|
+
if system and not (msgs and msgs[0].get("role") == "system"):
|
|
59
|
+
msgs.insert(0, {"role": "system", "content": system})
|
|
60
|
+
return msgs
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Split ``<think>...</think>`` reasoning out of a (possibly streamed) text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
OPEN = "<think>"
|
|
8
|
+
CLOSE = "</think>"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _partial_suffix(s: str, token: str) -> int:
|
|
12
|
+
"""Length of the longest proper prefix of ``token`` that ``s`` ends with."""
|
|
13
|
+
for k in range(min(len(token) - 1, len(s)), 0, -1):
|
|
14
|
+
if s.endswith(token[:k]):
|
|
15
|
+
return k
|
|
16
|
+
return 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ThinkSplitter:
|
|
20
|
+
"""Incremental splitter. ``feed()`` returns the visible text for a chunk."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._buf = ""
|
|
24
|
+
self._inside = False
|
|
25
|
+
self._started = False # visible text has begun (for leading-space trim)
|
|
26
|
+
self.thought: List[str] = []
|
|
27
|
+
|
|
28
|
+
def _emit(self, text: str) -> str:
|
|
29
|
+
if not self._started:
|
|
30
|
+
text = text.lstrip()
|
|
31
|
+
if text:
|
|
32
|
+
self._started = True
|
|
33
|
+
return text
|
|
34
|
+
|
|
35
|
+
def feed(self, chunk: str) -> str:
|
|
36
|
+
self._buf += chunk
|
|
37
|
+
out: List[str] = []
|
|
38
|
+
while True:
|
|
39
|
+
if not self._inside:
|
|
40
|
+
i = self._buf.find(OPEN)
|
|
41
|
+
if i >= 0:
|
|
42
|
+
out.append(self._buf[:i])
|
|
43
|
+
self._buf = self._buf[i + len(OPEN):]
|
|
44
|
+
self._inside = True
|
|
45
|
+
continue
|
|
46
|
+
keep = _partial_suffix(self._buf, OPEN)
|
|
47
|
+
cut = len(self._buf) - keep
|
|
48
|
+
out.append(self._buf[:cut])
|
|
49
|
+
self._buf = self._buf[cut:]
|
|
50
|
+
break
|
|
51
|
+
j = self._buf.find(CLOSE)
|
|
52
|
+
if j >= 0:
|
|
53
|
+
self.thought.append(self._buf[:j])
|
|
54
|
+
self._buf = self._buf[j + len(CLOSE):]
|
|
55
|
+
self._inside = False
|
|
56
|
+
continue
|
|
57
|
+
keep = _partial_suffix(self._buf, CLOSE)
|
|
58
|
+
cut = len(self._buf) - keep
|
|
59
|
+
self.thought.append(self._buf[:cut])
|
|
60
|
+
self._buf = self._buf[cut:]
|
|
61
|
+
break
|
|
62
|
+
return self._emit("".join(out))
|
|
63
|
+
|
|
64
|
+
def flush(self) -> str:
|
|
65
|
+
rest, self._buf = self._buf, ""
|
|
66
|
+
if self._inside:
|
|
67
|
+
self.thought.append(rest)
|
|
68
|
+
return ""
|
|
69
|
+
return self._emit(rest)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def reasoning(self) -> str:
|
|
73
|
+
return "".join(self.thought).strip()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def split_think(text: str):
|
|
77
|
+
"""Non-streaming helper -> ``(visible_text, reasoning_or_None)``."""
|
|
78
|
+
# Some templates only emit the closing tag (the opening one is in the prompt).
|
|
79
|
+
if CLOSE in text and OPEN not in text:
|
|
80
|
+
thought, _, text = text.partition(CLOSE)
|
|
81
|
+
return text.strip(), (thought.strip() or None)
|
|
82
|
+
sp = ThinkSplitter()
|
|
83
|
+
visible = sp.feed(text) + sp.flush()
|
|
84
|
+
return visible.strip(), (sp.reasoning or None)
|