Skip to content

Commit 51b911e

Browse files
fix(extract): resolve refs into plain json schema (#8)
1 parent 754bb9d commit 51b911e

7 files changed

Lines changed: 125 additions & 25 deletions

File tree

.pre-commit-config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ repos:
2727
name: conventional branch name
2828
entry: >-
2929
bash -c 'b=$(git branch --show-current);
30-
[ -z "$b" ] || echo "$b" | grep -qE "^(main|master|develop|(feature|bugfix|hotfix|release|chore)/[a-z0-9._/-]+)$"
31-
|| { echo "branch \"$b\" must be <type>/<description>, e.g. feature/add-retries (types: feature, bugfix, hotfix, release, chore)"; exit 1; }' --
30+
[ -z "$b" ] || echo "$b" | grep -qE "^(main|master|develop|(feature|fix|bugfix|hotfix|release|chore)/[a-z0-9._/-]+)$"
31+
|| { echo "branch \"$b\" must be <type>/<description>, e.g. feature/add-retries (types: feature, fix, bugfix, hotfix, release, chore)"; exit 1; }' --
3232
language: system
3333
pass_filenames: false
3434
always_run: true # branch name is not tied to any changed file

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ too, no documented value set).
6060

6161
- **Sync only.** `httpx.Client`. No async client until a real event-loop caller needs one, `_request` is the only logic to mirror.
6262
- **One `_request`** does auth header, error mapping (→ raises), and JSON parse. All calls route through it. A 2xx body that isn't JSON → `MalformedResponseError`.
63-
- **Primary verbs** live one-per-file in `verbs/` as mixins (`AskMixin`/`SearchMixin`/`ParseMixin`/`ExtractMixin`) composed onto `LightOn`. Each references `self._request`; the stub on `_VerbClient` (their shared base) makes them type-check in isolation, and `LightOn._request` overrides it at runtime. Keeps `_client.py` to just the transport core. They take explicit typed params and return the generated response models via `model_validate`. `ask`/`search` take `workspaces`/`tags`/`files` (lists of `Workspace`/`Tag`/`File` objects or bare ids; `_ids()` in `utils.py` coerces via duck-typed `.id` → the API's `workspace_id`/`tag_id`/`file_id`; server-side, `file_id` can't combine with `workspace_id`/`tag_id`, and `tag_id` is OR-matched). `tags` additionally accepts **name strings**, resolved through `tag.resolve_ids` (same helper as `File.tag`), so the verb `cast`s `self` to `LightOn` (the mixin `self` is typed `_VerbClient`) to call `Tag.list`; resolution only lists when a name is present. `parse` takes keyword-only `path` XOR `url` (multipart vs JSON body; raises `ValueError` unless exactly one). `extract` takes keyword-only `path` XOR `url` (multipart vs JSON body; raises `ValueError` unless exactly one, same as `parse`) plus a `schema` that is **either a pydantic model class** or a **raw JSON-Schema dict**; returns `ExtractJobResponse`. The multipart `file` upload isn't in the OpenAPI schema (`ExtractRequest` models only `document`/`schema`/`options`) but the endpoint accepts it, verified by curl; on multipart, `schema`/`options` ride as JSON-encoded form fields alongside the `file` part. Schema handling (in `utils.py`): a dict is validated against the draft-2020-12 meta-schema via `jsonschema` (`validate_response_format_json`, raises `SchemaError`) and otherwise passed through; a pydantic model is converted to a vLLM guided-generation `response_format` schema by `convert_pydantic_to_response_format_json`, `model_json_schema()` then normalized: `$defs`/`$ref` inlined (`_inline_refs`), nullable `anyOf` collapsed to `type: [X, "null"]` (`_collapse_nullable`), draft-2020-12 `$schema` marker added. `jsonschema` is a runtime dep (meta-schema validation is its job; hand-rolling would be flimsy). Ceiling: `_inline_refs` recurses through refs, so a self-referential model would overflow, fine, guided-gen grammars can't express unbounded recursion anyway.
63+
- **Primary verbs** live one-per-file in `verbs/` as mixins (`AskMixin`/`SearchMixin`/`ParseMixin`/`ExtractMixin`) composed onto `LightOn`. Each references `self._request`; the stub on `_VerbClient` (their shared base) makes them type-check in isolation, and `LightOn._request` overrides it at runtime. Keeps `_client.py` to just the transport core. They take explicit typed params and return the generated response models via `model_validate`. `ask`/`search` take `workspaces`/`tags`/`files` (lists of `Workspace`/`Tag`/`File` objects or bare ids; `_ids()` in `utils.py` coerces via duck-typed `.id` → the API's `workspace_id`/`tag_id`/`file_id`; server-side, `file_id` can't combine with `workspace_id`/`tag_id`, and `tag_id` is OR-matched). `tags` additionally accepts **name strings**, resolved through `tag.resolve_ids` (same helper as `File.tag`), so the verb `cast`s `self` to `LightOn` (the mixin `self` is typed `_VerbClient`) to call `Tag.list`; resolution only lists when a name is present. `parse` takes keyword-only `path` XOR `url` (multipart vs JSON body; raises `ValueError` unless exactly one). `extract` takes keyword-only `path` XOR `url` (multipart vs JSON body; raises `ValueError` unless exactly one, same as `parse`) plus a `schema` that is **either a pydantic model class** or a **raw JSON-Schema dict**; returns `ExtractJobResponse`. The multipart `file` upload isn't in the OpenAPI schema (`ExtractRequest` models only `document`/`schema`/`options`) but the endpoint accepts it, verified by curl; on multipart, `schema`/`options` ride as JSON-encoded form fields alongside the `file` part. Schema handling (in `utils.py`): **both inputs end up normalized by `normalize_response_format_json`** — `$defs`/`$ref` inlined (`_inline_refs`), nullable `anyOf` collapsed to `type: [X, "null"]` (`_collapse_nullable`), draft-2020-12 `$schema` marker added (an existing one is kept). A pydantic model goes `model_json_schema()` → normalize (`convert_pydantic_to_response_format_json`); a dict is first validated against the draft-2020-12 meta-schema via `jsonschema` (`validate_response_format_json`, raises `SchemaError`) and then normalized too — **not** passed through as it used to be, because the endpoint 422s on `$ref` and a dict is usually just someone's own `model_json_schema()` call, which carries them (the original bug: nested models only worked via the model-class path). A `#/$defs/` ref with no target raises `SchemaError` rather than a bare `KeyError` from inside the recursion. `jsonschema` is a runtime dep (meta-schema validation is its job; hand-rolling would be flimsy). Ceiling: `_inline_refs` recurses through refs, so a self-referential model would overflow, fine, guided-gen grammars can't express unbounded recursion anyway.
6464
- **Async jobs.** `parse`/`extract` take `mode: ExecMode` (default `ExecMode.SYNC`); `ExecMode.ASYNC` (uppercase members, value `"async"`, and lowercase `async` can't be a member name) sends `options={"async": true}`. `ExecMode` lives in `enums.py` (StrEnum, exported). Async returns a **pollable job handle** (`job.py`): `parse(mode=ASYNC)` → `ParseJob`, `extract(mode=ASYNC)` → `ExtractJob`; sync returns the full response model as before. Each verb has two `@overload`s keyed on `mode: Literal[ExecMode.SYNC|ASYNC]` so callers get the exact return type (`ParseResponse` vs `ParseJob`) instead of the union, the impl signature keeps the `ExecMode` default and the `... | ...Job` return. `Job.poll(page=None)` GETs `<path>/<id>`, absorbs the response onto itself in place (mirrors `_ActiveRecord._absorb`), returns self; `.done` (terminal, `completed_at` set) and `.succeeded` (`status == completed`) read state. `_Job` is a hand-written curated model (`extra="ignore"`) holding the shared plumbing + fields; `ParseJob`/`ExtractJob` subclass it ONLY because `result` differs (`ParseResult.pages` vs `ExtractResult.data`, whose optional fields make a union ambiguous), parse also has `error`. The job binds to the client via the `_VerbClient` transport surface (all it needs is `_request`), not a full `LightOn` (keeps the mixin's `self` assignable without a cast). `JobStatus` (enums.py) has only the documented `pending`/`completed`, the API doesn't publish the failure vocab, so it's for call-site comparison (StrEnum, unknown server values compare unequal, never validated onto the field), and the "poll until `.succeeded`, raise once `.done`" pattern keys off `completed_at`, not a failure string. `_Job.wait(timeout=300, poll=2)` is the auto-wait: a `File.wait`-style poll loop (no webhook exists) that returns self once terminal, raises `TimeoutError` past the deadline and `LightOnError` if `not .succeeded` (detail from `error` when the subclass has one, `getattr`, since only `ParseJob` does). The verbs expose it as `wait=False`/`timeout=300.0` (**same pair as `Workspace.ingest`**), declared **only on the ASYNC `@overload`** so `wait=True` without `mode=ASYNC` is a static error *and* a `ValueError` (sync already blocks); the two negative tests carry a `# ty: ignore[no-matching-overload]`. `wait=True` still returns the job (not the sync response model), so the return-type overloads stay two. No `poll` knob on the verbs, callers who need one use `job.wait(poll=...)`.
6565
- Deferred: tag/content_type/attribute filters, streaming, add the params when needed.
6666
- **Config object.** Non-essential knobs (`base_url`, `timeout`, `retries`, `transport`) live in `LightOnConfiguration` (pydantic, `arbitrary_types_allowed`). `api_key` stays a direct `LightOn()` arg; falls back to `LIGHTON_API_KEY` env.

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -403,7 +403,10 @@ with LightOn() as client:
403403
```
404404

405405
Or pass the schema dict directly, it's validated against the JSON-Schema
406-
meta-schema (raises `jsonschema.SchemaError` if malformed) and sent as-is:
406+
meta-schema (raises `jsonschema.SchemaError` if malformed), then normalized the
407+
same way a model is — the endpoint rejects `$ref`, so `$defs`/`$ref` are inlined
408+
whether the schema came from a model class or from your own
409+
`Model.model_json_schema()` call:
407410

408411
```python
409412
with LightOn() as client:

lighton/utils.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any
66

77
from jsonschema import Draft202012Validator
8+
from jsonschema.exceptions import SchemaError
89
from pydantic import BaseModel
910

1011
_DRAFT = "https://json-schema.org/draft/2020-12/schema"
@@ -36,7 +37,10 @@ def _inline_refs(node: Any, defs: dict[str, Any]) -> Any:
3637
if isinstance(node, dict):
3738
ref = node.get("$ref")
3839
if isinstance(ref, str) and ref.startswith("#/$defs/"):
39-
target = defs[ref.rsplit("/", 1)[-1]]
40+
name = ref.rsplit("/", 1)[-1]
41+
if name not in defs:
42+
raise SchemaError(f"unresolved $ref {ref!r}: no such entry in $defs")
43+
target = defs[name]
4044
siblings = {
4145
k: _inline_refs(v, defs) for k, v in node.items() if k != "$ref"
4246
}
@@ -71,8 +75,8 @@ def _collapse_nullable(node: Any) -> Any:
7175
def validate_response_format_json(schema: dict[str, Any]) -> dict[str, Any]:
7276
"""Validate a raw response_format schema against the draft-2020-12 meta-schema.
7377
74-
For dict schemas passed straight through to vLLM (no pydantic model to vouch
75-
for them), this catches a malformed schema client-side instead of at the API.
78+
For dict schemas handed to vLLM without a pydantic model to vouch for them,
79+
this catches a malformed schema client-side instead of at the API.
7680
7781
Args:
7882
schema: A dict holding a JSON Schema.
@@ -87,20 +91,39 @@ def validate_response_format_json(schema: dict[str, Any]) -> dict[str, Any]:
8791
return schema
8892

8993

94+
def normalize_response_format_json(schema: dict[str, Any]) -> dict[str, Any]:
95+
"""Normalize a JSON Schema into the self-contained shape vLLM wants.
96+
97+
`$defs`/`$ref` inlined, nullable `anyOf` collapsed to `type: [X, "null"]`,
98+
draft-2020-12 `$schema` marker added (an existing one is kept). The endpoint
99+
rejects `$ref`, so every schema goes through here, whether it came from a
100+
pydantic model or was passed in as a dict.
101+
102+
Args:
103+
schema: A dict holding a JSON Schema, possibly with `$defs`/`$ref`.
104+
105+
Returns:
106+
An equivalent self-contained schema, free of `$defs`/`$ref`.
107+
108+
Raises:
109+
jsonschema.exceptions.SchemaError: If a `#/$defs/` ref has no target.
110+
"""
111+
defs = schema.get("$defs", {})
112+
inlined = _inline_refs({k: v for k, v in schema.items() if k != "$defs"}, defs)
113+
return {"$schema": _DRAFT, **_collapse_nullable(inlined)}
114+
115+
90116
def convert_pydantic_to_response_format_json(model: type[BaseModel]) -> dict[str, Any]:
91117
"""Convert a pydantic model class to a vLLM guided-generation `response_format` schema.
92118
93-
Runs `model_json_schema()`, then normalizes: `$defs`/`$ref` inlined into a
94-
self-contained schema, nullable `anyOf` collapsed to `type: [X, "null"]`, and
95-
the draft-2020-12 `$schema` marker added.
119+
Runs `model_json_schema()` through `normalize_response_format_json`, which a
120+
nested model needs: pydantic emits `$defs`/`$ref` for every sub-model and the
121+
endpoint rejects those.
96122
97123
Args:
98124
model: The pydantic model class describing the extraction target.
99125
100126
Returns:
101127
A self-contained JSON Schema dict suitable for vLLM guided generation.
102128
"""
103-
raw = model.model_json_schema()
104-
defs = raw.get("$defs", {})
105-
inlined = _inline_refs({k: v for k, v in raw.items() if k != "$defs"}, defs)
106-
return {"$schema": _DRAFT, **_collapse_nullable(inlined)}
129+
return normalize_response_format_json(model.model_json_schema())

lighton/verbs/extract.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,23 @@
1313
from lighton.types.api import ExtractJobResponse
1414
from lighton.utils import (
1515
convert_pydantic_to_response_format_json,
16+
normalize_response_format_json,
1617
validate_response_format_json,
1718
)
1819
from lighton.verbs._base import _VerbClient
1920

2021

2122
def _as_json_schema(schema: type[BaseModel] | dict[str, Any]) -> dict[str, Any]:
22-
"""A pydantic model class → a vLLM guided-generation schema; a dict is validated.
23+
"""Either input → the self-contained vLLM guided-generation schema.
2324
24-
A dict is validated against the JSON-Schema meta-schema (raises on malformed)
25-
and otherwise returned untouched. A pydantic model is converted via
26-
`convert_pydantic_to_response_format_json`.
25+
A dict is validated against the JSON-Schema meta-schema (raises on malformed),
26+
then normalized; a pydantic model class is converted, which normalizes too.
27+
Both go through `normalize_response_format_json` because the endpoint rejects
28+
`$ref`, and a dict hand-built from `model_json_schema()` carries them just as
29+
a model class does.
2730
"""
2831
if isinstance(schema, dict):
29-
return validate_response_format_json(schema)
32+
return normalize_response_format_json(validate_response_format_json(schema))
3033
if isinstance(schema, type) and issubclass(schema, BaseModel):
3134
return convert_pydantic_to_response_format_json(schema)
3235
raise TypeError("schema must be a pydantic BaseModel subclass or a dict")

tests/e2e/cli.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,32 @@
5252

5353

5454
class DocumentSummary(BaseModel):
55-
"""Doc-agnostic extraction schema (see --extract-schema in the module docs)."""
55+
"""Doc-agnostic extraction schema, flat: no sub-models, so no `$defs`/`$ref`."""
5656

5757
title: str = Field(description="Document title.")
5858
summary: str = Field(description="One-sentence summary of the document.")
5959
language: str = Field(description="Primary language, as an ISO 639-1 code.")
6060

6161

62+
class Section(BaseModel):
63+
"""A section heading; sub-model of `DocumentOutline`."""
64+
65+
heading: str = Field(description="Section heading, verbatim as written.")
66+
page: int | None = Field(None, description="Page it starts on; null if unclear.")
67+
68+
69+
class DocumentOutline(BaseModel):
70+
"""Nested schema: `model_json_schema()` emits `$defs`/`$ref` for both sub-models.
71+
72+
The API 422s on `$ref`, so this only reaches it because the SDK inlines them.
73+
Reuses `DocumentSummary` as a sub-model on purpose: the same model then appears
74+
both nested and standalone.
75+
"""
76+
77+
overview: DocumentSummary = Field(description="Summary of the whole document.")
78+
sections: list[Section] = Field(description="Every top-level section heading.")
79+
80+
6281
@dataclass
6382
class Ctx:
6483
client: LightOn
@@ -286,7 +305,7 @@ def parse(c: Ctx) -> None:
286305

287306
@step
288307
def extract(c: Ctx) -> None:
289-
"""sync extract → async extract job (schema: DocumentSummary)."""
308+
"""sync → async job (flat schema) → nested schema, as a model and as a raw dict."""
290309
doc = c.docs[0]
291310
r = c.client.extract(DocumentSummary, path=doc)
292311
assert r.result and r.result.data, "sync extract returned no data"
@@ -298,6 +317,19 @@ def extract(c: Ctx) -> None:
298317
assert job.result and job.result.data, "async extract returned no data"
299318
_say(f"async: job {job.id} completed in {job.processing_time_ms}ms")
300319

320+
# A 422 on either call means $ref reached the API: the SDK stopped inlining.
321+
nested = c.client.extract(DocumentOutline, path=doc)
322+
assert nested.result and nested.result.data, "nested extract returned no data"
323+
_say(f"nested (model class): {nested.result.data}")
324+
325+
raw = DocumentOutline.model_json_schema() # carries $defs/$ref verbatim
326+
assert "$defs" in raw, "pydantic stopped emitting $defs — this case is now moot"
327+
as_dict = c.client.extract(raw, path=doc)
328+
assert as_dict.result and as_dict.result.data, (
329+
"nested dict extract returned no data"
330+
)
331+
_say(f"nested (raw dict): {as_dict.result.data}")
332+
301333

302334
@step
303335
def batch(c: Ctx) -> None:

tests/test_extract.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""extract verb: pydantic → vLLM schema, raw-dict passthrough, url vs path upload."""
1+
"""extract verb: pydantic/dict → vLLM schema, url vs path upload."""
22

33
import json
44

@@ -9,7 +9,12 @@
99

1010
from lighton import ExecMode, LightOn, LightOnConfiguration
1111
from lighton.exceptions import LightOnError
12-
from lighton.utils import validate_response_format_json
12+
from lighton.utils import (
13+
convert_pydantic_to_response_format_json,
14+
validate_response_format_json,
15+
)
16+
17+
_DRAFT = "https://json-schema.org/draft/2020-12/schema"
1318

1419

1520
def make_client(handler) -> LightOn:
@@ -68,10 +73,44 @@ def handler(req: httpx.Request) -> httpx.Response:
6873

6974
make_client(handler).extract(raw, url="https://x/i.pdf", options={"async": False})
7075
assert seen["body"]["document"] == "https://x/i.pdf"
71-
assert seen["body"]["schema"] == raw # dict passed through untouched
76+
assert seen["body"]["schema"] == {"$schema": _DRAFT, **raw}
7277
assert seen["body"]["options"] == {"async": False}
7378

7479

80+
def test_extract_dict_schema_refs_are_inlined():
81+
"""A dict built from model_json_schema() carries $defs/$ref; the API rejects them."""
82+
83+
class Address(BaseModel):
84+
city: str
85+
86+
class Company(BaseModel):
87+
name: str
88+
addr: Address
89+
sites: list[Address] = []
90+
91+
seen = {}
92+
93+
def handler(req: httpx.Request) -> httpx.Response:
94+
seen["body"] = json.loads(req.content)
95+
return httpx.Response(200, json=_OK)
96+
97+
make_client(handler).extract(Company.model_json_schema(), url="https://x/i.pdf")
98+
schema = seen["body"]["schema"]
99+
assert "$defs" not in schema and "$ref" not in json.dumps(schema)
100+
city = {"city": {"title": "City", "type": "string"}}
101+
assert schema["properties"]["addr"]["properties"] == city
102+
assert schema["properties"]["sites"]["items"]["properties"] == city
103+
# same result as handing the model class over directly
104+
assert schema == convert_pydantic_to_response_format_json(Company)
105+
106+
107+
def test_extract_dict_schema_with_dangling_ref_raises():
108+
client = make_client(lambda req: httpx.Response(200, json=_OK))
109+
raw = {"type": "object", "properties": {"addr": {"$ref": "#/$defs/Nope"}}}
110+
with pytest.raises(SchemaError, match="unresolved"):
111+
client.extract(raw, url="https://x/i.pdf")
112+
113+
75114
def test_extract_path_sends_multipart(tmp_path):
76115
raw = {"type": "object", "properties": {"total": {"type": "number"}}}
77116
f = tmp_path / "doc.png"
@@ -87,7 +126,7 @@ def handler(req: httpx.Request) -> httpx.Response:
87126
assert seen["ctype"].startswith("multipart/form-data")
88127
# file part + schema/options as JSON-encoded form fields
89128
assert b'name="file"' in seen["body"] and b"doc.png" in seen["body"]
90-
assert json.dumps(raw).encode() in seen["body"]
129+
assert json.dumps({"$schema": _DRAFT, **raw}).encode() in seen["body"]
91130
assert b'{"async": false}' in seen["body"]
92131

93132

0 commit comments

Comments
 (0)