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 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -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)