diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml
index 2d5b119..aba9c22 100644
--- a/.github/workflows/spec-sync.yml
+++ b/.github/workflows/spec-sync.yml
@@ -516,8 +516,8 @@ jobs:
/v1/ade/parse and /v1/ade/parse/jobs — same name, different level, different route
family — and lifting a `/v1/*` top-level field onto a /v2 operation is the field-level
version of translating a /v1 route into a /v2 URL; (b) inventing a top-level
- convenience parameter that folds one wire field into another. Exactly two exist
- (`password` on parse, `strict` on extract); they are cross-SDK contract decisions
+ convenience parameter that folds one wire field into another. Exactly three exist
+ (`password` on parse, `strict` and `grounding` on extract); they are cross-SDK decisions
shared with ade-typescript and written down in CONTRIBUTING.md, and changing one is a
maintainer's call — do NOT add, remove or re-target an alias. Wire the spec's own
shape, and if a spec change looks like it needs a new one, leave that field unwired
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ab95fc4..ae88d77 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -223,9 +223,21 @@ conveniences that fold into nested fields, and these must stay identical in `ade
its key beats the caller's own kwarg. `ade-typescript` carries the mirror-image trap — there
`{password: undefined}` is how JS spells "absent" and `JSON.stringify` drops the key, so testing
key *presence* rather than value would suppress the shorthand **and** send no password at all.
-- `strict` on `/v2/extract` and `/v2/extract/jobs` → `options.strict`. No conflict is possible —
- neither SDK exposes `options` on extract, so the shorthand is the only way to reach the field. If
- either SDK ever exposes it, that becomes a real tie-break and needs a rule here first.
+- `strict` and `grounding` on `/v2/extract` and `/v2/extract/jobs` → `options.strict` and
+ `options.grounding`. No conflict is possible — neither SDK exposes `options` on extract, so the
+ shorthands are the only way to reach those fields. If either SDK ever exposes it, that becomes a
+ real tie-break and needs a rule here first. Three rules bind both repos: both shorthands fold into
+ the **same** `options` object, so build it once and attach it once (a per-shorthand
+ `body["options"] = {...}` drops whichever landed first); coerce with `bool()` / `Boolean()` rather
+ than truthiness, because `grounding=False` is the only value that flag is ever passed; and an
+ omitted or `None`/`undefined` value leaves its key out so the server default applies (`strict`
+ false, `grounding` true), with `options` omitted entirely when neither is given.
+
+ `grounding` was wired in both SDKs together, at the API owners' request, after three snapshots of
+ arriving/being dropped/being re-added while unwired (it skips the grounding stage, so every
+ `extraction_metadata` leaf comes back with `ranges: null`). It is a Preview flag: it reached
+ staging ahead of production, and with grounding off the `extraction` itself can differ slightly
+ from a grounded run.
**Nothing in CI checks this** — `check-v2-paths` covers routes only, and a hand-written alias is not
spec-derived, so neither repo's CI can see the other's choice. A PR that adds or touches a top-level
diff --git a/README.md b/README.md
index 0ca580d..0d1cbd6 100644
--- a/README.md
+++ b/README.md
@@ -177,6 +177,8 @@ The response is a `V2ExtractResult`:
By default, unsupported schema fields are skipped and extraction continues. Pass `strict=True` to reject such schemas with an error (HTTP 422) instead.
+Pass `grounding=False` to skip the grounding stage: extraction runs as usual and finishes faster, but every `extraction_metadata` field comes back with `ranges: None` instead of pointing into the Markdown. Preview — with grounding off the `extraction` itself can differ slightly from a grounded run. `extract_jobs.create` takes both options too.
+
## Process Large Documents Asynchronously (Jobs)
For documents that take longer than a synchronous request allows, create a job and wait for it. `client.v2.parse_jobs` and `client.v2.extract_jobs` share the same shape: `create`, `get`, `list`, and `wait`.
diff --git a/api.md b/api.md
index 5f0c897..3974a1a 100644
--- a/api.md
+++ b/api.md
@@ -120,11 +120,11 @@ Methods:
Blocks, polling `.get(job_id)` with exponential backoff, until the job reaches a terminal status. Raises `JobWaitTimeoutError` if `timeout` seconds elapse first, and `JobFailedError` if `raise_on_failure=True` and the terminal job carries an `error` (not simply every `failed`/`cancelled` status).
-- client.v2.extract(\*, schema, markdown=..., markdown_url=..., model=..., strict=..., save_to=...) -> V2ExtractResult
+- client.v2.extract(\*, schema, markdown=..., markdown_url=..., model=..., strict=..., grounding=..., save_to=...) -> V2ExtractResult
- Synchronous extract. `schema` accepts a pydantic `BaseModel` subclass, a `dict`, or a JSON-encoded string -- all are coerced to a JSON Schema object. Provide exactly one of `markdown` or `markdown_url`. `strict=True` rejects schemas with unsupported fields (HTTP 422) instead of silently pruning them. Raises `V2SyncTimeoutError` on a 504; use `extract_jobs` for long-running documents.
+ Synchronous extract. `schema` accepts a pydantic `BaseModel` subclass, a `dict`, or a JSON-encoded string -- all are coerced to a JSON Schema object. Provide exactly one of `markdown` or `markdown_url`. `strict=True` rejects schemas with unsupported fields (HTTP 422) instead of silently pruning them. `grounding=False` skips the grounding stage, so every `extraction_metadata` leaf comes back with `ranges: None` and the request finishes faster -- Preview, and with grounding off the `extraction` itself can differ slightly from a grounded run (empty leaves are not nulled, all-empty array rows are not dropped). Both are sent inside the same nested `options` object and `extract_jobs.create` takes them with the same semantics. Raises `V2SyncTimeoutError` on a 504; use `extract_jobs` for long-running documents.
-- client.v2.extract_jobs.create(\*, schema, markdown=..., markdown_url=..., model=..., strict=..., output_save_url=..., service_tier=...) -> Job
+- client.v2.extract_jobs.create(\*, schema, markdown=..., markdown_url=..., model=..., strict=..., grounding=..., output_save_url=..., service_tier=...) -> Job
- client.v2.extract_jobs.get(job_id) -> Job
- client.v2.extract_jobs.list(\*, page=..., page_size=..., status=...) -> JobList[Job]
- client.v2.extract_jobs.wait(job_id, \*, timeout=600, poll_interval=None, raise_on_failure=False) -> Job
@@ -138,5 +138,5 @@ Methods:
Notes:
- `parse_jobs.list` and `extract_jobs.list` both return a `JobList` (a `list[Job]` subclass) carrying pagination metadata: `.has_more`, `.org_id`, `.page`, `.page_size`. The `page_size=` keyword is sent on the wire as the `pageSize` query parameter (the spec renamed it, matching the V1 list routes); the keyword and the response envelope's `page_size` field are unchanged.
-- `options.grounding` (added upstream to `/v2/extract` and `/v2/extract/jobs`) is **not** exposed. It is nested inside `options`, which `client.v2.extract` does not surface — reaching it would need a third hand-written top-level shorthand alongside `password` and `strict`, and per CONTRIBUTING.md that is a cross-SDK decision to make with `ade-typescript` first. Until then, send it with `extra_body={"options": {"grounding": False}}` — note that `extra_body` merges at the top level only, so that replaces the whole `options` object and you must repeat `strict` in it rather than passing `strict=`.
+- `strict=` and `grounding=` on extract are hand-written top-level shorthands: the spec declares both inside the nested `options` object, which `client.v2.extract` does not surface. They fold into one `options` and `ade-typescript` exposes the same two with the same semantics (CONTRIBUTING.md pins that parity). Omitting one — or passing `None` — leaves its key out, so the server default applies (`strict` false, `grounding` true). An `options` supplied through `extra_body` replaces the assembled one outright rather than merging into it, since `extra_body` merges at the top level only.
- All `client.v2.*` methods accept the usual `extra_headers`, `extra_query`, `extra_body`, and `timeout` overrides; sync methods additionally accept `save_to` (parse/extract only, not the job-creation methods) to write the response to disk, mirroring V1's `save_to`.
diff --git a/docs/v2-testing.md b/docs/v2-testing.md
index 8c8da88..6648919 100644
--- a/docs/v2-testing.md
+++ b/docs/v2-testing.md
@@ -112,50 +112,51 @@ fields the model could not extract — the extraction is partial), `warnings`
`output_extraction_chars` char counts moved from `billing` onto `metadata`
upstream; both are retained on `V2ExtractBilling` for backward compatibility.
-### Extraction options (`strict`)
-
-`/v2/extract` and `/v2/extract/jobs` take a nested `options` object. Unlike parse,
-extract does **not** expose `options` as a keyword — the only nested option the SDK
-reaches is `strict`, through a hand-written top-level shorthand that folds into
-`options.strict`.
-
-That object now has a second member again: this snapshot **re-adds**
-`options.grounding`, a Preview flag that skips the grounding stage so every
-`extraction_metadata` leaf comes back with `ranges: null` (and the request finishes
-faster). It has now been added, dropped, and re-added across three snapshots; the
-SDK's answer has not moved, and `strict` remains the only option the SDK itself
-folds in.
-
-`grounding` stays **unwired**, for the same reason as before: it is nested inside
-`options`, not a top-level `requestBody` property, so surfacing it would mean a
-third hand-written alias next to `password` and `strict`. CONTRIBUTING.md pins that
-as a maintainer's call to be made jointly with `ade-typescript`, not a spec-sync
-one. Exposing `options` on extract instead is not a way around it — CONTRIBUTING.md
-calls that out explicitly, because it would turn the `strict` shorthand into a real
-precedence tie-break that needs a written rule in that file first.
-
-To reach the flag today, use `extra_body`:
-
-```python
-client.v2.extract(
- schema=..., markdown="# doc",
- extra_body={"options": {"grounding": False, "strict": False}},
-)
-```
-
-`extra_body` merges at the **top level only**, so it replaces the whole `options`
-object — repeat `strict` inside it rather than also passing `strict=`, or the
-keyword's value is silently discarded. (The escape hatch was rejected by the gateway
-under the previous snapshot, where `grounding` was absent and `options` is
-`additionalProperties: false`; it works again now.)
-
-`test_extract_options_carries_only_strict` pins the exact key set the SDK folds in,
-so an unreviewed `grounding` alias cannot start riding along unnoticed, and
-`test_extract_grounding_reachable_only_via_extra_body` pins the snippet above —
-including the shallow merge that makes repeating `strict` necessary.
-
-Nothing about the **response** changed — `extraction_metadata` is unaffected, and
-`V2ExtractResult` already models it as an untyped `Dict[str, object]`.
+### Extraction options (`strict`, `grounding`)
+
+`/v2/extract` and `/v2/extract/jobs` take a nested `options` object with exactly two
+members, `strict` and `grounding`. Unlike parse, extract does **not** expose `options`
+as a keyword: both are reached through hand-written top-level shorthands that fold
+into it.
+
+`grounding` is the newer of the two. It is a Preview flag that skips the grounding
+stage, so every `extraction_metadata` leaf comes back with `ranges: null` and the
+request finishes faster. It arrived, was dropped, and was re-added across three
+snapshots while staying unwired; it is wired now because the API owners asked both
+SDKs to expose it as a top-level keyword, matching `strict` (the decision, and the
+`ade-typescript` half of it, is recorded in CONTRIBUTING.md — that file is where a
+shorthand like this has to be agreed before it ships).
+
+Two behaviors are worth knowing:
+
+- **Both shorthands fold into the SAME `options` object**, so `_build_extract_body`
+ collects them and attaches the object once. Assigning `body["options"]` per
+ shorthand — which is what the code did while `strict` was alone — would silently
+ drop whichever landed first. `test_extract_options_carries_only_the_wired_shorthands`
+ pins the assembled object exactly, which also catches a third key riding along:
+ `options` is `additionalProperties: false` upstream, so an extra one is a 422.
+- **`False` must survive.** The fold is `bool(value)`, not a truthiness test, and an
+ omitted or `None` value leaves the key out entirely so the server default applies
+ (`strict` false, `grounding` true). `grounding=False` is the only value anyone
+ passes this flag for, so a truthiness regression would make the keyword a silent
+ no-op.
+
+`extra_body` still merges at the **top level only**, so an `options` passed there
+replaces the assembled one rather than merging into it
+(`test_extract_extra_body_options_replaces_the_shorthands`). Nobody needs it to reach
+`grounding` any more, but a caller who mixes the two gets the override, not a union.
+
+The live check is `test_extract_grounding_false_nulls_every_range` in
+`tests/contract/test_v2_smoke.py`: it asserts the returned `ranges` are all `null`
+rather than asserting a 2xx. A shorthand that never reached the wire — wrong nesting,
+a dropped `False` — is not an error staging reports; it just grounds anyway and comes
+back with real ranges.
+
+Nothing about the **response** changed — `extraction_metadata` is still an untyped
+`Dict[str, object]` on `V2ExtractResult`. Note that with grounding off the
+`extraction` itself can differ slightly from a grounded run (empty leaves are not
+nulled and all-empty array rows are not dropped), which is upstream behavior, not
+something the SDK normalizes.
The async `extract_jobs.create` also accepts `output_save_url` (async jobs only):
when set, the finished result is delivered to that URL and the completed job
diff --git a/src/landingai_ade/resources/v2/extract.py b/src/landingai_ade/resources/v2/extract.py
index f4d722d..1c6a332 100644
--- a/src/landingai_ade/resources/v2/extract.py
+++ b/src/landingai_ade/resources/v2/extract.py
@@ -28,6 +28,7 @@ def _build_extract_body(
markdown_url: object,
model: object,
strict: object,
+ grounding: object = omit,
service_tier: object = omit,
) -> Dict[str, Any]:
provided = [
@@ -52,8 +53,19 @@ def _build_extract_body(
):
if value is not omit and value is not None:
body[key] = value
- if strict is not omit and strict is not None:
- body["options"] = {"strict": bool(strict)}
+ # `strict` and `grounding` are hand-written top-level shorthands that both fold into
+ # the SAME nested `options` object (CONTRIBUTING.md -> "V2 request fields"), so they
+ # are collected and attached once: assigning `body["options"]` per key would drop
+ # whichever landed first. `options` is `additionalProperties: false` upstream, so
+ # nothing beyond these two may ride along. An omitted or `None` value leaves the key
+ # out entirely and the server default applies (`strict` false, `grounding` true) --
+ # `bool()` rather than truthiness so an explicit `False` is sent, not dropped.
+ options: Dict[str, Any] = {}
+ for key, value in (("strict", strict), ("grounding", grounding)):
+ if value is not omit and value is not None:
+ options[key] = bool(value)
+ if options:
+ body["options"] = options
return body
@@ -66,6 +78,7 @@ def run(
markdown_url: Optional[str] | Omit = omit,
model: Optional[str] | Omit = omit,
strict: Optional[bool] | Omit = omit,
+ grounding: Optional[bool] | Omit = omit,
save_to: str | Path | None = None,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
@@ -96,6 +109,12 @@ def run(
False, prune unsupported fields and continue. Sent as
`options.strict`.
+ grounding: If False, skip the grounding stage: every `extraction_metadata`
+ leaf comes back with `ranges: None` and the request finishes faster.
+ Defaults to True server-side. Sent as `options.grounding`. Preview --
+ the extraction itself can differ slightly from a grounded run (empty
+ leaves are not nulled and all-empty array rows are not dropped).
+
save_to: Optional output path. If a directory, auto-generates the filename
(e.g. {input_file}_extract_output.json, or extract_output.json when no
input filename is available). If a full path ending in .json, saves there
@@ -109,7 +128,7 @@ def run(
timeout: Override the client-level default timeout for this request, in seconds
"""
- body = _build_extract_body(schema, markdown, markdown_url, model, strict)
+ body = _build_extract_body(schema, markdown, markdown_url, model, strict, grounding=grounding)
try:
result = self._post(
self._v2_url("/v2/extract"),
@@ -139,6 +158,7 @@ async def run(
markdown_url: Optional[str] | Omit = omit,
model: Optional[str] | Omit = omit,
strict: Optional[bool] | Omit = omit,
+ grounding: Optional[bool] | Omit = omit,
save_to: str | Path | None = None,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
@@ -148,7 +168,7 @@ async def run(
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> V2ExtractResult:
"""Async mirror of `ExtractResource.run`. See there for full documentation."""
- body = _build_extract_body(schema, markdown, markdown_url, model, strict)
+ body = _build_extract_body(schema, markdown, markdown_url, model, strict, grounding=grounding)
try:
result = await self._post(
self._v2_url("/v2/extract"),
@@ -178,6 +198,7 @@ def create(
markdown_url: Optional[str] | Omit = omit,
model: Optional[str] | Omit = omit,
strict: Optional[bool] | Omit = omit,
+ grounding: Optional[bool] | Omit = omit,
output_save_url: Optional[str] | Omit = omit,
service_tier: Optional[Literal["standard", "priority"]] | Omit = omit,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
@@ -208,6 +229,12 @@ def create(
False, prune unsupported fields and continue. Sent as
`options.strict`.
+ grounding: If False, skip the grounding stage: every `extraction_metadata`
+ leaf comes back with `ranges: None` and the request finishes faster.
+ Defaults to True server-side. Sent as `options.grounding`. Preview --
+ the extraction itself can differ slightly from a grounded run (empty
+ leaves are not nulled and all-empty array rows are not dropped).
+
output_save_url: URL the result should be saved to (e.g. a presigned S3 PUT
URL) instead of being returned inline. Async jobs only. When set, the
completed job reports `output_url` instead of an inline `result`.
@@ -222,7 +249,9 @@ def create(
timeout: Override the client-level default timeout for this request, in seconds
"""
- body = _build_extract_body(schema, markdown, markdown_url, model, strict, service_tier)
+ body = _build_extract_body(
+ schema, markdown, markdown_url, model, strict, grounding=grounding, service_tier=service_tier
+ )
if is_given(output_save_url) and output_save_url is not None:
body["output_save_url"] = output_save_url
raw = self._post(
@@ -338,6 +367,7 @@ async def create(
markdown_url: Optional[str] | Omit = omit,
model: Optional[str] | Omit = omit,
strict: Optional[bool] | Omit = omit,
+ grounding: Optional[bool] | Omit = omit,
output_save_url: Optional[str] | Omit = omit,
service_tier: Optional[Literal["standard", "priority"]] | Omit = omit,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
@@ -348,7 +378,9 @@ async def create(
timeout: float | httpx.Timeout | None | NotGiven = not_given,
) -> Job:
"""Async mirror of `ExtractJobsResource.create`. See there for full documentation."""
- body = _build_extract_body(schema, markdown, markdown_url, model, strict, service_tier)
+ body = _build_extract_body(
+ schema, markdown, markdown_url, model, strict, grounding=grounding, service_tier=service_tier
+ )
if is_given(output_save_url) and output_save_url is not None:
body["output_save_url"] = output_save_url
raw = await self._post(
diff --git a/src/landingai_ade/resources/v2/v2.py b/src/landingai_ade/resources/v2/v2.py
index 422de36..fe61b09 100644
--- a/src/landingai_ade/resources/v2/v2.py
+++ b/src/landingai_ade/resources/v2/v2.py
@@ -92,6 +92,7 @@ def extract(
markdown_url: Optional[str] | Omit = omit,
model: Optional[str] | Omit = omit,
strict: Optional[bool] | Omit = omit,
+ grounding: Optional[bool] | Omit = omit,
save_to: str | Path | None = None,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
@@ -107,6 +108,7 @@ def extract(
markdown_url=markdown_url,
model=model,
strict=strict,
+ grounding=grounding,
save_to=save_to,
extra_headers=extra_headers,
extra_query=extra_query,
@@ -208,6 +210,7 @@ async def extract(
markdown_url: Optional[str] | Omit = omit,
model: Optional[str] | Omit = omit,
strict: Optional[bool] | Omit = omit,
+ grounding: Optional[bool] | Omit = omit,
save_to: str | Path | None = None,
# Use the following arguments if you need to pass additional parameters to the API that aren't available via kwargs.
# The extra values given here take precedence over values defined on the client or passed to this method.
@@ -223,6 +226,7 @@ async def extract(
markdown_url=markdown_url,
model=model,
strict=strict,
+ grounding=grounding,
save_to=save_to,
extra_headers=extra_headers,
extra_query=extra_query,
diff --git a/tests/api_resources/v2/test_async_smoke.py b/tests/api_resources/v2/test_async_smoke.py
index 96cd1da..b98a470 100644
--- a/tests/api_resources/v2/test_async_smoke.py
+++ b/tests/api_resources/v2/test_async_smoke.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import json
from typing import Any, Dict
import httpx
@@ -44,6 +45,28 @@ async def test_async_extract() -> None:
assert isinstance(r, V2ExtractResult)
+@respx.mock
+@pytest.mark.asyncio
+async def test_async_extract_folds_both_option_shorthands() -> None:
+ # The async mirrors share `_build_extract_body` with the sync methods, but the
+ # kwargs are declared four times over (sync/async x run/create) -- a shorthand
+ # added to only some of them is exactly the kind of drift that reads fine in a
+ # diff. Pin both async entry points.
+ client = AsyncLandingAIADE(apikey=APIKEY)
+
+ sync_route = respx.post("https://api.ade.landing.ai/v2/extract").mock(
+ return_value=httpx.Response(200, json=EXTRACT_BODY)
+ )
+ await client.v2.extract(schema={"type": "object"}, markdown="m", strict=True, grounding=False)
+ assert json.loads(sync_route.calls.last.request.content)["options"] == {"strict": True, "grounding": False}
+
+ job_route = respx.post("https://api.ade.landing.ai/v2/extract/jobs").mock(
+ return_value=httpx.Response(202, json={"job_id": "e1", "status": "pending"})
+ )
+ await client.v2.extract_jobs.create(schema={"type": "object"}, markdown="m", grounding=False)
+ assert json.loads(job_route.calls.last.request.content)["options"] == {"grounding": False}
+
+
@respx.mock
@pytest.mark.asyncio
async def test_async_extract_jobs_create_get_and_wait() -> None:
diff --git a/tests/api_resources/v2/test_extract.py b/tests/api_resources/v2/test_extract.py
index 8894ba6..f8f8f22 100644
--- a/tests/api_resources/v2/test_extract.py
+++ b/tests/api_resources/v2/test_extract.py
@@ -52,32 +52,61 @@ def test_extract_sync_strict_option() -> None:
@respx.mock
-def test_extract_options_carries_only_strict() -> None:
- # `V2ExtractOptions` is `additionalProperties: false` upstream and now carries
- # two members, `strict` and the re-added Preview flag `grounding`. Only
- # `strict` is wired: `grounding` is nested rather than a top-level
- # `requestBody` property, so reaching it would need a third hand-written alias,
- # which CONTRIBUTING.md reserves for a joint call with `ade-typescript`. Pin
- # the exact key set rather than just `strict`, so neither a stray option nor an
- # unreviewed `grounding` alias can start riding along unnoticed.
+def test_extract_sync_grounding_option() -> None:
+ # `grounding=False` skips the grounding stage server-side (every
+ # `extraction_metadata` leaf comes back with `ranges: null`). It folds into
+ # `options.grounding`, the second hand-written top-level shorthand on extract
+ # alongside `strict` -- `bool()`, not truthiness, so `False` is SENT rather
+ # than dropped, which is the only value anyone passes this for.
client = LandingAIADE(apikey=APIKEY)
route = respx.post("https://api.ade.landing.ai/v2/extract").mock(
return_value=httpx.Response(200, json=EXTRACT_BODY)
)
- client.v2.extract(schema={"type": "object"}, markdown="# doc", strict=False)
+ client.v2.extract(schema={"type": "object"}, markdown="# doc", grounding=False)
req = json.loads(route.calls.last.request.content)
- assert set(req["options"]) == {"strict"}
+ assert req["options"] == {"grounding": False}
@respx.mock
-def test_extract_grounding_reachable_only_via_extra_body() -> None:
- # `options.grounding` is deliberately unwired (nested, not top-level), so
- # `extra_body` is the documented escape hatch -- api.md and docs/v2-testing.md
- # both point at it. `extra_body` merges at the top level only
- # (`_merge_mappings` is a shallow `{**a, **b}`), so the caller's `options`
- # replaces the one `_build_extract_body` assembled and must repeat `strict`.
- # Pin that here: if the merge ever went deep, the documented snippet would
- # start sending a different body than it advertises.
+def test_extract_options_carries_only_the_wired_shorthands() -> None:
+ # `V2ExtractOptions` is `additionalProperties: false` upstream and carries
+ # exactly two members, `strict` and `grounding`, both wired as top-level
+ # shorthands. Pin the exact object rather than one key: the two fold into the
+ # SAME nested object, so a regression that assigns `options` per shorthand
+ # instead of merging would silently drop one, and any third key riding along
+ # would be rejected by the gateway.
+ client = LandingAIADE(apikey=APIKEY)
+ route = respx.post("https://api.ade.landing.ai/v2/extract").mock(
+ return_value=httpx.Response(200, json=EXTRACT_BODY)
+ )
+ client.v2.extract(schema={"type": "object"}, markdown="# doc", strict=False, grounding=False)
+ req = json.loads(route.calls.last.request.content)
+ assert req["options"] == {"strict": False, "grounding": False}
+
+
+@respx.mock
+def test_extract_omits_options_when_no_shorthand_given() -> None:
+ # Neither shorthand given -> no `options` at all, so the server's own defaults
+ # apply (`strict` false, `grounding` true). `None` means "not given" for both,
+ # the same as omitting them: there is no null to send here -- `options` is
+ # where a caller would express "default", and leaving it out IS that.
+ client = LandingAIADE(apikey=APIKEY)
+ route = respx.post("https://api.ade.landing.ai/v2/extract").mock(
+ return_value=httpx.Response(200, json=EXTRACT_BODY)
+ )
+ client.v2.extract(schema={"type": "object"}, markdown="# doc", strict=None, grounding=None)
+ req = json.loads(route.calls.last.request.content)
+ assert "options" not in req
+
+
+@respx.mock
+def test_extract_extra_body_options_replaces_the_shorthands() -> None:
+ # `extra_body` merges at the top level only (`_merge_mappings` is a shallow
+ # `{**a, **b}`), so an `options` supplied there replaces the one the shorthands
+ # assembled rather than merging into it. Both shorthands are now keywords, so
+ # nobody needs `extra_body` to reach `grounding` -- but the shallow merge is
+ # still the documented behavior of `extra_body` everywhere, and a caller who
+ # mixes the two gets the override, not a union.
client = LandingAIADE(apikey=APIKEY)
route = respx.post("https://api.ade.landing.ai/v2/extract").mock(
return_value=httpx.Response(200, json=EXTRACT_BODY)
@@ -86,15 +115,17 @@ def test_extract_grounding_reachable_only_via_extra_body() -> None:
schema={"type": "object"},
markdown="# doc",
strict=True,
- extra_body={"options": {"grounding": False, "strict": False}},
+ grounding=True,
+ extra_body={"options": {"grounding": False}},
)
req = json.loads(route.calls.last.request.content)
- assert req["options"] == {"grounding": False, "strict": False}
+ assert req["options"] == {"grounding": False}
-def test_extract_job_create_options_carries_only_strict(monkeypatch: pytest.MonkeyPatch) -> None:
+def test_extract_job_create_carries_both_shorthands(monkeypatch: pytest.MonkeyPatch) -> None:
# Same `additionalProperties: false` contract on the async job route, which
- # shares `_build_extract_body` with the sync one.
+ # shares `_build_extract_body` with the sync one -- including the merge that
+ # keeps both shorthands in one `options` object.
client = LandingAIADE(apikey=APIKEY)
captured: Dict[str, Any] = {}
@@ -103,8 +134,8 @@ def fake_post(path: str, *, cast_to: Any, body: Any = None, options: Any = None,
return {"job_id": "e1", "status": "pending"}
monkeypatch.setattr(client.v2.extract_jobs, "_post", fake_post)
- client.v2.extract_jobs.create(schema={"type": "object"}, markdown="x", strict=True)
- assert set(captured["body"]["options"]) == {"strict"}
+ client.v2.extract_jobs.create(schema={"type": "object"}, markdown="x", strict=True, grounding=False)
+ assert captured["body"]["options"] == {"strict": True, "grounding": False}
@respx.mock
diff --git a/tests/contract/test_v2_smoke.py b/tests/contract/test_v2_smoke.py
index da2b4a4..bec3aba 100644
--- a/tests/contract/test_v2_smoke.py
+++ b/tests/contract/test_v2_smoke.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import os
-from typing import List, Iterator, Optional
+from typing import Any, Dict, List, Iterator, Optional, cast
from pathlib import Path
import pytest
@@ -227,3 +227,25 @@ def test_job_lists_honor_page_size(staging_client: LandingAIADE) -> None:
# route) included; an undocumented one falls back to `pending`.
assert isinstance(job.status, JobStatus)
assert job.job_id
+
+
+def test_extract_grounding_false_nulls_every_range(staging_client: LandingAIADE) -> None:
+ # `grounding=False` folds into `options.grounding` and switches the grounding
+ # stage off, so every `extraction_metadata` leaf comes back with `ranges: null`.
+ # That is the whole point of a live check here: a shorthand that never reached
+ # the wire -- wrong nesting, dropped `False`, a name the gateway doesn't know --
+ # is not an error staging reports. `options` is `additionalProperties: false`,
+ # so a misspelled key 422s, but a silently ignored one just grounds anyway and
+ # comes back with real ranges. Assert the ranges, not the status code.
+ res = staging_client.v2.extract(schema=RevenueSchema, markdown=SAMPLE_MARKDOWN, grounding=False)
+ assert isinstance(res, V2ExtractResult)
+ metadata = cast(Dict[str, Any], res.extraction_metadata)
+ assert metadata, "extraction returned no metadata leaves to check"
+ # `RevenueSchema` is flat, so every top-level entry IS a leaf. With grounding off
+ # each one carries `ranges: null` -- a null value, not a missing key. Assert the
+ # key's presence separately from its value: skipping leaves that lack `ranges`
+ # would let a leaf that dropped it pass as if it had been checked.
+ for name, value in metadata.items():
+ leaf = cast(Dict[str, Any], value)
+ assert "ranges" in leaf, f"leaf {name!r} carries no `ranges`: {leaf}"
+ assert leaf["ranges"] is None, f"leaf {name!r} was grounded anyway: {leaf}"