llmf 0.1.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.
llmf/__init__.py 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
+ ]
llmf/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
llmf/_http.py ADDED
@@ -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
llmf/_messages.py ADDED
@@ -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
llmf/_think.py ADDED
@@ -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)
llmf/_types.py ADDED
@@ -0,0 +1,114 @@
1
+ """Public value types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+
9
+ @dataclass
10
+ class Attempt:
11
+ """One provider/model try (successful or not)."""
12
+
13
+ provider: str
14
+ model: str
15
+ ok: bool
16
+ latency: float = 0.0
17
+ error: Optional[str] = None
18
+
19
+
20
+ class Reply(str):
21
+ """The answer text, plus metadata.
22
+
23
+ A ``Reply`` *is* a ``str`` – print it, slice it, ``json.loads`` it – and
24
+ additionally carries ``provider``, ``model``, ``reasoning``, ``usage``,
25
+ ``finish_reason``, ``tool_calls``, ``latency``, ``raw`` and ``attempts``.
26
+ """
27
+
28
+ provider: str
29
+ model: str
30
+ reasoning: Optional[str]
31
+ usage: Optional[Dict[str, Any]]
32
+ finish_reason: Optional[str]
33
+ tool_calls: Optional[List[Any]]
34
+ latency: float
35
+ raw: Any
36
+ attempts: List[Attempt]
37
+
38
+ def __new__(
39
+ cls,
40
+ text: str = "",
41
+ *,
42
+ provider: str = "",
43
+ model: str = "",
44
+ reasoning: Optional[str] = None,
45
+ usage: Optional[Dict[str, Any]] = None,
46
+ finish_reason: Optional[str] = None,
47
+ tool_calls: Optional[List[Any]] = None,
48
+ latency: float = 0.0,
49
+ raw: Any = None,
50
+ attempts: Optional[List[Attempt]] = None,
51
+ ) -> "Reply":
52
+ self = super().__new__(cls, text)
53
+ self.provider = provider
54
+ self.model = model
55
+ self.reasoning = reasoning
56
+ self.usage = usage
57
+ self.finish_reason = finish_reason
58
+ self.tool_calls = tool_calls
59
+ self.latency = latency
60
+ self.raw = raw
61
+ self.attempts = attempts if attempts is not None else []
62
+ return self
63
+
64
+ @property
65
+ def text(self) -> str:
66
+ return str.__str__(self)
67
+
68
+ @property
69
+ def source(self) -> str:
70
+ """``"provider/model"`` that produced this reply."""
71
+ return "%s/%s" % (self.provider, self.model)
72
+
73
+ def __getnewargs_ex__(self): # keeps copy/pickle working for a str subclass
74
+ meta = dict(
75
+ provider=self.provider, model=self.model, reasoning=self.reasoning,
76
+ usage=self.usage, finish_reason=self.finish_reason,
77
+ tool_calls=self.tool_calls, latency=self.latency, raw=self.raw,
78
+ attempts=self.attempts,
79
+ )
80
+ return (self.text,), meta
81
+
82
+
83
+ @dataclass
84
+ class ModelInfo:
85
+ provider: str
86
+ id: str
87
+ tags: Tuple[str, ...] = ()
88
+ vision: bool = False
89
+ verified: bool = True
90
+
91
+ @property
92
+ def name(self) -> str:
93
+ """Fully-qualified name usable as ``model=``."""
94
+ return "%s/%s" % (self.provider, self.id)
95
+
96
+
97
+ @dataclass
98
+ class ProviderInfo:
99
+ name: str
100
+ base_url: str
101
+ kind: str
102
+ default_model: str
103
+ models: List[str] = field(default_factory=list)
104
+ min_interval: float = 0.0
105
+ note: str = ""
106
+
107
+
108
+ @dataclass
109
+ class HealthResult:
110
+ provider: str
111
+ model: str
112
+ ok: bool
113
+ latency: float = 0.0
114
+ error: Optional[str] = None
llmf/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
llmf/cli.py ADDED
@@ -0,0 +1,150 @@
1
+ """Command line: ``llmf "your question"``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import logging
8
+ import sys
9
+ from typing import List, Optional
10
+
11
+ from . import __version__, default_client
12
+ from .errors import LLMFError
13
+
14
+ COMMANDS = ("chat", "models", "providers", "health", "version")
15
+
16
+ HELP = """\
17
+ usage: llmf [options] "question" ask once (also reads stdin)
18
+ llmf -i interactive chat
19
+ llmf models | providers | health inspect what is available
20
+ """
21
+
22
+
23
+ def _parser() -> argparse.ArgumentParser:
24
+ p = argparse.ArgumentParser(prog="llmf", usage=HELP, add_help=True,
25
+ description="Free, keyless LLMs behind one command.")
26
+ p.add_argument("words", nargs="*", help="question, or one of: " + ", ".join(COMMANDS))
27
+ p.add_argument("-m", "--model", help="model, alias (fast/best/code/vision/reasoning) or provider/model")
28
+ p.add_argument("-s", "--system", help="system prompt")
29
+ p.add_argument("-i", "--interactive", action="store_true", help="interactive chat (REPL)")
30
+ p.add_argument("--image", action="append", default=[], help="image path/URL (repeatable)")
31
+ p.add_argument("--max-tokens", type=int)
32
+ p.add_argument("-t", "--temperature", type=float)
33
+ p.add_argument("--stream", action="store_true", help="print the answer as it arrives")
34
+ p.add_argument("--json", action="store_true", help="machine-readable output")
35
+ p.add_argument("--live", action="store_true", help="models: also query providers live")
36
+ p.add_argument("--timeout", type=float)
37
+ p.add_argument("-v", "--verbose", action="store_true", help="show provider/model used")
38
+ p.add_argument("-V", "--version", action="version", version="llmf " + __version__)
39
+ return p
40
+
41
+
42
+ def _read_stdin() -> str:
43
+ return "" if sys.stdin is None or sys.stdin.isatty() else sys.stdin.read().strip()
44
+
45
+
46
+ def _ask(a, prompt, history: Optional[List[dict]] = None) -> str:
47
+ c = default_client()
48
+ kw = dict(model=a.model, system=a.system, max_tokens=a.max_tokens, temperature=a.temperature)
49
+ if a.image:
50
+ kw["images"] = a.image
51
+ msgs = (history or []) + [{"role": "user", "content": prompt}]
52
+ if a.stream and not a.json:
53
+ st = c.stream(msgs, **kw)
54
+ for piece in st:
55
+ sys.stdout.write(piece)
56
+ sys.stdout.flush()
57
+ sys.stdout.write("\n")
58
+ reply = st.reply
59
+ text = reply.text if reply else st.text
60
+ src = reply.source if reply else ""
61
+ else:
62
+ reply = c.chat(msgs, **kw)
63
+ text, src = reply.text, reply.source
64
+ if a.json:
65
+ print(json.dumps({"text": text, "provider": reply.provider, "model": reply.model,
66
+ "reasoning": reply.reasoning, "usage": reply.usage,
67
+ "latency": round(reply.latency, 3)}, ensure_ascii=False))
68
+ else:
69
+ print(text)
70
+ if a.verbose and src:
71
+ print("[%s]" % src, file=sys.stderr)
72
+ return text
73
+
74
+
75
+ def _repl(a) -> int:
76
+ history: List[dict] = []
77
+ print("llmf %s - /reset clears history, /exit quits" % __version__, file=sys.stderr)
78
+ while True:
79
+ try:
80
+ line = input("> ").strip()
81
+ except (EOFError, KeyboardInterrupt):
82
+ print()
83
+ return 0
84
+ if not line:
85
+ continue
86
+ if line in ("/exit", "/quit"):
87
+ return 0
88
+ if line == "/reset":
89
+ history.clear()
90
+ continue
91
+ try:
92
+ text = _ask(a, line, history)
93
+ except LLMFError as e:
94
+ print("error: %s" % e, file=sys.stderr)
95
+ continue
96
+ history += [{"role": "user", "content": line}, {"role": "assistant", "content": text}]
97
+
98
+
99
+ def main(argv: Optional[List[str]] = None) -> int:
100
+ a = _parser().parse_args(argv)
101
+ if a.verbose:
102
+ logging.basicConfig(level=logging.DEBUG, format="%(name)s: %(message)s")
103
+ c = default_client()
104
+ if a.timeout:
105
+ c.configure(timeout=a.timeout)
106
+ cmd = a.words[0] if a.words and a.words[0] in COMMANDS and len(a.words) == 1 else None
107
+ try:
108
+ if cmd == "version":
109
+ print(__version__)
110
+ elif cmd == "providers":
111
+ rows = c.providers()
112
+ if a.json:
113
+ print(json.dumps([r.__dict__ for r in rows], ensure_ascii=False, indent=2))
114
+ for r in rows if not a.json else []:
115
+ print("%-16s default=%-42s %s" % (r.name, r.default_model, r.note))
116
+ elif cmd == "models":
117
+ rows = c.models(live=a.live)
118
+ if a.json:
119
+ print(json.dumps([{"name": m.name, "tags": m.tags, "vision": m.vision,
120
+ "verified": m.verified} for m in rows], indent=2))
121
+ for m in rows if not a.json else []:
122
+ tags = list(m.tags) + (["vision"] if m.vision and "vision" not in m.tags else [])
123
+ flags = ",".join(tags + ([] if m.verified else ["unverified"]))
124
+ print("%-60s %s" % (m.name, flags))
125
+ elif cmd == "health":
126
+ bad = 0
127
+ for h in c.health():
128
+ bad += not h.ok
129
+ print("%-4s %-16s %-42s %5.1fs %s" % ("OK" if h.ok else "FAIL", h.provider,
130
+ h.model, h.latency, h.error or ""))
131
+ return 1 if bad == len(c.providers()) else 0
132
+ elif a.interactive:
133
+ return _repl(a)
134
+ else:
135
+ prompt = " ".join(a.words[1:] if a.words[:1] == ["chat"] else a.words).strip()
136
+ piped = _read_stdin()
137
+ prompt = ("%s\n\n%s" % (prompt, piped)).strip() if piped else prompt
138
+ if not prompt:
139
+ print(HELP, file=sys.stderr)
140
+ return 2
141
+ _ask(a, prompt)
142
+ except LLMFError as e:
143
+ print("error: %s" % e, file=sys.stderr)
144
+ return 1
145
+ except FileNotFoundError as e:
146
+ print("error: %s" % e, file=sys.stderr)
147
+ return 2
148
+ except BrokenPipeError: # e.g. ``llmf ... | head``
149
+ return 0
150
+ return 0