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.
- llms_ink-0.1.0/.gitignore +5 -0
- llms_ink-0.1.0/CHANGELOG.md +9 -0
- llms_ink-0.1.0/LICENSE +21 -0
- llms_ink-0.1.0/PKG-INFO +134 -0
- llms_ink-0.1.0/README.md +109 -0
- llms_ink-0.1.0/pyproject.toml +47 -0
- llms_ink-0.1.0/src/llms_ink/__init__.py +41 -0
- llms_ink-0.1.0/src/llms_ink/__main__.py +45 -0
- llms_ink-0.1.0/src/llms_ink/client.py +289 -0
- llms_ink-0.1.0/src/llms_ink/errors.py +56 -0
- llms_ink-0.1.0/src/llms_ink/langchain.py +44 -0
- llms_ink-0.1.0/src/llms_ink/llama_index.py +46 -0
- llms_ink-0.1.0/src/llms_ink/py.typed +0 -0
- llms_ink-0.1.0/tests/__init__.py +0 -0
- llms_ink-0.1.0/tests/conftest.py +134 -0
- llms_ink-0.1.0/tests/test_cli.py +43 -0
- llms_ink-0.1.0/tests/test_client.py +136 -0
- llms_ink-0.1.0/tests/test_live.py +16 -0
- llms_ink-0.1.0/tests/test_loaders.py +75 -0
|
@@ -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.
|
llms_ink-0.1.0/PKG-INFO
ADDED
|
@@ -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.
|
llms_ink-0.1.0/README.md
ADDED
|
@@ -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}"
|