llms-ink 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.
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ *.egg-info/
5
+ .pytest_cache/
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-11-14
4
+
5
+ - `Client.llms(url, mode, fresh)` and `Client.check(url)` for the llms.ink JSON API, standard library only.
6
+ - Optional API key from `key=` or `LLMS_INK_KEY`, sent as `Authorization: Bearer`.
7
+ - Typed errors from problem details: `InvalidKey`, `RateLimited`, `QuotaExceeded` (with `retry_after`), `LlmsInkError`.
8
+ - `llms_ink.langchain.LlmsInkLoader` (`[langchain]`) and `llms_ink.llama_index.LlmsInkReader` (`[llama-index]`).
9
+ - `python -m llms_ink <url> [--full] [--fresh] [--json]`.
llms_ink-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lucas Espindola
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.
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.5
2
+ Name: llms-ink
3
+ Version: 0.1.0
4
+ Summary: Turn any link — site, docs section, page, GitHub repo, PDF or video — into llms.txt and llms-full.txt. Python client for llms.ink, with LangChain and LlamaIndex loaders.
5
+ Project-URL: Homepage, https://llms.ink
6
+ Project-URL: Documentation, https://llms.ink/docs/
7
+ Author: Lucas Espindola
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai,context,docs,langchain,llama-index,llm,llms-full.txt,llms.txt,markdown,rag
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.9
20
+ Provides-Extra: langchain
21
+ Requires-Dist: langchain-core>=0.2; extra == 'langchain'
22
+ Provides-Extra: llama-index
23
+ Requires-Dist: llama-index-core>=0.10; extra == 'llama-index'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # llms-ink
27
+
28
+ Turn any public link — a site, docs section, page, GitHub repo, PDF or video — into
29
+ [llms.txt](https://llmstxt.org) or llms-full.txt, from Python. A client for [llms.ink](https://llms.ink)
30
+ with LangChain and LlamaIndex loaders. No required dependencies.
31
+
32
+ ```sh
33
+ pip install llms-ink
34
+ ```
35
+
36
+ ## Use
37
+
38
+ ```python
39
+ from llms_ink import Client
40
+
41
+ ink = Client()
42
+ index = ink.llms("llmstxt.org") # llms.txt: an index of links
43
+ docs = ink.llms("docs.astro.build/en/guides/", mode="full") # llms-full.txt: the content
44
+
45
+ print(docs.title, docs.kind, docs.tokens)
46
+ print(docs.text)
47
+ ```
48
+
49
+ `llms()` returns a `Result`: `url`, `kind` (`site`, `section`, `page`, `repo`, `document`, `video`),
50
+ `source` (`native` = published by the site, `generated` = made by llms.ink), `mode`, `title`, `text`,
51
+ `tokens`, `pages`, `model`, `cached`, `generated_at`, and `media` for videos (duration, downloads,
52
+ transcript segments). `fresh=True` skips the cache and rebuilds.
53
+
54
+ Check a site's published llms.txt:
55
+
56
+ ```python
57
+ report = ink.check("llmstxt.org")
58
+ print(report.found, report.summary.errors, [i.message for i in report.issues])
59
+ ```
60
+
61
+ ## API key (optional)
62
+
63
+ Without a key you share the free anonymous limits (10 requests/minute, 60 new links a day per IP;
64
+ cached links are free). A key gets its own daily quota:
65
+
66
+ ```sh
67
+ export LLMS_INK_KEY=lk_...
68
+ ```
69
+
70
+ or `Client(key="lk_...")`. It is sent as `Authorization: Bearer`. A bad key raises `InvalidKey`; it never
71
+ falls back to anonymous. [Ask for a key](https://lucasespindola.com/message?text=I%27d+like+an+llms.ink+API+key).
72
+
73
+ ## Errors
74
+
75
+ ```python
76
+ from llms_ink import Client, LlmsInkError, QuotaExceeded, RateLimited
77
+
78
+ try:
79
+ Client().llms("example.com")
80
+ except (RateLimited, QuotaExceeded) as err:
81
+ print(f"wait {err.retry_after}s")
82
+ except LlmsInkError as err:
83
+ print(err.status, err.code, err.detail)
84
+ ```
85
+
86
+ `LlmsInkError` carries the API's `status`, `code` (see the [error table](https://llms.ink/docs/#errors)),
87
+ `detail` and `errors`. `InvalidKey`, `RateLimited` and `QuotaExceeded` are subclasses.
88
+
89
+ ## LangChain
90
+
91
+ ```sh
92
+ pip install 'llms-ink[langchain]'
93
+ ```
94
+
95
+ ```python
96
+ from llms_ink.langchain import LlmsInkLoader
97
+
98
+ docs = LlmsInkLoader(["llmstxt.org", "github.com/AnswerDotAI/llms-txt"], mode="full").load()
99
+ docs[0].metadata # {'url', 'title', 'kind', 'source', 'mode', 'tokens', 'generated_at'}
100
+ ```
101
+
102
+ ## LlamaIndex
103
+
104
+ ```sh
105
+ pip install 'llms-ink[llama-index]'
106
+ ```
107
+
108
+ ```python
109
+ from llama_index.core import VectorStoreIndex
110
+ from llms_ink.llama_index import LlmsInkReader
111
+
112
+ docs = LlmsInkReader(mode="full").load_data(["docs.astro.build/en/guides/"])
113
+ index = VectorStoreIndex.from_documents(docs)
114
+ ```
115
+
116
+ ## Command line
117
+
118
+ ```sh
119
+ python -m llms_ink llmstxt.org # llms.txt
120
+ python -m llms_ink llmstxt.org --full # llms-full.txt
121
+ python -m llms_ink llmstxt.org --json # the whole result
122
+ ```
123
+
124
+ ## Development
125
+
126
+ ```sh
127
+ python3 -m venv .venv && .venv/bin/pip install -e '.[langchain,llama-index]' pytest
128
+ .venv/bin/pytest # against a local stub server
129
+ .venv/bin/pytest -m live # one real call to https://llms.ink
130
+ ```
131
+
132
+ `LLMS_INK_API` points the client at another API base URL.
133
+
134
+ MIT licensed.
@@ -0,0 +1,109 @@
1
+ # llms-ink
2
+
3
+ Turn any public link — a site, docs section, page, GitHub repo, PDF or video — into
4
+ [llms.txt](https://llmstxt.org) or llms-full.txt, from Python. A client for [llms.ink](https://llms.ink)
5
+ with LangChain and LlamaIndex loaders. No required dependencies.
6
+
7
+ ```sh
8
+ pip install llms-ink
9
+ ```
10
+
11
+ ## Use
12
+
13
+ ```python
14
+ from llms_ink import Client
15
+
16
+ ink = Client()
17
+ index = ink.llms("llmstxt.org") # llms.txt: an index of links
18
+ docs = ink.llms("docs.astro.build/en/guides/", mode="full") # llms-full.txt: the content
19
+
20
+ print(docs.title, docs.kind, docs.tokens)
21
+ print(docs.text)
22
+ ```
23
+
24
+ `llms()` returns a `Result`: `url`, `kind` (`site`, `section`, `page`, `repo`, `document`, `video`),
25
+ `source` (`native` = published by the site, `generated` = made by llms.ink), `mode`, `title`, `text`,
26
+ `tokens`, `pages`, `model`, `cached`, `generated_at`, and `media` for videos (duration, downloads,
27
+ transcript segments). `fresh=True` skips the cache and rebuilds.
28
+
29
+ Check a site's published llms.txt:
30
+
31
+ ```python
32
+ report = ink.check("llmstxt.org")
33
+ print(report.found, report.summary.errors, [i.message for i in report.issues])
34
+ ```
35
+
36
+ ## API key (optional)
37
+
38
+ Without a key you share the free anonymous limits (10 requests/minute, 60 new links a day per IP;
39
+ cached links are free). A key gets its own daily quota:
40
+
41
+ ```sh
42
+ export LLMS_INK_KEY=lk_...
43
+ ```
44
+
45
+ or `Client(key="lk_...")`. It is sent as `Authorization: Bearer`. A bad key raises `InvalidKey`; it never
46
+ falls back to anonymous. [Ask for a key](https://lucasespindola.com/message?text=I%27d+like+an+llms.ink+API+key).
47
+
48
+ ## Errors
49
+
50
+ ```python
51
+ from llms_ink import Client, LlmsInkError, QuotaExceeded, RateLimited
52
+
53
+ try:
54
+ Client().llms("example.com")
55
+ except (RateLimited, QuotaExceeded) as err:
56
+ print(f"wait {err.retry_after}s")
57
+ except LlmsInkError as err:
58
+ print(err.status, err.code, err.detail)
59
+ ```
60
+
61
+ `LlmsInkError` carries the API's `status`, `code` (see the [error table](https://llms.ink/docs/#errors)),
62
+ `detail` and `errors`. `InvalidKey`, `RateLimited` and `QuotaExceeded` are subclasses.
63
+
64
+ ## LangChain
65
+
66
+ ```sh
67
+ pip install 'llms-ink[langchain]'
68
+ ```
69
+
70
+ ```python
71
+ from llms_ink.langchain import LlmsInkLoader
72
+
73
+ docs = LlmsInkLoader(["llmstxt.org", "github.com/AnswerDotAI/llms-txt"], mode="full").load()
74
+ docs[0].metadata # {'url', 'title', 'kind', 'source', 'mode', 'tokens', 'generated_at'}
75
+ ```
76
+
77
+ ## LlamaIndex
78
+
79
+ ```sh
80
+ pip install 'llms-ink[llama-index]'
81
+ ```
82
+
83
+ ```python
84
+ from llama_index.core import VectorStoreIndex
85
+ from llms_ink.llama_index import LlmsInkReader
86
+
87
+ docs = LlmsInkReader(mode="full").load_data(["docs.astro.build/en/guides/"])
88
+ index = VectorStoreIndex.from_documents(docs)
89
+ ```
90
+
91
+ ## Command line
92
+
93
+ ```sh
94
+ python -m llms_ink llmstxt.org # llms.txt
95
+ python -m llms_ink llmstxt.org --full # llms-full.txt
96
+ python -m llms_ink llmstxt.org --json # the whole result
97
+ ```
98
+
99
+ ## Development
100
+
101
+ ```sh
102
+ python3 -m venv .venv && .venv/bin/pip install -e '.[langchain,llama-index]' pytest
103
+ .venv/bin/pytest # against a local stub server
104
+ .venv/bin/pytest -m live # one real call to https://llms.ink
105
+ ```
106
+
107
+ `LLMS_INK_API` points the client at another API base URL.
108
+
109
+ MIT licensed.
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "llms-ink"
7
+ dynamic = ["version"]
8
+ description = "Turn any link — site, docs section, page, GitHub repo, PDF or video — into llms.txt and llms-full.txt. Python client for llms.ink, with LangChain and LlamaIndex loaders."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Lucas Espindola" }]
14
+ keywords = ["llms.txt", "llms-full.txt", "llm", "ai", "markdown", "docs", "context", "rag", "langchain", "llama-index"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
22
+ "Topic :: Text Processing :: Markup :: Markdown",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = []
26
+
27
+ [project.optional-dependencies]
28
+ langchain = ["langchain-core>=0.2"]
29
+ llama-index = ["llama-index-core>=0.10"]
30
+
31
+ [project.urls]
32
+ Homepage = "https://llms.ink"
33
+ Documentation = "https://llms.ink/docs/"
34
+
35
+ [tool.hatch.version]
36
+ path = "src/llms_ink/__init__.py"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/llms_ink"]
40
+
41
+ [tool.hatch.build.targets.sdist]
42
+ include = ["src", "tests", "README.md", "LICENSE", "CHANGELOG.md"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
46
+ addopts = "-m 'not live'"
47
+ markers = ["live: calls the real https://llms.ink (run with: pytest -m live)"]
@@ -0,0 +1,41 @@
1
+ """Turn any link — site, docs section, page, GitHub repo, PDF or video — into llms.txt and llms-full.txt.
2
+
3
+ >>> from llms_ink import Client
4
+ >>> Client().llms("llmstxt.org").text
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ from .client import ( # noqa: E402
10
+ DEFAULT_BASE_URL,
11
+ BrokenLink,
12
+ CheckIssue,
13
+ CheckLinks,
14
+ CheckResult,
15
+ CheckSummary,
16
+ Client,
17
+ Media,
18
+ Result,
19
+ Segment,
20
+ Transcript,
21
+ )
22
+ from .errors import InvalidKey, LlmsInkError, QuotaExceeded, RateLimited # noqa: E402
23
+
24
+ __all__ = [
25
+ "DEFAULT_BASE_URL",
26
+ "BrokenLink",
27
+ "CheckIssue",
28
+ "CheckLinks",
29
+ "CheckResult",
30
+ "CheckSummary",
31
+ "Client",
32
+ "InvalidKey",
33
+ "LlmsInkError",
34
+ "Media",
35
+ "QuotaExceeded",
36
+ "RateLimited",
37
+ "Result",
38
+ "Segment",
39
+ "Transcript",
40
+ "__version__",
41
+ ]
@@ -0,0 +1,45 @@
1
+ """python -m llms_ink <url> [--full] [--fresh] [--json]: prints llms.txt (or llms-full.txt) for a link."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from typing import List, Optional
9
+
10
+ from . import __version__
11
+ from .client import Client
12
+ from .errors import LlmsInkError, QuotaExceeded, RateLimited
13
+
14
+
15
+ def main(argv: Optional[List[str]] = None) -> int:
16
+ parser = argparse.ArgumentParser(
17
+ prog="python -m llms_ink",
18
+ description="Print llms.txt for any link: a site, docs section, page, GitHub repo, PDF or video.",
19
+ epilog="Uses LLMS_INK_KEY as the API key when set (optional).",
20
+ )
21
+ parser.add_argument("url", help="any public link, e.g. llmstxt.org")
22
+ parser.add_argument("--full", action="store_true", help="llms-full.txt (the content) instead of the index")
23
+ parser.add_argument("--fresh", action="store_true", help="skip the cache and rebuild")
24
+ parser.add_argument("--json", action="store_true", help="the whole API result as JSON")
25
+ parser.add_argument("--version", action="version", version=f"llms-ink {__version__}")
26
+ args = parser.parse_args(argv)
27
+
28
+ try:
29
+ result = Client().llms(args.url, mode="full" if args.full else "llms", fresh=args.fresh)
30
+ except LlmsInkError as err:
31
+ message = err.detail
32
+ if isinstance(err, (RateLimited, QuotaExceeded)) and err.retry_after is not None:
33
+ message += f" Try again in {err.retry_after}s."
34
+ print(f"llms-ink: {message}", file=sys.stderr)
35
+ return 1
36
+
37
+ if args.json:
38
+ print(json.dumps(result.raw, indent=2, ensure_ascii=False))
39
+ else:
40
+ sys.stdout.write(result.text if result.text.endswith("\n") else result.text + "\n")
41
+ return 0
42
+
43
+
44
+ if __name__ == "__main__":
45
+ sys.exit(main())
@@ -0,0 +1,289 @@
1
+ """The llms.ink JSON API (https://llms.ink/docs/) over the standard library's urllib."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import socket
8
+ import urllib.error
9
+ import urllib.parse
10
+ import urllib.request
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime, timezone
13
+ from email.utils import parsedate_to_datetime
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from . import __version__
17
+ from .errors import LlmsInkError, error_for
18
+
19
+ DEFAULT_BASE_URL = "https://llms.ink"
20
+ MODES = ("llms", "full")
21
+
22
+
23
+ @dataclass
24
+ class Segment:
25
+ """A transcript line; ``start`` and ``end`` are seconds into the source video."""
26
+
27
+ start: float
28
+ end: float
29
+ text: str
30
+
31
+
32
+ @dataclass
33
+ class Transcript:
34
+ source: str
35
+ segments: List[Segment]
36
+
37
+
38
+ @dataclass
39
+ class Media:
40
+ """Video results only: what ``/api/v0/frame`` and ``/api/v0/media`` can serve."""
41
+
42
+ duration: Optional[float]
43
+ has_video: bool
44
+ has_audio: bool
45
+ thumbnail: Optional[str]
46
+ downloads: List[str]
47
+ is_live: Optional[bool] = None
48
+ transcript: Optional[Transcript] = None
49
+
50
+ @classmethod
51
+ def from_json(cls, d: Dict[str, Any]) -> "Media":
52
+ t = d.get("transcript")
53
+ return cls(
54
+ duration=d.get("duration"),
55
+ has_video=bool(d.get("hasVideo")),
56
+ has_audio=bool(d.get("hasAudio")),
57
+ thumbnail=d.get("thumbnail"),
58
+ downloads=list(d.get("downloads") or []),
59
+ is_live=d.get("isLive"),
60
+ transcript=Transcript(
61
+ source=t.get("source", ""),
62
+ segments=[Segment(s["start"], s["end"], s["text"]) for s in t.get("segments") or []],
63
+ )
64
+ if t
65
+ else None,
66
+ )
67
+
68
+
69
+ @dataclass
70
+ class Result:
71
+ """``GET /api/v0/llms``: the llms.txt or llms-full.txt of a link and what it was built from.
72
+
73
+ ``kind`` is ``site``, ``section``, ``page``, ``repo``, ``document`` or ``video``; ``source`` is ``native``
74
+ (published by the site) or ``generated`` (made by llms.ink). ``text`` is the Markdown file.
75
+ """
76
+
77
+ url: str
78
+ kind: str
79
+ source: str
80
+ mode: str
81
+ title: str
82
+ text: str
83
+ tokens: int
84
+ pages: int
85
+ model: Optional[str]
86
+ cached: bool
87
+ generated_at: str
88
+ media: Optional[Media] = None
89
+ raw: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
90
+
91
+ @classmethod
92
+ def from_json(cls, d: Dict[str, Any]) -> "Result":
93
+ return cls(
94
+ url=d.get("url", ""),
95
+ kind=d.get("kind", ""),
96
+ source=d.get("source", ""),
97
+ mode=d.get("mode", ""),
98
+ title=d.get("title", ""),
99
+ text=d.get("text", ""),
100
+ tokens=d.get("tokens", 0),
101
+ pages=d.get("pages", 0),
102
+ model=d.get("model"),
103
+ cached=bool(d.get("cached")),
104
+ generated_at=d.get("generatedAt", ""),
105
+ media=Media.from_json(d["media"]) if d.get("media") else None,
106
+ raw=d,
107
+ )
108
+
109
+
110
+ @dataclass
111
+ class CheckIssue:
112
+ """``level`` is ``error``, ``warning`` or ``info``; ``code`` names the check (``missing_title``, ``broken_link``…)."""
113
+
114
+ level: str
115
+ code: str
116
+ line: Optional[int]
117
+ message: str
118
+
119
+
120
+ @dataclass
121
+ class BrokenLink:
122
+ url: str
123
+ status: int # 0 when it didn't answer in time
124
+
125
+
126
+ @dataclass
127
+ class CheckLinks:
128
+ total: int
129
+ checked: int
130
+ broken: List[BrokenLink]
131
+
132
+
133
+ @dataclass
134
+ class CheckSummary:
135
+ errors: int
136
+ warnings: int
137
+
138
+
139
+ @dataclass
140
+ class CheckResult:
141
+ """``GET /api/v0/check``: a published llms.txt checked against the format and its links."""
142
+
143
+ site: str
144
+ url: str
145
+ found: bool
146
+ status: int
147
+ title: Optional[str]
148
+ tokens: int
149
+ sections: int
150
+ links: CheckLinks
151
+ llms_full: bool
152
+ issues: List[CheckIssue]
153
+ summary: CheckSummary
154
+ checked_at: str
155
+ raw: Dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
156
+
157
+ @classmethod
158
+ def from_json(cls, d: Dict[str, Any]) -> "CheckResult":
159
+ links = d.get("links") or {}
160
+ summary = d.get("summary") or {}
161
+ return cls(
162
+ site=d.get("site", ""),
163
+ url=d.get("url", ""),
164
+ found=bool(d.get("found")),
165
+ status=d.get("status", 0),
166
+ title=d.get("title"),
167
+ tokens=d.get("tokens", 0),
168
+ sections=d.get("sections", 0),
169
+ links=CheckLinks(
170
+ total=links.get("total", 0),
171
+ checked=links.get("checked", 0),
172
+ broken=[BrokenLink(b["url"], b["status"]) for b in links.get("broken") or []],
173
+ ),
174
+ llms_full=bool(d.get("llmsFull")),
175
+ issues=[CheckIssue(i["level"], i["code"], i.get("line"), i["message"]) for i in d.get("issues") or []],
176
+ summary=CheckSummary(errors=summary.get("errors", 0), warnings=summary.get("warnings", 0)),
177
+ checked_at=d.get("checkedAt", ""),
178
+ raw=d,
179
+ )
180
+
181
+
182
+ class Client:
183
+ """A client for https://llms.ink.
184
+
185
+ ``key`` is an optional API key (``lk_…``), sent as ``Authorization: Bearer``; it defaults to the
186
+ ``LLMS_INK_KEY`` environment variable. Without one, requests are anonymous and share the free limits.
187
+ ``base_url`` defaults to ``LLMS_INK_API`` or https://llms.ink. Building a big site or a video can take
188
+ minutes, hence the long default ``timeout`` (seconds).
189
+ """
190
+
191
+ def __init__(
192
+ self,
193
+ key: Optional[str] = None,
194
+ *,
195
+ base_url: Optional[str] = None,
196
+ timeout: float = 300.0,
197
+ ) -> None:
198
+ key = key if key is not None else os.environ.get("LLMS_INK_KEY")
199
+ self.key = key.strip() if key and key.strip() else None
200
+ self.base_url = (base_url or os.environ.get("LLMS_INK_API") or DEFAULT_BASE_URL).rstrip("/")
201
+ self.timeout = timeout
202
+
203
+ def __repr__(self) -> str:
204
+ return f"Client(base_url={self.base_url!r}, key={'set' if self.key else None!r})"
205
+
206
+ def llms(self, url: str, mode: str = "llms", fresh: bool = False) -> Result:
207
+ """llms.txt (``mode='llms'``, an index of links) or llms-full.txt (``mode='full'``, the content inlined)
208
+ for any public link. ``fresh=True`` skips the cache and rebuilds (counts against the daily quota)."""
209
+ if mode not in MODES:
210
+ raise ValueError(f"mode must be 'llms' (the index) or 'full' (llms-full.txt), not {mode!r}")
211
+ params = {"url": url, "mode": mode}
212
+ if fresh:
213
+ params["fresh"] = "1"
214
+ return Result.from_json(self._get("/api/v0/llms", params))
215
+
216
+ def check(self, url: str) -> CheckResult:
217
+ """Checks a site's published llms.txt against the format and that its links answer."""
218
+ return CheckResult.from_json(self._get("/api/v0/check", {"url": url}))
219
+
220
+ def _get(self, path: str, params: Dict[str, str]) -> Dict[str, Any]:
221
+ headers = {"Accept": "application/json", "User-Agent": f"llms-ink-python/{__version__}"}
222
+ if self.key:
223
+ headers["Authorization"] = f"Bearer {self.key}"
224
+ request = urllib.request.Request(f"{self.base_url}{path}?{urllib.parse.urlencode(params)}", headers=headers)
225
+ try:
226
+ with urllib.request.urlopen(request, timeout=self.timeout) as res:
227
+ body = res.read()
228
+ except urllib.error.HTTPError as err:
229
+ raise _problem(err) from None
230
+ except (urllib.error.URLError, socket.timeout, TimeoutError, ConnectionError) as err:
231
+ reason = getattr(err, "reason", err)
232
+ raise LlmsInkError(f"Couldn't reach {self.base_url}: {reason}") from err
233
+ try:
234
+ return json.loads(body)
235
+ except ValueError as err:
236
+ raise LlmsInkError(f"{self.base_url}{path} answered with something other than JSON.") from err
237
+
238
+
239
+ def _problem(err: urllib.error.HTTPError) -> LlmsInkError:
240
+ """An HTTP error response → the typed error for its problem ``code``."""
241
+ raw = err.read()
242
+ try:
243
+ data = json.loads(raw)
244
+ except ValueError:
245
+ data = None
246
+ if not isinstance(data, dict):
247
+ data = {}
248
+ status = data.get("status") if isinstance(data.get("status"), int) else err.code
249
+ detail = data.get("detail") or data.get("title") or f"HTTP {err.code} {err.reason or 'error'}"
250
+ code = data.get("code")
251
+ return error_for(code)(
252
+ detail,
253
+ status=status,
254
+ code=code,
255
+ title=data.get("title"),
256
+ errors=data.get("errors"),
257
+ retry_after=_retry_after(err.headers.get("Retry-After") if err.headers else None),
258
+ )
259
+
260
+
261
+ def _retry_after(value: Optional[str]) -> Optional[int]:
262
+ """``Retry-After`` as seconds: delta-seconds, or an HTTP date turned into seconds from now."""
263
+ if not value:
264
+ return None
265
+ value = value.strip()
266
+ if value.isdigit():
267
+ return int(value)
268
+ try:
269
+ when = parsedate_to_datetime(value)
270
+ except (TypeError, ValueError):
271
+ return None
272
+ if when is None:
273
+ return None
274
+ if when.tzinfo is None:
275
+ when = when.replace(tzinfo=timezone.utc)
276
+ return max(0, int((when - datetime.now(timezone.utc)).total_seconds()))
277
+
278
+
279
+ def _metadata(result: Result) -> Dict[str, Any]:
280
+ """Document metadata for the LangChain and LlamaIndex loaders."""
281
+ return {
282
+ "url": result.url,
283
+ "title": result.title,
284
+ "kind": result.kind,
285
+ "source": result.source,
286
+ "mode": result.mode,
287
+ "tokens": result.tokens,
288
+ "generated_at": result.generated_at,
289
+ }
@@ -0,0 +1,56 @@
1
+ """Typed errors from the API's RFC 9457 problem details (codes at https://llms.ink/docs/#errors)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List, Optional
6
+
7
+
8
+ class LlmsInkError(Exception):
9
+ """An API error, or llms.ink couldn't be reached (then ``status`` and ``code`` are ``None``).
10
+
11
+ ``code`` is the problem's machine-readable code (``invalid_url``, ``unprocessable``, ``upstream_error``…),
12
+ ``detail`` the human-readable message, ``errors`` the per-parameter issues of ``invalid_request``.
13
+ ``retry_after`` is the ``Retry-After`` header in seconds, when the response had one.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ detail: str,
19
+ *,
20
+ status: Optional[int] = None,
21
+ code: Optional[str] = None,
22
+ title: Optional[str] = None,
23
+ errors: Optional[List[Dict[str, Any]]] = None,
24
+ retry_after: Optional[int] = None,
25
+ ) -> None:
26
+ super().__init__(detail)
27
+ self.detail = detail
28
+ self.status = status
29
+ self.code = code
30
+ self.title = title
31
+ self.errors = errors or []
32
+ self.retry_after = retry_after
33
+
34
+ def __repr__(self) -> str:
35
+ return f"{type(self).__name__}(status={self.status!r}, code={self.code!r}, detail={self.detail!r})"
36
+
37
+
38
+ class InvalidKey(LlmsInkError):
39
+ """401 ``invalid_key``: the API key is unknown or revoked. Requests never fall back to anonymous."""
40
+
41
+
42
+ class RateLimited(LlmsInkError):
43
+ """429 ``rate_limited``: too many requests this minute; wait ``retry_after`` seconds."""
44
+
45
+
46
+ class QuotaExceeded(LlmsInkError):
47
+ """429 ``quota_exceeded``: the daily quota of new links is used up; ``retry_after`` is the seconds until it
48
+ resets (midnight UTC). Cached links still work."""
49
+
50
+
51
+ _BY_CODE = {"invalid_key": InvalidKey, "rate_limited": RateLimited, "quota_exceeded": QuotaExceeded}
52
+
53
+
54
+ def error_for(code: Optional[str]) -> type:
55
+ """The exception class for a problem ``code``."""
56
+ return _BY_CODE.get(code or "", LlmsInkError)
@@ -0,0 +1,44 @@
1
+ """LangChain document loader: ``pip install 'llms-ink[langchain]'``.
2
+
3
+ >>> from llms_ink.langchain import LlmsInkLoader
4
+ >>> docs = LlmsInkLoader(["docs.astro.build/en/guides/"], mode="full").load()
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Iterator, Optional, Sequence, Union
10
+
11
+ try:
12
+ from langchain_core.document_loaders import BaseLoader
13
+ from langchain_core.documents import Document
14
+ except ImportError as err: # pragma: no cover
15
+ raise ImportError("LlmsInkLoader needs langchain-core: pip install 'llms-ink[langchain]'") from err
16
+
17
+ from .client import MODES, Client, _metadata
18
+
19
+
20
+ class LlmsInkLoader(BaseLoader):
21
+ """One Document per link: llms.txt (``mode='llms'``) or llms-full.txt (``mode='full'``) as
22
+ ``page_content``, with metadata ``url``, ``title``, ``kind``, ``source``, ``mode``, ``tokens`` and
23
+ ``generated_at``. ``key`` defaults to ``LLMS_INK_KEY``; pass ``client`` to reuse one."""
24
+
25
+ def __init__(
26
+ self,
27
+ urls: Union[str, Sequence[str]],
28
+ mode: str = "llms",
29
+ *,
30
+ fresh: bool = False,
31
+ key: Optional[str] = None,
32
+ client: Optional[Client] = None,
33
+ ) -> None:
34
+ if mode not in MODES:
35
+ raise ValueError(f"mode must be 'llms' or 'full', not {mode!r}")
36
+ self.urls = [urls] if isinstance(urls, str) else list(urls)
37
+ self.mode = mode
38
+ self.fresh = fresh
39
+ self.client = client or Client(key)
40
+
41
+ def lazy_load(self) -> Iterator[Document]:
42
+ for url in self.urls:
43
+ result = self.client.llms(url, mode=self.mode, fresh=self.fresh)
44
+ yield Document(page_content=result.text, metadata=_metadata(result))
@@ -0,0 +1,46 @@
1
+ """LlamaIndex reader: ``pip install 'llms-ink[llama-index]'``.
2
+
3
+ >>> from llms_ink.llama_index import LlmsInkReader
4
+ >>> docs = LlmsInkReader(mode="full").load_data(["docs.astro.build/en/guides/"])
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Iterator, List, Optional, Union
10
+
11
+ try:
12
+ from llama_index.core import Document
13
+ from llama_index.core.readers.base import BaseReader
14
+ except ImportError as err: # pragma: no cover
15
+ raise ImportError("LlmsInkReader needs llama-index-core: pip install 'llms-ink[llama-index]'") from err
16
+
17
+ from .client import MODES, Client, _metadata
18
+
19
+
20
+ class LlmsInkReader(BaseReader):
21
+ """One Document per link: llms.txt (``mode='llms'``) or llms-full.txt (``mode='full'``) as text, with
22
+ metadata ``url``, ``title``, ``kind``, ``source``, ``mode``, ``tokens`` and ``generated_at``.
23
+ ``key`` defaults to ``LLMS_INK_KEY``; pass ``client`` to reuse one."""
24
+
25
+ def __init__(
26
+ self,
27
+ mode: str = "llms",
28
+ *,
29
+ fresh: bool = False,
30
+ key: Optional[str] = None,
31
+ client: Optional[Client] = None,
32
+ ) -> None:
33
+ if mode not in MODES:
34
+ raise ValueError(f"mode must be 'llms' or 'full', not {mode!r}")
35
+ self.mode = mode
36
+ self.fresh = fresh
37
+ self.client = client or Client(key)
38
+
39
+ def lazy_load_data(self, urls: Union[str, List[str]], mode: Optional[str] = None) -> Iterator[Document]:
40
+ """Documents for ``urls``; ``mode`` overrides the reader's for this call."""
41
+ for url in [urls] if isinstance(urls, str) else urls:
42
+ result = self.client.llms(url, mode=mode or self.mode, fresh=self.fresh)
43
+ yield Document(text=result.text, metadata=_metadata(result))
44
+
45
+ def load_data(self, urls: Union[str, List[str]], mode: Optional[str] = None) -> List[Document]:
46
+ return list(self.lazy_load_data(urls, mode))
File without changes
File without changes
@@ -0,0 +1,134 @@
1
+ """A local stub of the llms.ink API, answering like the real one for a few known links."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import threading
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+ from typing import Any, Dict, List
9
+ from urllib.parse import parse_qs, urlsplit
10
+
11
+ import pytest
12
+
13
+ LLMS = "# llms.txt\n\n> A proposal to standardise on using an /llms.txt file.\n\n## Docs\n\n- [Intro](https://llmstxt.org/intro.html)\n"
14
+ FULL = "# llms.txt\n\n> A proposal.\n\n## Intro\n\nThe whole page.\n"
15
+ GOOD_KEY = "lk_" + "a" * 32
16
+
17
+
18
+ def result(url: str, mode: str) -> Dict[str, Any]:
19
+ body: Dict[str, Any] = {
20
+ "url": f"https://{url}/",
21
+ "kind": "site",
22
+ "source": "native",
23
+ "mode": mode,
24
+ "title": "llms.txt",
25
+ "text": LLMS if mode == "llms" else FULL,
26
+ "tokens": 42,
27
+ "pages": 1,
28
+ "model": None,
29
+ "cached": True,
30
+ "generatedAt": "2026-11-14T00:00:00.000Z",
31
+ }
32
+ if url.startswith("youtu.be"):
33
+ body.update(
34
+ kind="video",
35
+ source="generated",
36
+ model="gpt",
37
+ media={
38
+ "duration": 61.5,
39
+ "hasVideo": True,
40
+ "hasAudio": True,
41
+ "thumbnail": "https://i.ytimg.com/vi/x/hq.jpg",
42
+ "downloads": ["mp4", "m4a"],
43
+ "transcript": {"source": "captions", "segments": [{"start": 0, "end": 2.5, "text": "Hello"}]},
44
+ },
45
+ )
46
+ return body
47
+
48
+
49
+ CHECK = {
50
+ "site": "https://llmstxt.org",
51
+ "url": "https://llmstxt.org/llms.txt",
52
+ "found": True,
53
+ "status": 200,
54
+ "title": "llms.txt",
55
+ "tokens": 300,
56
+ "sections": 2,
57
+ "links": {"total": 3, "checked": 3, "broken": [{"url": "https://llmstxt.org/gone", "status": 404}]},
58
+ "llmsFull": False,
59
+ "issues": [{"level": "error", "code": "broken_link", "line": 9, "message": "https://llmstxt.org/gone answers HTTP 404."}],
60
+ "summary": {"errors": 1, "warnings": 0},
61
+ "checkedAt": "2026-11-14T00:00:00.000Z",
62
+ }
63
+
64
+ PROBLEMS = {
65
+ "quota.test": (429, "quota_exceeded", "Daily quota of new links used up.", {"Retry-After": "3600"}),
66
+ "busy.test": (429, "rate_limited", "Too many requests this minute.", {"Retry-After": "12"}),
67
+ "login.test": (422, "unprocessable", "That page needs a login.", {}),
68
+ }
69
+
70
+
71
+ class Stub:
72
+ def __init__(self) -> None:
73
+ self.requests: List[Dict[str, Any]] = []
74
+ self.base_url = ""
75
+
76
+ @property
77
+ def last(self) -> Dict[str, Any]:
78
+ return self.requests[-1]
79
+
80
+
81
+ def handler(stub: Stub):
82
+ class Handler(BaseHTTPRequestHandler):
83
+ def log_message(self, *args: Any) -> None:
84
+ pass
85
+
86
+ def send(self, status: int, body: Any, content_type: str = "application/json", headers: Dict[str, str] = {}) -> None:
87
+ data = body.encode() if isinstance(body, str) else json.dumps(body).encode()
88
+ self.send_response(status)
89
+ self.send_header("Content-Type", content_type)
90
+ self.send_header("Content-Length", str(len(data)))
91
+ for k, v in headers.items():
92
+ self.send_header(k, v)
93
+ self.end_headers()
94
+ self.wfile.write(data)
95
+
96
+ def problem(self, status: int, code: str, detail: str, headers: Dict[str, str] = {}) -> None:
97
+ body = {"type": f"{stub.base_url}/docs/#errors", "title": "Error", "status": status, "code": code, "detail": detail}
98
+ self.send(status, body, "application/problem+json", headers)
99
+
100
+ def do_GET(self) -> None:
101
+ parts = urlsplit(self.path)
102
+ query = {k: v[0] for k, v in parse_qs(parts.query).items()}
103
+ stub.requests.append({"path": parts.path, "query": query, "headers": dict(self.headers)})
104
+ auth = self.headers.get("Authorization")
105
+ if auth and auth != f"Bearer {GOOD_KEY}":
106
+ return self.problem(401, "invalid_key", "That API key is unknown or revoked.")
107
+ url = query.get("url", "")
108
+ if url == "html.test":
109
+ return self.send(502, "<html>Bad gateway</html>", "text/html")
110
+ if url in PROBLEMS:
111
+ status, code, detail, headers = PROBLEMS[url]
112
+ return self.problem(status, code, detail, headers)
113
+ if parts.path == "/api/v0/llms":
114
+ return self.send(200, result(url, query.get("mode", "llms")))
115
+ if parts.path == "/api/v0/check":
116
+ return self.send(200, CHECK)
117
+ self.problem(404, "not_found", "Not found.")
118
+
119
+ return Handler
120
+
121
+
122
+ @pytest.fixture
123
+ def stub(monkeypatch: pytest.MonkeyPatch):
124
+ """The stub server, with LLMS_INK_API pointing at it and LLMS_INK_KEY unset."""
125
+ s = Stub()
126
+ server = ThreadingHTTPServer(("127.0.0.1", 0), handler(s))
127
+ s.base_url = f"http://127.0.0.1:{server.server_address[1]}"
128
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
129
+ thread.start()
130
+ monkeypatch.setenv("LLMS_INK_API", s.base_url)
131
+ monkeypatch.delenv("LLMS_INK_KEY", raising=False)
132
+ yield s
133
+ server.shutdown()
134
+ server.server_close()
@@ -0,0 +1,43 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import sys
5
+
6
+ from llms_ink.__main__ import main
7
+
8
+ from .conftest import FULL, LLMS
9
+
10
+
11
+ def test_prints_llms_txt(stub, capsys):
12
+ assert main(["llmstxt.org"]) == 0
13
+ assert capsys.readouterr().out == LLMS
14
+ assert stub.last["query"] == {"url": "llmstxt.org", "mode": "llms"}
15
+
16
+
17
+ def test_full_and_fresh(stub, capsys):
18
+ assert main(["llmstxt.org", "--full", "--fresh"]) == 0
19
+ assert capsys.readouterr().out == FULL
20
+ assert stub.last["query"] == {"url": "llmstxt.org", "mode": "full", "fresh": "1"}
21
+
22
+
23
+ def test_json(stub, capsys):
24
+ assert main(["llmstxt.org", "--json"]) == 0
25
+ assert json.loads(capsys.readouterr().out)["generatedAt"] == "2026-11-14T00:00:00.000Z"
26
+
27
+
28
+ def test_errors_go_to_stderr_with_exit_1(stub, capsys):
29
+ assert main(["quota.test"]) == 1
30
+ out = capsys.readouterr()
31
+ assert out.out == ""
32
+ assert out.err == "llms-ink: Daily quota of new links used up. Try again in 3600s.\n"
33
+
34
+
35
+ def test_runs_as_a_module(stub):
36
+ env = {**os.environ, "LLMS_INK_API": stub.base_url}
37
+ env.pop("LLMS_INK_KEY", None)
38
+ ok = subprocess.run([sys.executable, "-m", "llms_ink", "llmstxt.org"], env=env, capture_output=True, text=True)
39
+ assert (ok.returncode, ok.stdout) == (0, LLMS)
40
+ bad = subprocess.run([sys.executable, "-m", "llms_ink", "login.test"], env=env, capture_output=True, text=True)
41
+ assert (bad.returncode, bad.stderr) == (1, "llms-ink: That page needs a login.\n")
42
+ usage = subprocess.run([sys.executable, "-m", "llms_ink"], env=env, capture_output=True, text=True)
43
+ assert usage.returncode == 2
@@ -0,0 +1,136 @@
1
+ import pytest
2
+
3
+ from llms_ink import (
4
+ Client,
5
+ InvalidKey,
6
+ LlmsInkError,
7
+ QuotaExceeded,
8
+ RateLimited,
9
+ Result,
10
+ __version__,
11
+ )
12
+
13
+ from .conftest import FULL, GOOD_KEY, LLMS
14
+
15
+
16
+ def test_llms_returns_the_index_as_a_result(stub):
17
+ r = Client().llms("llmstxt.org")
18
+ assert isinstance(r, Result)
19
+ assert r.text == LLMS
20
+ assert (r.url, r.kind, r.source, r.mode, r.title) == ("https://llmstxt.org/", "site", "native", "llms", "llms.txt")
21
+ assert (r.tokens, r.pages, r.model, r.cached, r.generated_at) == (42, 1, None, True, "2026-11-14T00:00:00.000Z")
22
+ assert r.media is None
23
+ assert r.raw["generatedAt"] == r.generated_at
24
+ assert stub.last["path"] == "/api/v0/llms"
25
+ assert stub.last["query"] == {"url": "llmstxt.org", "mode": "llms"}
26
+ assert "Authorization" not in stub.last["headers"]
27
+ assert stub.last["headers"]["User-Agent"] == f"llms-ink-python/{__version__}"
28
+ assert stub.last["headers"]["Accept"] == "application/json"
29
+
30
+
31
+ def test_full_mode_and_fresh(stub):
32
+ r = Client().llms("llmstxt.org", mode="full", fresh=True)
33
+ assert (r.mode, r.text) == ("full", FULL)
34
+ assert stub.last["query"] == {"url": "llmstxt.org", "mode": "full", "fresh": "1"}
35
+
36
+
37
+ def test_bad_mode_fails_before_any_request(stub):
38
+ with pytest.raises(ValueError, match="mode"):
39
+ Client().llms("llmstxt.org", mode="index")
40
+ assert stub.requests == []
41
+
42
+
43
+ def test_video_results_have_media(stub):
44
+ m = Client().llms("youtu.be/x").media
45
+ assert m is not None
46
+ assert (m.duration, m.has_video, m.has_audio, m.downloads, m.is_live) == (61.5, True, True, ["mp4", "m4a"], None)
47
+ assert m.transcript.source == "captions"
48
+ assert (m.transcript.segments[0].start, m.transcript.segments[0].text) == (0, "Hello")
49
+
50
+
51
+ def test_check(stub):
52
+ c = Client().check("llmstxt.org")
53
+ assert stub.last["path"] == "/api/v0/check"
54
+ assert stub.last["query"] == {"url": "llmstxt.org"}
55
+ assert (c.found, c.status, c.title, c.sections, c.llms_full) == (True, 200, "llms.txt", 2, False)
56
+ assert (c.links.total, c.links.broken[0].url, c.links.broken[0].status) == (3, "https://llmstxt.org/gone", 404)
57
+ assert (c.issues[0].level, c.issues[0].code, c.issues[0].line) == ("error", "broken_link", 9)
58
+ assert (c.summary.errors, c.summary.warnings, c.checked_at) == (1, 0, "2026-11-14T00:00:00.000Z")
59
+
60
+
61
+ def test_key_argument_is_sent_as_bearer(stub):
62
+ Client(GOOD_KEY).llms("llmstxt.org")
63
+ assert stub.last["headers"]["Authorization"] == f"Bearer {GOOD_KEY}"
64
+
65
+
66
+ def test_key_comes_from_env_and_the_argument_wins(stub, monkeypatch):
67
+ monkeypatch.setenv("LLMS_INK_KEY", GOOD_KEY)
68
+ Client().llms("llmstxt.org")
69
+ assert stub.last["headers"]["Authorization"] == f"Bearer {GOOD_KEY}"
70
+ with pytest.raises(InvalidKey):
71
+ Client("lk_other").llms("llmstxt.org")
72
+ assert stub.last["headers"]["Authorization"] == "Bearer lk_other"
73
+
74
+
75
+ def test_blank_env_key_is_anonymous(stub, monkeypatch):
76
+ monkeypatch.setenv("LLMS_INK_KEY", " ")
77
+ Client().llms("llmstxt.org")
78
+ assert "Authorization" not in stub.last["headers"]
79
+
80
+
81
+ def test_invalid_key_raises_and_never_falls_back(stub):
82
+ with pytest.raises(InvalidKey) as info:
83
+ Client("lk_revoked").llms("llmstxt.org")
84
+ assert (info.value.status, info.value.code) == (401, "invalid_key")
85
+ assert info.value.detail == "That API key is unknown or revoked."
86
+ assert len(stub.requests) == 1
87
+
88
+
89
+ def test_quota_exceeded_has_retry_after(stub):
90
+ with pytest.raises(QuotaExceeded) as info:
91
+ Client().llms("quota.test")
92
+ assert (info.value.status, info.value.code, info.value.retry_after) == (429, "quota_exceeded", 3600)
93
+ assert isinstance(info.value, LlmsInkError)
94
+
95
+
96
+ def test_rate_limited_has_retry_after(stub):
97
+ with pytest.raises(RateLimited) as info:
98
+ Client().llms("busy.test")
99
+ assert (info.value.code, info.value.retry_after) == ("rate_limited", 12)
100
+
101
+
102
+ def test_other_problems_are_llms_ink_errors(stub):
103
+ with pytest.raises(LlmsInkError) as info:
104
+ Client().llms("login.test")
105
+ assert type(info.value) is LlmsInkError
106
+ assert (info.value.status, info.value.code, info.value.detail) == (422, "unprocessable", "That page needs a login.")
107
+ assert str(info.value) == "That page needs a login."
108
+
109
+
110
+ def test_non_json_errors_keep_the_status(stub):
111
+ with pytest.raises(LlmsInkError) as info:
112
+ Client().llms("html.test")
113
+ assert (info.value.status, info.value.code) == (502, None)
114
+ assert "502" in info.value.detail
115
+
116
+
117
+ def test_unreachable_api():
118
+ with pytest.raises(LlmsInkError, match="Couldn't reach http://127.0.0.1:9") as info:
119
+ Client(base_url="http://127.0.0.1:9", timeout=5).llms("llmstxt.org")
120
+ assert info.value.status is None
121
+
122
+
123
+ def test_base_url_argument_and_trailing_slash(stub, monkeypatch):
124
+ monkeypatch.setenv("LLMS_INK_API", "http://127.0.0.1:9")
125
+ assert Client(base_url=stub.base_url + "/").llms("llmstxt.org").text == LLMS
126
+ monkeypatch.delenv("LLMS_INK_API")
127
+ assert Client().base_url == "https://llms.ink"
128
+
129
+
130
+ def test_retry_after_parsing():
131
+ from llms_ink.client import _retry_after
132
+
133
+ assert _retry_after(None) is None
134
+ assert _retry_after("120") == 120
135
+ assert _retry_after("Wed, 21 Oct 2015 07:28:00 GMT") == 0
136
+ assert _retry_after("soon") is None
@@ -0,0 +1,16 @@
1
+ """One real call to https://llms.ink. Skipped by default; run with: pytest -m live"""
2
+
3
+ import pytest
4
+
5
+ from llms_ink import Client
6
+
7
+ pytestmark = pytest.mark.live
8
+
9
+
10
+ def test_llmstxt_org(monkeypatch):
11
+ monkeypatch.delenv("LLMS_INK_API", raising=False)
12
+ r = Client().llms("llmstxt.org")
13
+ assert r.mode == "llms"
14
+ assert r.text.startswith("# ")
15
+ assert r.tokens > 0
16
+ assert r.url.startswith("https://llmstxt.org")
@@ -0,0 +1,75 @@
1
+ import pytest
2
+
3
+ from llms_ink import Client, QuotaExceeded
4
+
5
+ from .conftest import FULL, GOOD_KEY, LLMS
6
+
7
+ METADATA = {
8
+ "url": "https://llmstxt.org/",
9
+ "title": "llms.txt",
10
+ "kind": "site",
11
+ "source": "native",
12
+ "mode": "full",
13
+ "tokens": 42,
14
+ "generated_at": "2026-11-14T00:00:00.000Z",
15
+ }
16
+
17
+
18
+ def test_langchain_loader(stub):
19
+ from langchain_core.document_loaders import BaseLoader
20
+ from langchain_core.documents import Document
21
+
22
+ from llms_ink.langchain import LlmsInkLoader
23
+
24
+ loader = LlmsInkLoader(["llmstxt.org", "youtu.be/x"], mode="full")
25
+ assert isinstance(loader, BaseLoader)
26
+ docs = loader.load()
27
+ assert [type(d) for d in docs] == [Document, Document]
28
+ assert docs[0].page_content == FULL
29
+ assert docs[0].metadata == METADATA
30
+ assert docs[1].metadata["kind"] == "video"
31
+ assert [r["query"]["url"] for r in stub.requests] == ["llmstxt.org", "youtu.be/x"]
32
+
33
+
34
+ def test_langchain_loader_takes_one_url_a_key_and_a_client(stub):
35
+ from llms_ink.langchain import LlmsInkLoader
36
+
37
+ docs = list(LlmsInkLoader("llmstxt.org", key=GOOD_KEY).lazy_load())
38
+ assert docs[0].page_content == LLMS
39
+ assert docs[0].metadata["mode"] == "llms"
40
+ assert stub.last["headers"]["Authorization"] == f"Bearer {GOOD_KEY}"
41
+ client = Client()
42
+ assert LlmsInkLoader("x", client=client).client is client
43
+ with pytest.raises(ValueError):
44
+ LlmsInkLoader("x", mode="index")
45
+
46
+
47
+ def test_langchain_loader_raises_api_errors(stub):
48
+ from llms_ink.langchain import LlmsInkLoader
49
+
50
+ with pytest.raises(QuotaExceeded):
51
+ LlmsInkLoader(["quota.test"]).load()
52
+
53
+
54
+ def test_llama_index_reader(stub):
55
+ from llama_index.core import Document
56
+ from llama_index.core.readers.base import BaseReader
57
+
58
+ from llms_ink.llama_index import LlmsInkReader
59
+
60
+ reader = LlmsInkReader(mode="full")
61
+ assert isinstance(reader, BaseReader)
62
+ docs = reader.load_data(["llmstxt.org"])
63
+ assert [type(d) for d in docs] == [Document]
64
+ assert docs[0].text == FULL
65
+ assert docs[0].metadata == METADATA
66
+ assert [d.text for d in reader.load_data("llmstxt.org", mode="llms")] == [LLMS]
67
+ assert stub.last["query"]["mode"] == "llms"
68
+
69
+
70
+ def test_llama_index_reader_key_and_fresh(stub):
71
+ from llms_ink.llama_index import LlmsInkReader
72
+
73
+ list(LlmsInkReader(fresh=True, key=GOOD_KEY).lazy_load_data(["llmstxt.org"]))
74
+ assert stub.last["query"] == {"url": "llmstxt.org", "mode": "llms", "fresh": "1"}
75
+ assert stub.last["headers"]["Authorization"] == f"Bearer {GOOD_KEY}"