# SmartLLM Web-Tools — Agent-Schnittstelle

Web-Suche, Page-Fetch und Multi-Hop-Research für AI-Agents, hinter dem
selben Bearer-Key wie die Chat-API. Gedacht als drop-in-Quelle, wenn ein
Agent eine Frage hat, die er ohne Web-Recherche nicht beantworten kann.

- **Basis-URL:** `https://smartllm.at`
- **Authentifizierung:** `Authorization: Bearer sk-smartllm-…`
- **Token-Verrechnung:**
  - `POST /web/search` — kein LLM, **gratis**.
  - `POST /web/fetch` ohne `relevance` — kein LLM, **gratis**.
  - `POST /web/fetch` mit `relevance` — Pre-Filter-LLM-Call läuft unter
    Ihrem Bearer-Key, Tokens werden ganz normal auf Ihr Kontingent gebucht.
  - `POST /web/research` — Link-Prio, Query-Expansion, Gap-Queries,
    Per-Page-Filter und Synthesis, alles unter Ihrem Bearer-Key. Kann
    pro Aufruf 50k–500k Tokens kosten; siehe Hinweise weiter unten.
  - `POST /web/verify` — Claim-Extraktion, symmetrische Suche (Per-Page-Filter)
    und ein Judge-Modell, alles unter Ihrem Bearer-Key. Bewusst teuer;
    siehe Hinweise weiter unten.
  - `POST /web/answer` — Search + Per-Page-Filter + kurze Synthese, unter
    Ihrem Bearer-Key. Leichter als research, aber LLM-gestützt.
  - `POST /web/extract` — ein Extraktions-LLM-Call unter Ihrem Bearer-Key.
  - `GET /web/tools` — **offen**, kein Key nötig: das Tool-Manifest (OpenAI
    function-calling-Schema) für alle Endpunkte. Kein LLM, gratis.
- **Spec-Status:** SmartLLM-Erweiterung, **nicht** Teil der OpenAI-Spec.
  Die Endpunkte tauchen zwar im öffentlichen `/openapi.json` auf (zusammen
  mit den `/v1/*`-Routes), aber OpenAI-Client-Bibliotheken kennen sie
  nicht — direkter HTTP-Aufruf.

---

## Wann was nutzen

| Situation | Endpunkt |
| --- | --- |
| „Welche Quellen gibt es zu Thema X?" | `POST /web/search` |
| „Was steht konkret auf URL Y?" | `POST /web/fetch` ohne `relevance` |
| „Was sagt URL Y über Aspekt Z?" | `POST /web/fetch` **mit** `relevance` |
| „Kurze, belegte Antwort auf Frage X" | `POST /web/answer` |
| „Strukturierte Felder (Preis, Datum, …) aus URL Y" | `POST /web/extract` |
| „Mach mir eine Übersicht über Thema X aus 5–15 Quellen" | `POST /web/research` |
| „Stimmt die Aussage Z — hält sie einem Widerlegungsversuch stand?" | `POST /web/verify` |
| „Welche Web-Tools gibt es und wie rufe ich sie auf?" | `GET /web/tools` |

Faustregel: Wenn der Agent nur einen Bruchteil einer Page braucht, **immer**
`relevance` setzen. Das spart ihm 10–50× Token im eigenen Kontext und
gleichzeitig 10–50× Token am Inferenz-Call.

Was die Endpunkte **nicht** bieten:
- Kein Streaming. Antworten sind komplette JSON-Responses.
- Kein Cache. Jeder Fetch holt frisch.
- Keine OpenAI-Client-SDK-Methoden. Reine HTTP-POSTs.

---

## `POST /web/search`

Meta-Suche über SearXNG (70+ Engines, anonymisiert, aggregiert).

### Request

```json
{
  "query": "qwen3 vision capabilities",
  "limit": 8
}
```

| Feld | Typ | Default | Beschreibung |
| --- | --- | --- | --- |
| `query` | string, ≥1 | — | Suchanfrage in natürlicher Sprache. Mehrsprachig OK. |
| `limit` | int, 1–30 | 8 | Maximale Anzahl Treffer. |

### Response 200

```json
{
  "query": "qwen3 vision capabilities",
  "results": [
    {
      "rank": 1,
      "url": "https://example.com/qwen3-vision-guide",
      "title": "Qwen3 Vision Guide",
      "snippet": "Qwen3 supports image inputs via the `image_url` content type…"
    }
  ],
  "engine_count": 47,
  "elapsed_ms": 612
}
```

`engine_count` ist die Zahl der Engines, die geantwortet haben (zur
Triangulation der Vertrauenswürdigkeit). `rank` startet bei 1 und ist
SearXNG-aggregiert (Cross-Engine-Score).

### curl

```bash
curl https://smartllm.at/web/search \
  -H "Authorization: Bearer IHR-API-KEY" \
  -H "Content-Type: application/json" \
  -d '{"query":"qwen3 vision","limit":5}'
```

### Python

```python
import httpx

resp = httpx.post(
    "https://smartllm.at/web/search",
    headers={"Authorization": "Bearer IHR-API-KEY"},
    json={"query": "qwen3 vision", "limit": 5},
    timeout=30,
)
resp.raise_for_status()
for r in resp.json()["results"]:
    print(r["rank"], r["url"], r["title"])
```

---

## `POST /web/fetch`

Fetched eine URL, gibt Markdown zurück. Mit optionalem `relevance`-Param
läuft die Page durch einen LLM-Pre-Filter („Magen") und der Agent bekommt
nur die zur Relevance-Frage passenden Passagen.

### Request — roh

```json
{
  "url": "https://example.com/article"
}
```

| Feld | Typ | Default | Beschreibung |
| --- | --- | --- | --- |
| `url` | string, ≥1 | — | Vollständige URL (http oder https). |
| `relevance` | string \| null | null | Wenn gesetzt: Pre-Filter aktiv (siehe unten). |
| `tier` | `"fast"` \| `"mid"` \| null | null | Erzwingt einen Filter-Tier. Nur sinnvoll mit `relevance`. |

### Response 200 — roh (ohne `relevance`)

```json
{
  "url": "https://example.com/article",
  "final_url": "https://example.com/article",
  "title": "Article Title",
  "markdown": "# Article\n\nFull page content as markdown...",
  "token_estimate": 12453,
  "elapsed_ms": 2104
}
```

`final_url` weicht nur ab wenn die Page redirected hat. `token_estimate`
ist eine grobe Schätzung (Zeichen/4) — wenn das über Ihrem Kontext-Budget
liegt, lieber direkt mit `relevance` arbeiten.

### Request — mit Pre-Filter

```json
{
  "url": "https://example.com/article",
  "relevance": "Welche Vision-Modelle werden ab Q3 2026 supported?"
}
```

### Response 200 — gefiltert

```json
{
  "url": "https://example.com/article",
  "relevance_query": "Welche Vision-Modelle werden ab Q3 2026 supported?",
  "tier_used": "fast",
  "relevant_content": "## Vision Roadmap\n\nAb Q3 2026 unterstützt das Modell…\n\n## Andere Themen auf der Seite: Pricing, FAQ, Changelog",
  "token_estimate_in": 12453,
  "token_estimate_out": 1823,
  "compression_ratio": 6.83,
  "elapsed_ms": 5421
}
```

Wenn der Pre-Filter nichts passendes findet:
```json
{
  "relevant_content": null,
  "reason": "NO_MATCH",
  ...
}
```

Der Agent sollte explizit auf `relevant_content is None` prüfen — sonst
arbeitet er mit `null` weiter und produziert Halluzinationen.

### Tier-Auswahl

| `tier` im Body | Verhalten |
| --- | --- |
| nicht gesetzt | **fast** als Default (Alias `mid` auf SmartLLM-Seite — qwen3.6-35b, warm). Auto-Eskalation auf `mid` (= `frontier`-Alias, 397B) ab ~150 k Page-Tokens. |
| `"fast"` | Erzwungen `fast`. Wenn Page >150k Tokens: gechunked statt eskaliert. |
| `"mid"` | Erzwungen `mid`. Für komplexe Pages oder wenn `fast` schon enttäuscht hat. Höhere Token-Kosten. |

Empfehlung für Agents: **nicht setzen**. Der Service trifft die richtige
Wahl. `mid` ist nur dann manuell hilfreich, wenn der Agent vorher mit
`fast` unzufrieden war und einen zweiten Versuch starten will.

### curl

```bash
# Raw
curl https://smartllm.at/web/fetch \
  -H "Authorization: Bearer IHR-API-KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/article"}'

# Mit Pre-Filter
curl https://smartllm.at/web/fetch \
  -H "Authorization: Bearer IHR-API-KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url":"https://example.com/article",
    "relevance":"Welche Vision-Modelle werden supported?"
  }'
```

### Python

```python
import httpx

def web_fetch(url: str, relevance: str | None = None) -> dict:
    body = {"url": url}
    if relevance:
        body["relevance"] = relevance
    resp = httpx.post(
        "https://smartllm.at/web/fetch",
        headers={"Authorization": "Bearer IHR-API-KEY"},
        json=body,
        timeout=120,   # Pre-Filter kann ein paar Sekunden brauchen
    )
    resp.raise_for_status()
    return resp.json()

data = web_fetch("https://example.com/article", relevance="Welche Modelle?")
if data.get("relevant_content"):
    print(data["relevant_content"])
else:
    print("Nichts passendes auf der Seite.")
```

---

## `POST /web/research`

Multi-Hop-Research: Discover (Search), Fetch, Per-Page-Filter, Synthesis.
Der Agent stellt eine offene Frage zu einem Thema, der Service iteriert
mehrere Quellen durch und liefert eine zusammenfassende Antwort plus
Quellenliste mit Vertrauens-Score.

**Wenn Sie das nutzen, wissen Sie, dass es Tokens kostet.** Ein typischer
Research-Lauf macht Dutzende Filter-Calls (1 pro Page), 2–10
Link-Prio-Calls und einen Synthesis-Call auf einem stärkeren Modell.
Größenordnung: 50 k–500 k Tokens pro Aufruf, je nach `max_pages`. Alles
geht auf Ihren Bearer-Key. Lieber sparsam starten (`max_pages: 5`) und
hochziehen wenn das Ergebnis dünn ist.

### Request

```json
{
  "topic": "Welche multimodalen Capabilities hat Qwen3 ab Mid-2026?",
  "depth": 1,
  "max_pages": 8,
  "start_url": null,
  "concurrency": 6,
  "gap_rounds": 1,
  "verify": false
}
```

| Feld | Typ | Default | Beschreibung |
| --- | --- | --- | --- |
| `topic` | string, ≥1 | — | Offene Frage oder Themen-Beschreibung in natürlicher Sprache. |
| `depth` | int, 0–5 | 1 | Hop-Tiefe ab Discovery. 0 = nur die initialen Suchresultate. 2+ wird teuer schnell. |
| `max_pages` | int, 1–100 | 10 | Harter Deckel für besuchte Pages. Auch wenn `depth` mehr erlauben würde, wird hier abgeschnitten. |
| `start_url` | string \| null | null | Wenn gesetzt: Discovery-Search überspringen und mit dieser URL als Seed starten. |
| `concurrency` | int, 1–32 \| null | null (= Server-Default 6) | Parallele Fetch+Filter-Calls. Höher = schneller + mehr LLM-Last. |
| `gap_rounds` | int, 0–5 \| null | null (= Server-Default 1) | Wie oft eine zweite Such-Runde mit „Lücken-Queries" gestartet wird, wenn die erste Synthesis-Confidence niedrig ausfällt. 0 deaktiviert das ganz. |
| `verify` | bool | false | Falsifikations-Pass über die fertige Synthese (siehe `/web/verify`). Die tragenden Aussagen werden gegengesucht und gerichtet; `confidence` wird ans Urteil gekoppelt (ein widerlegter Claim deckelt sie). Zusätzliche Kosten + Laufzeit. Das Ergebnis erscheint als `verification`-Block in der Response. |

### Response 200

```json
{
  "topic": "Welche multimodalen Capabilities hat Qwen3 ab Mid-2026?",
  "synthesis": "Qwen3 unterstützt seit Q3/2026 Bild- und Video-Input über das `image_url`-Content-Schema. Das 397B-Flagship und das 35B-Mid-Modell sind beide multimodal; Audio nur über Nemotron3:33b. Tools werden parallel zu Vision unterstützt, Reasoning bleibt optional…",
  "confidence": 0.78,
  "pages_visited": [
    {
      "url": "https://qwen.ai/blog?id=qwen3-vision",
      "title": "Qwen3 Vision",
      "depth": 0,
      "relevance_score": 1.0,
      "tokens_in": 14523,
      "tokens_out": 2104
    }
  ],
  "total_pages": 6,
  "max_depth_reached": 1,
  "elapsed_ms": 124530,
  "tokens_total_in": 87421,
  "tokens_total_out": 12305
}
```

- `synthesis` ist in der Sprache der Quellen-Mehrheit. Bei gemischten
  Quellen folgt der Service der Topic-Sprache.
- `confidence` ist die Selbsteinschätzung des Synthesis-Modells (0.0 =
  nichts gefunden, 1.0 = Topic vollständig beantwortet mit mehreren
  Quellen). Unter 0.5 würde ich misstrauen und mit anderer Formulierung
  oder höherem `max_pages` erneut versuchen.
- `relevance_score` pro Page ist binär: 1.0 = Filter hat was extrahiert,
  0.0 = NO_MATCH. Die NO_MATCH-Pages tauchen trotzdem in der Liste auf
  (für Audit-Zwecke), gehen aber nicht in die Synthesis ein.
- `pages_visited` kann gekürzt sein: bei sehr großen Runs liefert der
  Service eine Repräsentativ-Auswahl, nicht zwingend alle Seiten.

### Praktische Tipps

1. **Starte klein.** `max_pages: 5, depth: 1` liefert oft schon ein
   tragfähiges Ergebnis. Wenn die Confidence < 0.6 ist, hochziehen.
2. **`gap_rounds: 0` setzen, wenn Ihnen Schnelligkeit wichtiger ist als
   Vollständigkeit.** Default 1 macht eine zweite Recherche-Runde nur
   wenn das erste Ergebnis schwach ist — kann den Lauf verdoppeln.
3. **`start_url` ist mächtig.** Wenn der Agent schon eine vielversprechende
   URL hat (etwa aus einem vorherigen `/web/search`), spart der Skip der
   Discovery-Search Zeit *und* steuert den ganzen Lauf näher ans Ziel.
4. **Timeout-Budget:** plane mindestens 5 Minuten Client-Timeout ein,
   bei `max_pages > 20` auch 15 Minuten. Der Server-Timeout liegt bei
   15 Minuten — was länger braucht, wird abgebrochen.
5. **Quellen-Liste auswerten.** Auch wenn die Synthesis Ihnen genügt, sind
   `pages_visited[].url` die Belege, mit denen ein Mensch das Ergebnis
   nachvollziehen kann.

### curl

```bash
curl https://smartllm.at/web/research \
  -H "Authorization: Bearer IHR-API-KEY" \
  -H "Content-Type: application/json" \
  --max-time 900 \
  -d '{"topic":"Welche multimodalen Capabilities hat Qwen3 ab Mid-2026?","max_pages":8}'
```

### Python

```python
import httpx

def research(topic: str, max_pages: int = 8) -> dict:
    resp = httpx.post(
        "https://smartllm.at/web/research",
        headers={"Authorization": "Bearer IHR-API-KEY"},
        json={"topic": topic, "max_pages": max_pages},
        timeout=900,
    )
    resp.raise_for_status()
    return resp.json()

result = research("Multimodale Qwen3 Capabilities ab Mid-2026")
print(f"Confidence: {result['confidence']:.2f}")
print(f"Tokens: {result['tokens_total_in']}→{result['tokens_total_out']}")
print(result["synthesis"])
for p in result["pages_visited"]:
    if p["relevance_score"] > 0:
        print(f"  → {p['url']}")
```

---

## `POST /web/verify`

Falsifikation einer Aussage nach dem Prinzip **ipcha mistabra** („das
Umgekehrte ist plausibler"): Eine Aussage, die einen *gezielten
Widerlegungsversuch überlebt*, ist belastbarer als eine, die nur plausibel
klingt oder aus einer seriös aussehenden Quelle stammt. Der Service zieht die
**tragenden** Behauptungen aus der Aussage, sucht zu jeder *symmetrisch* nach
Gegenbelegen UND Belegen, und lässt ein **anderes Modell** (Judge, bewusst
nicht dasselbe wie der Generator) richten — vier Urteile, keine faule Mitte.

**Wann nutzen:** wenn eine Aussage entscheidungsrelevant ist und es teuer wäre,
wenn sie falsch ist — eine Behauptung des Users, ein Zwischenergebnis, eine
Annahme, auf der ein Plan ruht. **Nicht** als Reflex um jede Behauptung: der
Aufruf macht Claim-Extraktion + mehrere Suchen + Per-Page-Filter + einen
Reasoning-Judge — Sekunden bis Minuten, echte Tokens auf Ihrem Bearer-Key.

### Request

```json
{
  "statement": "Qwen3-35B ist multimodal und unterstützt Bild-Input.",
  "topic": null
}
```

| Feld | Typ | Default | Beschreibung |
| --- | --- | --- | --- |
| `statement` | string, ≥1 | — | Die Aussage (oder einzelne Behauptung), die geprüft werden soll. Darf mehrere Sätze umfassen — der Service extrahiert selbst die tragenden Behauptungen. |
| `topic` | string \| null | null | Optionaler Kontext, der die Claim-Extraktion schärft (z.B. das Recherche-Thema, aus dem die Aussage stammt). |

### Response 200

```json
{
  "overall": "eingeschraenkt",
  "claims": [
    {
      "claim": "Qwen3-35B unterstützt Bild-Input.",
      "urteil": "haelt_stand",
      "begruendung": "Mehrere unabhängige Quellen bestätigen Vision für das 35B-Modell; die Gegenbeleg-Suche fand nur Verwechslungen mit dem 8B-Text-only-Modell.",
      "kippvariable": "Modell-Variante (Base vs. Instruct)",
      "fehlende_evidenz": null,
      "sources_checked": 3
    }
  ],
  "judge_model": "judge",
  "elapsed_ms": 48210,
  "tokens_in": 31204,
  "tokens_out": 1820
}
```

- `overall` fasst die Einzelurteile zum **schwächsten** zusammen (ein einziges
  `widerlegt` dominiert): `haelt_stand` | `eingeschraenkt` | `unzureichend` |
  `widerlegt` | `keine_claims` (nichts Prüfbares extrahierbar).
- Pro Claim eines von vier Urteilen, **keine Mittelposition**:
  - `haelt_stand` — der Widerlegungsversuch ist gescheitert, die Behauptung trägt.
  - `widerlegt` — die Gegenbelege tragen, die Behauptung fällt.
  - `eingeschraenkt` — gilt nur unter den in `begruendung` benannten Bedingungen.
  - `unzureichend` — die Datenlage erlaubt kein Urteil; `fehlende_evidenz`
    trägt dann einen konkreten Folge-Suchauftrag.
- `kippvariable` ist die eine Variable, deren Wechsel das Urteil umdrehen würde
  — die handlungsrelevante Stellgröße (null wenn keine benennbar).
- `sources_checked` ist die Zahl der Seiten (beide Seiten), die tatsächlich
  Evidenz beigetragen haben (NO_MATCH-Seiten zählen nicht).

### Praktische Tipps

1. **Eine Aussage, kein Aufsatz.** Je fokussierter `statement`, desto schärfer
   die Behauptungen. Eine ganze Synthese können Sie übergeben — dann prüf
   lieber gezielt mit `/web/research` + `"verify": true`, das koppelt direkt
   die Confidence.
2. **`unzureichend` ist ein ehrliches Ergebnis**, kein Fehler — nutzen Sie
   `fehlende_evidenz` als nächsten Suchauftrag, statt das Urteil zu erzwingen.
3. **Timeout-Budget:** mindestens 5 Minuten Client-Timeout, der Server bricht
   nach 15 Minuten ab.

### curl

```bash
curl https://smartllm.at/web/verify \
  -H "Authorization: Bearer IHR-API-KEY" \
  -H "Content-Type: application/json" \
  --max-time 900 \
  -d '{"statement":"Qwen3-35B ist multimodal und unterstützt Bild-Input."}'
```

### Python

```python
import httpx

def verify(statement: str, topic: str | None = None) -> dict:
    resp = httpx.post(
        "https://smartllm.at/web/verify",
        headers={"Authorization": "Bearer IHR-API-KEY"},
        json={"statement": statement, "topic": topic},
        timeout=900,
    )
    resp.raise_for_status()
    return resp.json()

result = verify("Qwen3-35B ist multimodal und unterstützt Bild-Input.")
print(f"Overall: {result['overall']}")
for c in result["claims"]:
    print(f"  [{c['urteil']}] {c['claim']}")
    print(f"    {c['begruendung']}")
```

---

## `POST /web/answer`

Eine konkrete Frage, eine kurze belegte Antwort — die Sprosse zwischen
`/web/fetch` (eine Seite) und `/web/research` (Multi-Hop). Der Service sucht die
Top-Treffer, destilliert jede gegen die Frage und synthetisiert eine kurze
Antwort, deren Aussagen `[S#]` auf die Quellenliste zitieren. Leicht und
schnell — für den schnellen belegten Fakt, nicht die große Übersicht.

### Request

```json
{ "question": "Welcher Qwen3-Tier ist multimodal?", "limit": 3 }
```

| Feld | Typ | Default | Beschreibung |
| --- | --- | --- | --- |
| `question` | string, ≥1 | — | Die zu beantwortende Frage. |
| `limit` | int, 1–10 | 3 | Wie viele Suchtreffer destilliert werden. |

### Response 200

```json
{
  "question": "Welcher Qwen3-Tier ist multimodal?",
  "answer": "Das 35B- und das 397B-Modell sind multimodal [S1]; das 8B ist text-only [S2].",
  "confidence": 0.7,
  "sources": [
    {"id": 1, "url": "https://qwen.ai/blog/vision", "title": "Qwen3 Vision"},
    {"id": 2, "url": "https://qwen.ai/models", "title": "Models"}
  ],
  "elapsed_ms": 8200, "tokens_in": 5100, "tokens_out": 90
}
```

`[S#]` in `answer` löst gegen `sources[].id` auf. `confidence` < 0.5 ⇒ dünn,
ggf. `/web/research` nehmen.

---

## `POST /web/extract`

Strukturierte Extraktion einer Seite in ein selbst vorgegebenes JSON-Schema.
Antwort ist ein getyptes Objekt; Felder, die nicht auf der Seite stehen, kommen
als `null` zurück. Für maschinenlesbare Felder statt Prosa.

### Request

```json
{
  "url": "https://shop.example.com/item/42",
  "schema": {"title": null, "price": null, "currency": null, "in_stock": null}
}
```

| Feld | Typ | Default | Beschreibung |
| --- | --- | --- | --- |
| `url` | string, ≥1 | — | Seite, aus der extrahiert wird. |
| `schema` | object, ≥1 Feld | — | Gewünschte Output-Struktur (Feldnamen → `null`/Beispiel). |

### Response 200

```json
{
  "url": "https://shop.example.com/item/42",
  "data": {"title": "Widget", "price": "9.99", "currency": "EUR", "in_stock": true},
  "tier_used": "fast", "tokens_in": 2400, "tokens_out": 40, "elapsed_ms": 3100
}
```

PDFs werden automatisch erkannt (`.pdf` / `/pdf/`-URLs) und über das
Vision-Modell gelesen; bei `/web/fetch` lässt sich PDF mit `"pdf": true`
erzwingen.

---

## `GET /web/tools`

Offen, kein Key nötig. Liefert das Tool-Manifest aller `/web/*`-Endpunkte als
OpenAI function-calling-Schema — ein Agent lädt damit das ganze Toolset als
eine Datei.

### Response 200

```json
{
  "tools": [
    {"type": "function", "function": {"name": "web_search", "description": "...", "parameters": {"type": "object", "properties": {"query": {"type": "string"}, ...}, "required": ["query"]}}},
    ...
  ],
  "routes": {
    "web_search": {"method": "POST", "path": "/web/search"},
    "web_research": {"method": "POST", "path": "/web/research"},
    ...
  }
}
```

`tools` lässt sich direkt als `tools=`-Parameter an `/v1/chat/completions`
durchreichen; `routes` sagt dem Runtime, wohin der jeweilige Tool-Call als
HTTP-POST geht.

---

## Fehler-Handling

Alle Fehler kommen als JSON-Body mit dem üblichen `detail`-Feld oder als
strukturierter Error-Envelope vom Web-Backend (siehe unten).

| HTTP-Status | Bedeutung | Aktion für Agents |
| --- | --- | --- |
| `200` | Erfolg | Body normal verarbeiten. |
| `401` | Bearer-Key fehlt oder ungültig | Eskalieren, Customer informieren. |
| `429` | Rate-Limit (am Customer-Key, nicht am Web-Service) | `Retry-After`-Header lesen, dann erneut. |
| `502` | `web-api unreachable` — Tunnel oder Service down | Mit Backoff erneut versuchen (max 3×, exponentiell). |
| `500` | Unerwarteter Proxy-Fehler | Mit Backoff erneut, sonst aufgeben. |

Vom Web-Backend kommen zusätzlich strukturierte Envelopes für Search-,
Fetch- und Filter-spezifische Probleme:

```json
{
  "ok": false,
  "error": "TIMEOUT",
  "stage": "filter",
  "url": "https://example.com",
  "elapsed_ms": 60012,
  "details": "smartllm.at request failed: ReadTimeout"
}
```

| `error`-Code | Wo | Bedeutung |
| --- | --- | --- |
| `TIMEOUT` | search / fetch / filter | Upstream hat zu lange gebraucht. |
| `UPSTREAM` | search / fetch | SearXNG oder Crawl4AI hat 4xx/5xx geliefert. |
| `NETWORK` | search / fetch | Verbindungsfehler zur Upstream-Komponente. |
| `SCHEMA` | filter | LLM-Antwort konnte nicht geparst werden. |

`stage` zeigt wo es kaputtging — `"filter"` heißt: Fetch hat geklappt,
aber die Destillation ist gescheitert. In dem Fall lohnt sich ein
Retry **ohne** `relevance` (Sie bekommen dann das rohe Markdown).

```python
import httpx

resp = httpx.post(".../web/fetch", json={"url": url, "relevance": q}, ...)
data = resp.json()

if not resp.is_success or data.get("ok") is False:
    if data.get("stage") == "filter":
        # Filter kaputt, aber Page wurde gefetched — zurück zu raw
        resp2 = httpx.post(".../web/fetch", json={"url": url}, ...)
        data = resp2.json()
    else:
        raise RuntimeError(f"web-fetch failed: {data}")
```

---

## Praktische Hinweise für Agent-Implementierungen

1. **Erst Search, dann Fetch.** Search ist günstig (Millisekunden). Fetch
   eine zufällige URL aus den eigenen Halluzinationen produziert oft 404.
   Lieber `search → top-3-Ergebnisse → relevance-Fetch jedes davon`.

2. **`relevance` formulieren wie an einen Menschen.** „Welche Modelle"
   ist klar genug; „extrahiere alle Tabellen" auch. Vermeide LLM-Jargon
   wie „nutzen Sie Chain-of-Thought um …" — das verwirrt den Pre-Filter und
   bringt nichts.

3. **`token_estimate_in` und `token_estimate_out` loggen.** Wenn ein Agent
   merkt, dass `compression_ratio` < 2 ist, war seine `relevance` zu breit
   gefasst — beim nächsten Versuch enger formulieren.

4. **Timeout-Budget.** Fetch hard ceiling 30 s. 9b-Filter typischerweise
   2–30 s je nach Page-Größe. Setze Client-Timeouts auf mindestens 60 s
   für raw fetch, 120 s mit `relevance`.

5. **Nicht parallelisieren.** Der Web-Service hat genug Kapazität für
   normale Agent-Loads, aber Pre-Filter-Calls landen alle am selben
   internen LLM. Burst-Anfragen werden gequeued, nicht abgelehnt — kostet
   nur Wall-Clock. Ein Agent sollte sequenziell durch seine Quellen gehen.

6. **Robots/TOS sind Ihre Verantwortung.** Der Service fetched alles was
   Crawl4AI ranbekommt, ohne robots.txt-Check oder rate-limiting pro
   Domain. Wenn der Agent Quellen scrapt, bei denen das nicht erlaubt
   ist, ist das ein Anwender-Problem, nicht ein Service-Problem.

---

## Vollständiger Mini-Workflow

```python
import httpx

KEY = "sk-smartllm-..."
BASE = "https://smartllm.at"
H = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}

def search(q: str, limit: int = 5) -> list[dict]:
    r = httpx.post(f"{BASE}/web/search", headers=H, json={"query": q, "limit": limit}, timeout=30)
    r.raise_for_status()
    return r.json()["results"]

def distill(url: str, q: str) -> str | None:
    r = httpx.post(f"{BASE}/web/fetch", headers=H, json={"url": url, "relevance": q}, timeout=120)
    r.raise_for_status()
    return r.json().get("relevant_content")

def research(question: str) -> list[str]:
    hits = search(question, limit=3)
    notes = []
    for hit in hits:
        passage = distill(hit["url"], question)
        if passage:
            notes.append(f"## {hit['title']}\nQuelle: {hit['url']}\n\n{passage}")
    return notes

print("\n\n---\n\n".join(research("Welche Vision-Modelle bietet SmartLLM?")))
```

Drei Search-Hits, jeder gefiltert gegen die Originalfrage, fertig
durchsuchbare Notizen — typischer Agent-Loop in 15 Zeilen.
