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 +87 -0
- llmf/__main__.py +3 -0
- llmf/_http.py +67 -0
- llmf/_messages.py +60 -0
- llmf/_think.py +84 -0
- llmf/_types.py +114 -0
- llmf/_version.py +1 -0
- llmf/cli.py +150 -0
- llmf/client.py +442 -0
- llmf/discover.py +63 -0
- llmf/errors.py +71 -0
- llmf/providers/__init__.py +16 -0
- llmf/providers/base.py +161 -0
- llmf/providers/builtin.py +171 -0
- llmf/providers/chatjimmy.py +123 -0
- llmf/providers/openai_compat.py +143 -0
- llmf/py.typed +0 -0
- llmf/registry.py +156 -0
- llmf-0.1.0.dist-info/METADATA +165 -0
- llmf-0.1.0.dist-info/RECORD +24 -0
- llmf-0.1.0.dist-info/WHEEL +5 -0
- llmf-0.1.0.dist-info/entry_points.txt +2 -0
- llmf-0.1.0.dist-info/licenses/LICENSE +21 -0
- llmf-0.1.0.dist-info/top_level.txt +1 -0
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
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
|