Metadata-Version: 2.4
Name: llmlayer
Version: 0.3.0
Summary: Official Python SDK for the LLMLayer Search & Answer API
Project-URL: Homepage, https://github.com/YassKhazzan/llmlayer_python
Project-URL: Issues, https://github.com/YassKhazzan/llmlayer_python/issues
Author-email: Yassine Khazzan <yassine@llmlayer.ai>
License: MIT
License-File: LICENSE
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Requires-Python: >=3.9
Requires-Dist: httpx>=0.28.1
Requires-Dist: pydantic>=2.6
Description-Content-Type: text/markdown

# LLMLayer Python SDK (API v2)

> **Search → Reason → Cite** with one call.
>
> Official Python client for the **LLMLayer Web API v2** — typed models, streaming via SSE, and production‑ready ergonomics.

---

## Table of Contents

* [Overview](#overview)
* [What’s new in v2 (breaking changes)](#whats-new-in-v2-breaking-changes)
* [Installation](#installation)
* [Authentication](#authentication)
* [Quickstart](#quickstart)

  * [Synchronous](#synchronous)
  * [Asynchronous](#asynchronous)
* [Answer API](#answer-api)

  * [When to use blocking vs streaming](#when-to-use-blocking-vs-streaming)
  * [Blocking Answer — `POST /api/v2/answer`](#blocking-answer--post-apiv2answer)
  * [Streaming Answer — `POST /api/v2/answer_stream`](#streaming-answer--post-apiv2answer_stream)
  * [Request Parameters (complete reference)](#request-parameters-complete-reference)
  * [Response Shape](#response-shape)
  * [Streaming Frames](#streaming-frames)
* [Models & Pricing](#models--pricing)
* [Cost Model](#cost-model)
* [Utilities](#utilities)

  * [Web Search — `POST /api/v2/web_search`](#web-search--post-apiv2web_search)
  * [Scrape (multi-format) — `POST /api/v2/scrape`](#scrape-multi-format--post-apiv2scrape)
  * [Extract (multi-mode) — `POST /api/v2/extract`](#extract-multi-mode--post-apiv2extract)
  * [PDF Content — `POST /api/v2/get_pdf_content`](#pdf-content--post-apiv2get_pdf_content)
  * [YouTube Transcript — `POST /api/v2/youtube_transcript`](#youtube-transcript--post-apiv2youtube_transcript)
  * [Map — `POST /api/v2/map`](#map--post-apiv2map)
  * [Crawl Stream — `POST /api/v2/crawl_stream`](#crawl-stream--post-apiv2crawl_stream)
* [End‑to‑End Pipelines](#end-to-end-pipelines)

  * [Map → Crawl → Save Markdown](#map--crawl--save-markdown)
* [Advanced Usage](#advanced-usage)

  * [Configuration options](#configuration-options)
  * [Context managers](#context-managers)
  * [Injecting custom `httpx` clients / proxies / retries](#injecting-custom-httpx-clients--proxies--retries)
  * [Per‑request timeouts & headers](#per-request-timeouts--headers)
* [Errors](#errors)
* [Best Practices](#best-practices)
* [Troubleshooting](#troubleshooting)
* [License & Support](#license--support)

---

## Overview

**LLMLayer** unifies web search, context building, and LLM reasoning behind a clean API. The Python SDK provides:

* **Answer** (blocking & streaming via SSE)
* **Vertical Web Search** (general/news/images/videos/shopping/scholar)
* **Scraping** (markdown/html/pdf/screenshot)
* **Extract** (structured data, summaries, Q&A, links, and brand profiles from one page)
* **PDF** text extraction
* **YouTube** transcript + metadata
* **Site Map** discovery
* **Crawl Stream** (stream markdown pages; usage billed per successful page)

All endpoints use typed Pydantic models, mapped exceptions, and support both sync and async via `httpx`.

---

## What’s new in v2 (breaking changes)

* **Routes moved to `/api/v2`** for all endpoints.
* **Answer response** field renamed: `answer` (was `llm_response`).
* **Answer streaming** content frames use `{ "type": "answer", "content": "..." }` (was `type: "llm"`).
* **Scrape** accepts **`formats: List["markdown"|"html"|"screenshot"|"pdf"]`** and returns `markdown/html/pdf/screenshot`, `title`, `metadata`, and **`statusCode`**.
* **Extract** returns structured extraction results in **`structured_data`**.
* **Map** response now uses **`statusCode`** (camelCase).
* **Crawl** request takes a single **`url`** (not `seeds`) and returns markdown-only page frames: `page`/`usage`/`done`/`error`.
* **YouTube** response includes metadata: `title`, `description`, `author`, `views`, `likes`, `date`.

---

## Installation

```bash
pip install llmlayer
# or
pipx install llmlayer
```

**Python**: 3.9+ recommended (tested 3.9–3.12). The SDK uses `httpx` under the hood.

---

## Authentication

All requests require a bearer token:

```
Authorization: Bearer YOUR_LLMLAYER_API_KEY
```

* Pass `api_key=...` to the client **or** set the environment variable `LLMLAYER_API_KEY`.
* Missing/invalid keys raise `AuthenticationError`.

> **Never embed your API key in public client code.** Run calls from trusted server code.

---

## Quickstart

### Synchronous

```python
from llmlayer import LLMLayerClient

client = LLMLayerClient()  # reads LLMLAYER_API_KEY from env

resp = client.answer(
    query="What are the latest AI breakthroughs?",
    model="llmlayer-web",
    return_sources=True,
)
print(resp.answer)
print("sources:", len(resp.sources))
```

### Asynchronous

```python
import asyncio
from llmlayer import LLMLayerClient

async def main():
    client = LLMLayerClient()
    resp = await client.answer_async(
        query="Explain edge AI in one short paragraph",
        model="llmlayer-fast",
    )
    print(resp.answer)

asyncio.run(main())
```

---

## Answer API

### When to use blocking vs streaming

* **Blocking** (`POST /api/v2/answer`): you need the complete answer before proceeding or you want **structured JSON** (`answer_type='json'` with `json_schema`).
* **Streaming** (`POST /api/v2/answer_stream`): chat UIs, progressive rendering, lower perceived latency.

> **Note:** Streaming **does not** support `answer_type='json'`. Use blocking for structured output.

### Blocking Answer — `POST /api/v2/answer`

```python
from llmlayer import LLMLayerClient

client = LLMLayerClient()
resp = client.answer(
    query="Explain quantum computing in simple terms",
    model="llmlayer-web",
    temperature=0.7,
    max_tokens=1000,
    return_sources=True,
)
print(resp.answer)
print("sources:", len(resp.sources))
print("total cost =", (resp.model_cost or 0) + (resp.llmlayer_cost or 0))
```

**Structured JSON output**

```python
import json
from llmlayer import LLMLayerClient

schema = {
    "type": "object",
    "properties": {
        "topic":   {"type": "string"},
        "bullets": {"type": "array", "items": {"type": "string"}}
    },
    "required": ["topic", "bullets"],
}

client = LLMLayerClient()
resp = client.answer(
    query="Return a topic and 3 bullets about transformers",
    model="llmlayer-web",
    answer_type="json",
    json_schema=schema,   # dict allowed; client serializes to JSON string
)

data = resp.answer if isinstance(resp.answer, dict) else json.loads(resp.answer)
print(data["topic"], len(data["bullets"]))
```

### Streaming Answer — `POST /api/v2/answer_stream`

The response is **Server‑Sent Events** (SSE) with **data‑only** JSON frames that include a `type` discriminator.

**Sync streaming**

```python
from llmlayer import LLMLayerClient

client = LLMLayerClient()
text = []
for event in client.stream_answer(
    query="History of the Internet in 5 lines",
    model="llmlayer-fast",
    return_sources=True,
):
    t = event.get("type")
    if t == "answer":            # v2: 'answer' (not 'llm')
        chunk = event.get("content", "")
        print(chunk, end="")
        text.append(chunk)
    elif t == "sources":
        print("\n[SOURCES]", len(event.get("data", [])))
    elif t == "images":
        print("\n[IMAGES]", len(event.get("data", [])))
    elif t == "usage":
        print("\n[USAGE]", event)
    elif t == "done":
        print("\n✓ finished in", event.get("response_time"), "s")
```

**Async streaming**

```python
import asyncio
from llmlayer import LLMLayerClient

async def main():
    client = LLMLayerClient()
    async for event in client.stream_answer_async(
        query="Three concise benefits of edge AI",
        model="llmlayer-fast",
        return_sources=True,
    ):
        if event.get("type") == "answer":
            print(event.get("content", ""), end="")

asyncio.run(main())
```

---

### Request Parameters (complete reference)

Use the keyword names shown below.

| Param | Type | Required | Default | Description |
| --- | --- | :---: | --- | --- |
| `query` | `str` | ✅ | — | The question or instruction. |
| `model` | `str` | ✅ | — | LLM id, for example `llmlayer-web`, `llmlayer-fast`, `openai/gpt-4o-mini`, or `openai/gpt-5.1`. Unsupported values fall back to `llmlayer-web` for backward compatibility. |
| `provider_key` | `str` |  | — | Accepted for backward compatibility but currently ignored; provider usage is not billed to the user's provider account. |
| `location` | `str` |  | `'us'` | Market/geo bias for search. |
| `system_prompt` | `str \| None` |  | `None` | Override the default system prompt for non-JSON answers. |
| `response_language` | `str` |  | `'auto'` | Output language; `'auto'` infers from the query. |
| `answer_type` | `'markdown' \| 'html' \| 'json'` |  | `'markdown'` | Output format. If `'json'`, you must provide `json_schema`. Not supported by streaming. |
| `search_type` | `'general' \| 'news'` |  | `'general'` | Search vertical. Use `search_web()` for shopping, videos, images, and scholar. |
| `json_schema` | `str \| dict \| None` |  | `None` | Required when `answer_type='json'`. Dicts are serialized automatically. |
| `citations` | `bool` |  | `False` | Embed inline citation markers. |
| `return_sources` | `bool` |  | `False` | Include aggregated `sources` and emit a `sources` frame in streaming. |
| `return_images` | `bool` |  | `False` | Include image results. |
| `date_filter` | `'anytime' \| 'hour' \| 'day' \| 'week' \| 'month' \| 'year'` |  | `'anytime'` | Recency filter. |
| `max_tokens` | `int` |  | `1500` | Max LLM output tokens. |
| `temperature` | `float` |  | `0.7` | Sampling temperature. |
| `domain_filter` | `list[str] \| None` |  | `None` | Include domains normally; exclude with `-domain.com`. |
| `max_queries` | `int` |  | `1` | Number of search sub-queries. Each adds a small LLMLayer fee and may improve coverage. |
| `search_context_size` | `'low' \| 'medium' \| 'high'` |  | `'medium'` | How much context to feed the LLM. |

**Supported locations (examples):**

```
us, ca, uk, mx, es, de, fr, pt, be, nl, ch, no, se, at, dk, fi, tr, it, pl, ru, za, ae, sa, ar, br, au, cn, kr, jp, in, ps, kw, om, qa, il, ma, eg, ir, ly, ye, id, pk, bd, my, ph, th, vn
```

---

### Response Shape

`AnswerResponse` (Pydantic model)

```python
{
  "answer": str | dict,            # markdown/html string, or dict for JSON answers
  "response_time": float | str,    # e.g. "1.23"
  "input_tokens": int,
  "output_tokens": int,
  "sources": list[dict],           # present when return_sources=True
  "images": list[dict],            # present when return_images=True
  "model_cost": float | None,      # None for fixed-price LLMLayer models
  "llmlayer_cost": float | None
}
```

---

### Streaming Frames

The server emits JSON frames over SSE with a `type` discriminator:

| `type`    | Payload Keys                                                                                   | Meaning                     |
| --------- | ---------------------------------------------------------------------------------------------- | --------------------------- |
| `answer`  | `content: str`                                                                                 | Partial LLM text chunk (v2) |
| `sources` | `data: list[dict]`                                                                             | Aggregated sources          |
| `images`  | `data: list[dict]`                                                                             | Relevant images             |
| `usage`   | `input_tokens: int`, `output_tokens: int`, `model_cost: float \| None`, `llmlayer_cost: float` | Token/cost summary          |
| `done`    | `response_time: str`                                                                           | Completion                  |
| `error`   | `error: str`                                                                                   | Error frame (raised by SDK) |

> The client handles multi‑line `data:` frames and early error frames automatically.

---


## Models & Pricing

Prices are USD per **1M tokens** (input/output) for provider-backed models. Keep this table aligned with the backend allow-list and pricing configuration.

### OpenAI

| Model                 | Input ($/M) | Output ($/M) | Best For                     |
|-----------------------| ----------: | -----------: | ---------------------------- |
| `openai/gpt-5.1`      |       $1.25 |       $10.00 | Complex reasoning & analysis |
| `openai/gpt-4o-mini`  |       $0.15 |        $0.60 | Fast, affordable searches    |


#### Choosing a model

* **Fast & economical:** `llmlayer-fast`, fixed pricing of $0.009 per request
* **Balanced quality:** `llmlayer-web`, fixed pricing of $0.007 per request
* **Premium reasoning:** `openai/gpt-5.1`

---

## Cost Model

Costs are reported on every answer response through `model_cost` and `llmlayer_cost`.

```
Total Cost = model_cost + llmlayer_cost
```

For fixed-price LLMLayer models, `model_cost` may be `None` and `llmlayer_cost` carries the charged request amount. `provider_key` is accepted for backward compatibility but currently ignored; provider usage is not billed to the user's provider account.

---

## Utilities

### Web Search — `POST /api/v2/web_search`

```python
from llmlayer import LLMLayerClient

client = LLMLayerClient()
res = client.search_web(
    query="ai agents",
    search_type="general",      # "general" | "news" | "shopping" | "videos" | "images" | "scholar"
    location="us",
    recency="day",
    domain_filter=["-reddit.com", "reuters.com"],
)
print("results:", len(res.results), "cost:", res.cost)
for item in res.results:
    print(item.get("title"), item.get("link") or item.get("url"), item.get("snippet"))
```

```python
import asyncio
from llmlayer import LLMLayerClient

async def main():
    client = LLMLayerClient()
    news = await client.search_web_async(
        query="latest ai news",
        search_type="news",
        location="us",
    )
    for item in news.results:
        print(item.get("title"), item.get("source"), item.get("date"))

asyncio.run(main())
```

### Scrape (multi-format) — `POST /api/v2/scrape`

```python
from llmlayer import LLMLayerClient

client = LLMLayerClient()

# Request multiple outputs in one call
r = client.scrape(
    url="https://example.com",
    formats=["markdown", "html"], # "markdown" | "html" | "screenshot" | "pdf"
    include_images=True,
    include_links=True,
)
print("status:", r.statusCode, "cost:", r.cost)
print("md len:", len(r.markdown or ""), "html?", bool(r.html), "pdf?", bool(r.pdf), "shot?", bool(r.screenshot))
```

### Extract (multi-mode) — `POST /api/v2/extract`

Extract reads one non-PDF web page and can run several modes in a single request. Pricing is summed per selected mode: `json`/`summary`/`qa` $0.005 each, `links` $0.001, `brand` $0.002.

| Mode | Required input | Response field | Notes |
| --- | --- | --- | --- |
| `json` | `json_schema` | `structured_data` | Extracts structured data matching a schema, example object, or plain-text field description. |
| `summary` | none | `summary` | Returns a markdown summary grounded in the page content. |
| `qa` | `query` | `answer` | Answers using only the page content. |
| `links` | none | `links` | Returns page links without an LLM call. |
| `brand` | none | `brand` | Returns brand profile data such as logos, colors, socials, and industry. |

All result fields are present on the response; modes you did not request come back as `None`. The `json` mode name means "structured extraction"; the result field is `structured_data`.

```python
product = client.extract(
    "https://example.com/product/123",
    modes=["json"],
    json_schema={
        "name": "string",
        "price": "number",
        "currency": "string",
        "in_stock": "boolean",
    },
    instructions="Keep prices as numbers and use null when a field is missing.",
)
print(product.structured_data)
print(product.summary)  # None
```

```python
page = client.extract(
    "https://example.com/docs/refunds",
    modes=["summary", "qa"],
    query="What is the refund policy?",
    response_language="en",
)
print(page.summary)
print(page.answer)
```

```python
link_result = client.extract(
    "https://example.com",
    modes=["links"],
)
internal_links = [link for link in link_result.links or [] if link.internal]
print(internal_links)
```

```python
profile = client.extract(
    "https://example.com",
    modes=["brand"],
)
print(profile.brand)
```

```python
combined = client.extract(
    "https://example.com/product/123",
    modes=["json", "summary", "qa", "links", "brand"],
    json_schema="Product name, price, currency, availability, and warranty length.",
    query="Does this page mention a warranty?",
)
print(combined.structured_data)
print(combined.summary)
print(combined.answer)
print(combined.links)
print(combined.brand)
print("cost:", combined.cost)
```

Async variant:

```python
result = await client.extract_async(
    "https://example.com/product/123",
    modes=["json"],
    json_schema={"name": "string", "price": "number"},
)
print(result.structured_data)
```

Other options:

- `advanced_proxy`: adds $0.004 only when a page scrape runs.
- `response_language`: default `"auto"`; applies to LLM modes.
- `main_content_only`: omit it to let the API choose the per-mode default. Links-only requests scrape the full page; LLM modes use main content by default.

PDF URLs are not supported by `extract`; use `get_pdf_content()` for PDFs. Very long repeating JSON lists may be capped so the response stays valid, and links mode returns up to 500 links.

> Failures before any AI cost (page fetch failure, empty content, brand failure) are **fully refunded**.

### PDF Content — `POST /api/v2/get_pdf_content`

```python
from llmlayer import LLMLayerClient

client = LLMLayerClient()
pdf = client.get_pdf_content("https://arxiv.org/pdf/1706.03762.pdf")
print("pages:", pdf.pages, "status:", pdf.statusCode, "cost:", pdf.cost)
print("preview:", pdf.text[:200])
```

### YouTube Transcript — `POST /api/v2/youtube_transcript`

```python
from llmlayer import LLMLayerClient

client = LLMLayerClient()
yt = client.get_youtube_transcript("https://www.youtube.com/watch?v=dQw4w9WgXcQ", language="en")
print(yt.title, yt.author, yt.views, yt.date,yt.description,yt.likes)
print(yt.transcript[:200])
```

### Map — `POST /api/v2/map`

```python
from llmlayer import LLMLayerClient

client = LLMLayerClient()
mp = client.map("https://docs.llmlayer.ai", limit=100, include_subdomains=False)
print("status:", mp.statusCode, "links:", len(mp.links), "cost:", mp.cost)
print("first:", mp.links[0].url, mp.links[0].title)

for link in mp.links:
    print("----------------")
    print("URL:", link.url)
    print("TITLE:", link.title)
```

### Crawl Stream — `POST /api/v2/crawl_stream`

Request a crawl of a single seed **`url`**. Crawl currently returns markdown page content only; use `scrape()` when you need `html`, `screenshot`, or `pdf` artifacts.

```python
from llmlayer import LLMLayerClient

client = LLMLayerClient()

for f in client.crawl_stream(
    url="https://docs.llmlayer.ai",
    max_pages=5,
    max_depth=1,
    timeout_seconds=30,
    main_content_only=False,
    advanced_proxy=False,
):
    if f.get("type") == "page":
        p = f.get("page", {})
        if p.get("success"):
            markdown = p.get("markdown") or ""
            print("ok:", p.get("final_url"), "md_len:", len(markdown))
        else:
            print("fail:", p.get("final_url"), "err:", p.get("error"))
    elif f.get("type") == "usage":
        print("billed:", f.get("billed_count"), "cost:", f.get("cost"))
    elif f.get("type") == "done":
        print("done in", f.get("response_time"), "s")
```

> `max_pages` is an **upper bound** (not a guarantee). You may receive fewer pages if the site is small, the time budget is hit, pages fail, or duplicates are deduped. Only **successful** pages are billed.

---

## End‑to‑End Pipelines

### Map → Crawl → Save Markdown

```python
import pathlib
from llmlayer import LLMLayerClient

client = LLMLayerClient()

# 1) Map
m = client.map("https://docs.llmlayer.ai", limit=200)
seeds = [l.url for l in m.links][:50]

# 2) Crawl (pick the top seed or a section)
out_dir = pathlib.Path("crawl_out"); out_dir.mkdir(exist_ok=True)
for f in client.crawl_stream(url=seeds[0], max_pages=15, max_depth=2, timeout_seconds=60):
    if f.get("type") == "page":
        p = f.get("page", {})
        if p.get("success") and p.get("markdown"):
            name = (p.get("title") or p.get("final_url") or "page").split("/")[-1][:64]
            safe = "".join(c if c.isalnum() or c in "._-" else "_" for c in name)
            (out_dir / f"{safe}.md").write_text(p["markdown"], encoding="utf-8")
```

---

## Advanced Usage

### Configuration options

```python
from llmlayer import LLMLayerClient

client = LLMLayerClient(
    api_key="sk-...",                         # or via LLMLAYER_API_KEY env var
    base_url="https://api.llmlayer.dev",      # override if self-hosting / staging
    timeout=60.0,                              # seconds (float or httpx.Timeout)
)
```

### Context managers

```python
from llmlayer import LLMLayerClient

with LLMLayerClient() as client:
    res = client.answer(query="hi", model="llmlayer-web")
    print(res.answer)

# Async context manager
async def main():
    async with LLMLayerClient() as client:
        res = await client.answer_async(query="hi", model="llmlayer-fast")
```

### Injecting custom `httpx` clients / proxies / retries

```python
import httpx
from llmlayer import LLMLayerClient

transport = httpx.HTTPTransport(retries=3)
session = httpx.Client(transport=transport, timeout=30)
client = LLMLayerClient(client=session)
```

> The SDK merges required headers (Authorization, User‑Agent) into injected clients.

### Per‑request timeouts & headers

Every method accepts optional `timeout=` and `headers=` overrides:

```python
resp = client.answer(
    query="hi",
    model="llmlayer-web",
    timeout=15.0,
    headers={"X-Debug": "1"},
)
```

---

## Errors

All exceptions extend `LLMLayerError`:

| Condition | Exception |
|-----------|-----------|
| Status **400 / 422**, or `validation_error` (missing/invalid params; early SSE errors like `missing_model`) | `InvalidRequest` |
| Status **401 / 403**, or `authentication_error` (missing/invalid LLMLayer key; provider auth issues) | `AuthenticationError` |
| Status **429** | `RateLimitError` |
| Upstream LLM provider errors (`provider_error`) | `ProviderError` |
| Status **408 / 504 / 5xx**, or `internal_error` / `scraping_error` / `search_error` / `map_error` | `InternalServerError` |
| Anything else — e.g. **402** insufficient credits, **423** key disabled | `LLMLayerError` (base class, carries the server message) |

```python
from llmlayer.exceptions import LLMLayerError, RateLimitError, InvalidRequest

try:
    r = client.answer(query="...", model="llmlayer-fast")
except RateLimitError:
    ...  # back off and retry
except InvalidRequest:
    raise  # fix the request — never retry as-is
except LLMLayerError as e:
    print(e)  # catches everything, incl. 402 insufficient credits
```

**Server envelope example** (the client unwraps `detail` automatically):

```json
{
  "detail": {
    "error_type": "validation_error",
    "error_code": "missing_query",
    "message": "Query parameter cannot be empty"
  }
}
```

---

## Best Practices

* Use streaming for responsive UIs and blocking calls for structured JSON.
* Start with `max_queries=1`, then raise to `2` or `3` for research tasks that need broader coverage.
* Use `domain_filter` to focus search and reduce noise; exclude with `-domain.com`.
* Keep JSON schemas narrow and explicit for `answer_type="json"` and `extract(modes=["json"])`.
* Use `instructions` for extract formatting preferences, such as date or number normalization.
* Use `extract(modes=["links"])` for links on one page and `map()`/`crawl_stream()` for site-level discovery.
* `provider_key` is accepted for backward compatibility but currently ignored; provider usage is not billed to your provider account.

---

## Troubleshooting

* **`answer_type='json'` with streaming** → not supported. Use blocking `answer()`.
* **`extract()` raises `missing_json_schema`** → include `json_schema` when `modes` includes `"json"`.
* **`extract()` raises `missing_query`** → include `query` when `modes` includes `"qa"`.
* **PDF URLs with `extract()`** → use `get_pdf_content()` instead.
* **SSL/Connect errors** → configure corporate proxies on your injected `httpx.Client`.
* **Event loop errors** in notebooks → run with `asyncio.run(...)` or use a fresh kernel.
* **Large base64 payloads** (`pdf`/`screenshot`) → write to disk; avoid keeping big blobs in memory.

---

## Changelog

### 0.3.0

* **New: `extract()` / `extract_async()`** — multi-mode page extraction (`json`, `summary`, `qa`, `links`, `brand` — combinable in one call, priced per mode). Structured results are exposed as `response.structured_data`.
* HTTP **422** responses now raise `InvalidRequest` (previously the base `LLMLayerError`).
* Full refunds on extract failures that occur before any AI cost.

---

## License & Support

**License:** MIT
**Issues & feature requests:** GitHub Issues
**Private support:** [support@llmlayer.ai](mailto:support@llmlayer.ai)
