Files
docs/scripts/check_llms_urls.py
Lauren Hirata Singh 2276580e29 Name every llms.txt section index correctly so all are served (#5518)
## Why

Coverage fell from ~100% to **57%** after #5514. This is my regression,
and the cause is a Mintlify behavior I assumed rather than tested.

**Mintlify serves the exact filename `llms.txt` at any path, and 404s on
anything else.** The numbered variants my split produced were never
reachable:

| URL | status |
|---|---|
| `/oss/python/llms.txt` | 200 |
| `/oss/python/llms-2.txt` | **404** |
| `/oss/python/llms-3.txt` | **404** |
| `/langsmith/smith-api/llms.txt` | 200 |
| `/langsmith/smith-api/llms-2.txt` | **404** |

Only 4 of 9 section indexes were reachable. That left 1,229 visible
links — 1,112 across the four served files plus 117 inline in the root —
which is exactly what the audit reported: 883 sitemap pages plus 346
links not in the sitemap. The other **802 pages did not exist as far as
any agent was concerned**.

Confirmed the rule is filename-based, not path-based:
`.well-known/security.txt` is in the build and 404s, while
`/oss/python/llms.txt` byte-matches the file my build produced, so
Mintlify is serving my static file by name.

## What changed

Sections now split **by directory** rather than by filename. Every index
is written as `<prefix>/llms.txt`.

An oversized section sheds its heaviest child directories into their own
indexes until the remainder fits, instead of giving every child its own
file — a naive recursive split produced 109 files, most of them a single
page. Descriptions are also dropped from section indexes, which roughly
halves each entry; the root keeps them, since it has room.

| | before | after |
|---|---|---|
| root | 16,449 chars | 21,656 chars |
| section indexes | 9 (5 unreachable) | 56 (all reachable) |
| largest section | 40,101 | 42,263 |
| pages indexed | 2,038 (1,229 visible) | 2,038 |

All 56 are named `llms.txt`, none exceed 50,000 characters, and each is
one hop from the root so the coverage walker still reaches everything.

## The real gap

Every guard I added in #5514 checked **local files**, and nothing
checked they were **served**. The build-time validator passed happily
while a third of the index was unreachable in production.

`scripts/check_llms_urls.py` now verifies each section index returns 200
before it samples page URLs. Run against production today it correctly
reports 52 of 56 unserved, because the new paths are not deployed yet —
that is the check working. A unit test asserts no index is ever named
`llms-N.txt`.

## Still open: LLMS Full Size (a Mintlify routing issue, not a repo one)

The custom `llms-full.txt` **is** being picked up — Mintlify just serves
it on the wrong route. Probing all four endpoints:

| Route | Bytes | Whose |
|---|---:|---|
| `/llms.txt` | 16,449 | ours |
| `/.well-known/llms.txt` | 99,940 | Mintlify's, **truncated** |
| `/llms-full.txt` | 15,101,956 | Mintlify's |
| `/.well-known/llms-full.txt` | 6,246,717 | ours |

The override is applied to exactly one route per file, and they are
crossed. Verified by content rather than size:
`/.well-known/llms-full.txt` opens with `# Docs by LangChain`, while
`/.well-known/llms.txt` still ends with `_Note: this index was truncated
to stay under 100,000 characters; 569 pages and 3 OpenAPI specs
omitted._`. Neither `.well-known` file exists in our build (it contains
only `security.txt`), so both are served by Mintlify's own routing.

Two consequences:

1. **`LLMS Full Size` will keep failing** while the check reads
`/llms-full.txt`, because that route serves Mintlify's 15.1 MB file
regardless of the custom one. Nothing in this repo changes that.
2. **`/.well-known/llms.txt` serves a truncated index** that omits 569
pages, so any agent following the `.well-known` convention gets the
pre-fix file even though the root is correct.

This looks like a Mintlify bug and needs a support ticket, not a code
change here. Their documentation does not describe how custom files
interact with the `.well-known` mirrors. Separately, **no target size
for `llms-full.txt` is documented** by AFDocs or Mintlify, so even with
the routing fixed I cannot say whether 6.2 MB clears the bar.

## Other checks

- **Markdown Content Parity** (warning): unchanged diagnosis, not a
content defect. The only substantive gap is OpenAPI pages, where the
markdown is *richer* than the HTML. Set both parity thresholds to 0 for
informational mode.
- **LLMS TXT Directive Html** (1 of 15 pages), **Page Size Html** (1
page at 59K markdown from 1,195K HTML, 98% boilerplate), **Redirect
Behavior** (1 cross-host redirect): all single-page warnings in
Mintlify-controlled chrome or intentional `reference.langchain.com`
redirects. No repo change proposed.
- **Content Structure** and **Authentication**: skipped, not failures.
Both score 100.

## Validation

- 215 tests pass (1 new)
- `make lint` clean, `make broken-links` clean
- 56 section indexes, 0 named `llms-N.txt`, 0 over 50,000 chars
- 2,038 pages indexed, verified no duplicates and none lost

## AI disclosure

Authored with Claude Code (Claude Opus 5). The serving rule was
established by probing production rather than inferred, after the
previous PR shipped on an untested assumption about exactly this.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 12:49:55 -07:00

178 lines
6.8 KiB
Python

"""Verify that URLs listed in the generated llms.txt indexes still resolve.
The build derives API reference URLs from the OpenAPI specs by reproducing
Mintlify's slug rules. Those rules are not a published contract, so a change on
their side would silently turn hundreds of index entries into 404s without
anything in this repo failing. Page URLs come from real files and are far
safer, but they can still rot when a page is renamed mid-build.
Run against a build tree (``make build`` first):
uv run python scripts/check_llms_urls.py
uv run python scripts/check_llms_urls.py --all --base-url https://docs.langchain.com
"""
from __future__ import annotations
import argparse
import random
import re
import sys
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
MD_LINK = re.compile(r"\((https://\S+?\.md)\)")
TXT_LINK = re.compile(r"\((https://\S+?llms[\w-]*\.txt)\)")
# Derived from an OpenAPI spec rather than from a file on disk.
DERIVED = re.compile(r"/(smith-api|agent-server-api)/")
DEFAULT_BASE_URL = "https://docs.langchain.com"
REQUEST_TIMEOUT = 30
MAX_WORKERS = 4
RETRY_ATTEMPTS = 3
RETRY_BACKOFF = 1.0
def collect_urls(build_dir: Path, base_url: str) -> tuple[list[str], list[str]]:
"""Return (derived API URLs, page URLs) listed across the llms.txt indexes."""
root_path = build_dir / "llms.txt"
if not root_path.exists():
msg = f"{root_path} not found. Run `make build` first."
raise SystemExit(msg)
root = root_path.read_text(encoding="utf-8")
urls = set(MD_LINK.findall(root))
for link in TXT_LINK.findall(root):
section = build_dir / link.removeprefix(f"{base_url}/")
if section.exists():
urls |= set(MD_LINK.findall(section.read_text(encoding="utf-8")))
same_origin = sorted(u for u in urls if u.startswith(f"{base_url}/"))
return (
[u for u in same_origin if DERIVED.search(u)],
[u for u in same_origin if not DERIVED.search(u)],
)
def status_of(url: str) -> int:
"""Return the HTTP status for *url*, or 0 if it stayed unreachable.
A connection that drops is retried, and HEAD is retried as GET: some CDNs
answer HEAD unreliably under concurrency. Without this the job reports
healthy pages as broken, and a check that cries wolf gets ignored.
"""
for attempt in range(RETRY_ATTEMPTS):
for method in ("HEAD", "GET"):
request = urllib.request.Request(url, method=method) # noqa: S310
try:
with urllib.request.urlopen( # noqa: S310
request, timeout=REQUEST_TIMEOUT
) as response:
return int(response.status)
except urllib.error.HTTPError as exc:
# A real HTTP status is an answer, not a failure to reach.
return int(exc.code)
except (urllib.error.URLError, TimeoutError, ConnectionError):
continue
time.sleep(RETRY_BACKOFF * (attempt + 1))
return 0
def main() -> int:
"""Sample index URLs and report any that do not resolve."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--build-dir", type=Path, default=Path("build"))
parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
parser.add_argument(
"--sample-derived",
type=int,
default=120,
help="How many derived API URLs to check (these carry the real risk).",
)
parser.add_argument(
"--sample-pages",
type=int,
default=40,
help="How many ordinary page URLs to check.",
)
parser.add_argument("--all", action="store_true", help="Check every URL.")
parser.add_argument("--seed", type=int, default=0)
args = parser.parse_args()
derived, pages = collect_urls(args.build_dir, args.base_url)
print(f"index lists {len(derived):,} derived API URLs, {len(pages):,} page URLs")
# Check the section indexes themselves first. Mintlify serves the exact
# filename llms.txt at any path but 404s on anything else, so a rename or
# a routing change makes whole sections invisible to coverage walkers
# while every local check still passes.
root = (args.build_dir / "llms.txt").read_text(encoding="utf-8")
section_urls = sorted(set(TXT_LINK.findall(root)))
print(f"checking {len(section_urls)} section indexes are served\n")
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
section_results = list(pool.map(status_of, section_urls))
unserved = [
(url, status)
for url, status in zip(section_urls, section_results, strict=True)
if status != 200
]
if unserved:
print(
f"{len(unserved)} of {len(section_urls)} section indexes are not served:\n"
)
for url, status in unserved[:20]:
print(f" {status or 'no response'} {url}")
print(
"\nEvery section index must be named exactly llms.txt. Mintlify "
"404s other .txt filenames, which hides those pages from coverage."
)
return 1
print(f"✅ all {len(section_urls)} section indexes are served\n")
# Sampling picks which URLs to spot-check; nothing here is security-relevant.
rng = random.Random(args.seed) # noqa: S311
if args.all:
checking = derived + pages
else:
checking = rng.sample(derived, min(args.sample_derived, len(derived)))
checking += rng.sample(pages, min(args.sample_pages, len(pages)))
print(f"checking {len(checking):,} URLs against {args.base_url}\n")
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
results = list(pool.map(status_of, checking))
# A redirect still lands an agent on real content; only treat it as a note.
reachable = (200, 301, 302, 307, 308)
broken = [
(url, status)
for url, status in zip(checking, results, strict=True)
if status not in reachable
]
redirects = sum(1 for status in results if status in reachable[1:])
if redirects:
print(f"note: {redirects} URLs redirect (still reachable)")
if broken:
print(f"\n{len(broken)} of {len(checking)} URLs do not resolve:\n")
for url, status in sorted(broken)[:40]:
label = status or "no response"
print(f" {label} {url}")
if DERIVED.search(broken[0][0]):
print(
"\nDerived API URLs are failing. Mintlify's slug rules for "
"OpenAPI operations have most likely changed; compare against "
"the live sitemap and update _tag_slug/_slugify in "
"pipeline/core/builder.py."
)
return 1
print(f"\n✅ all {len(checking):,} sampled URLs resolve")
return 0
if __name__ == "__main__":
sys.exit(main())