diff --git a/api.md b/api.md
index 8d99aef..0652d10 100644
--- a/api.md
+++ b/api.md
@@ -73,6 +73,7 @@ from landingai_ade.types.v2 import (
Job,
JobError,
JobStatus,
+ V2ExtractBilling,
V2ExtractMetadata,
V2ExtractResult,
V2FileUploadResponse,
@@ -84,7 +85,7 @@ from landingai_ade.types.v2 import (
- Job -- unified job shape: `job_id`, `status` (JobStatus: `pending` / `processing` / `completed` / `failed` / `cancelled`), `created_at`, `completed_at`, `progress`, `result` (a `V2ParseResponse` for parse jobs, a `V2ExtractResult` for extract jobs, or `None` until completion), `error` (JobError), `raw` (the full original envelope as a `dict`), and the `.is_terminal` property.
- V2ParseResponse -- `markdown`, `structure`, `grounding`, `metadata` (V2ParseMetadata, which nests V2ParseBilling). Loosely typed pending a published gateway schema; unknown fields are retained.
-- V2ExtractResult -- `extraction`, `extraction_metadata`, `markdown`, `metadata` (V2ExtractMetadata).
+- V2ExtractResult -- `extraction`, `extraction_metadata`, `markdown`, `metadata` (V2ExtractMetadata, which nests V2ExtractBilling).
- V2FileUploadResponse -- `file_ref`.
Methods:
@@ -93,7 +94,7 @@ Methods:
Synchronous parse. Provide exactly one of `document` (file) or `document_url`. Returns a `V2ParseResponse` on both full success (HTTP 200) and partial success (HTTP 206, where `result.metadata.failed_pages` lists unparsed pages). Raises `V2SyncTimeoutError` (from `landingai_ade.lib.v2_errors`) on a 504; use `parse_jobs` for long-running documents.
-- client.v2.parse_jobs.create(\*, document=..., document_url=..., model=..., options=..., password=..., output_save_url=..., priority=...) -> Job
+- client.v2.parse_jobs.create(\*, document=..., document_url=..., model=..., options=..., password=..., output_save_url=..., service_tier=...) -> Job
- client.v2.parse_jobs.get(job_id) -> Job
- client.v2.parse_jobs.list(\*, page=..., page_size=..., status=...) -> JobList[Job]
- client.v2.parse_jobs.wait(job_id, \*, timeout=600, poll_interval=None, raise_on_failure=False) -> Job
@@ -104,7 +105,7 @@ Methods:
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`, `markdown_ref` (e.g. from `client.v2.files.upload`), 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.
-- client.v2.extract_jobs.create(\*, schema, markdown=..., markdown_ref=..., markdown_url=..., model=..., strict=..., idempotency_key=..., priority=...) -> Job
+- client.v2.extract_jobs.create(\*, schema, markdown=..., markdown_ref=..., markdown_url=..., model=..., strict=..., idempotency_key=..., 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
diff --git a/examples/v2_parse.py b/examples/v2_parse.py
index 8a04783..5ff1732 100644
--- a/examples/v2_parse.py
+++ b/examples/v2_parse.py
@@ -26,7 +26,7 @@
document_path = Path(__file__).parent / "sample.pdf"
# Create an async parse job for a (potentially large) document.
-job = client.v2.parse_jobs.create(document=document_path, priority="priority")
+job = client.v2.parse_jobs.create(document=document_path, service_tier="priority")
print(f"Created parse job {job.job_id} (status={job.status})")
# Block until the job reaches a terminal state.
diff --git a/src/landingai_ade/resources/v2/extract.py b/src/landingai_ade/resources/v2/extract.py
index e7d5590..7b736b4 100644
--- a/src/landingai_ade/resources/v2/extract.py
+++ b/src/landingai_ade/resources/v2/extract.py
@@ -30,7 +30,7 @@ def _build_extract_body(
model: object,
strict: object,
idempotency_key: object,
- priority: object = omit,
+ service_tier: object = omit,
) -> Dict[str, Any]:
body: Dict[str, Any] = {"schema": coerce_schema_to_dict(schema)}
for key, value in (
@@ -39,7 +39,7 @@ def _build_extract_body(
("markdown_url", markdown_url),
("model", model),
("idempotency_key", idempotency_key),
- ("priority", priority),
+ ("service_tier", service_tier),
):
if value is not omit and value is not None:
body[key] = value
@@ -180,7 +180,7 @@ def create(
model: Optional[str] | Omit = omit,
strict: Optional[bool] | Omit = omit,
idempotency_key: Optional[str] | Omit = omit,
- priority: Optional[Literal["standard", "priority"]] | 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.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
@@ -214,7 +214,7 @@ def create(
idempotency_key: An idempotency key for the request.
- priority: Processing priority for the job.
+ service_tier: Service tier for the job: ``standard`` or ``priority``.
extra_headers: Send extra headers
@@ -225,7 +225,7 @@ def create(
timeout: Override the client-level default timeout for this request, in seconds
"""
body = _build_extract_body(
- schema, markdown, markdown_ref, markdown_url, model, strict, idempotency_key, priority
+ schema, markdown, markdown_ref, markdown_url, model, strict, idempotency_key, service_tier
)
raw = self._post(
self._v2_url("/v2/extract/jobs"),
@@ -330,7 +330,7 @@ async def create(
model: Optional[str] | Omit = omit,
strict: Optional[bool] | Omit = omit,
idempotency_key: Optional[str] | Omit = omit,
- priority: Optional[Literal["standard", "priority"]] | 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.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
@@ -340,7 +340,7 @@ async def create(
) -> Job:
"""Async mirror of `ExtractJobsResource.create`. See there for full documentation."""
body = _build_extract_body(
- schema, markdown, markdown_ref, markdown_url, model, strict, idempotency_key, priority
+ schema, markdown, markdown_ref, markdown_url, model, strict, idempotency_key, service_tier
)
raw = await self._post(
self._v2_url("/v2/extract/jobs"),
diff --git a/src/landingai_ade/resources/v2/parse.py b/src/landingai_ade/resources/v2/parse.py
index 66126b7..4f2c6c3 100644
--- a/src/landingai_ade/resources/v2/parse.py
+++ b/src/landingai_ade/resources/v2/parse.py
@@ -190,7 +190,7 @@ def create(
options: Optional[Mapping[str, object]] | Omit = omit,
password: Optional[str] | Omit = omit,
output_save_url: Optional[str] | Omit = omit,
- priority: Optional[Literal["standard", "priority"]] | 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.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
@@ -220,7 +220,7 @@ def create(
output_save_url: If zero data retention (ZDR) is enabled, a URL the parsed output should be
saved to instead of being returned in the job result.
- priority: Processing priority for the job.
+ service_tier: Service tier for the job: ``standard`` or ``priority``.
extra_headers: Send extra headers
@@ -233,8 +233,8 @@ def create(
body = _build_parse_body(document, document_url, model, options, password)
if is_given(output_save_url) and output_save_url is not None:
body["output_save_url"] = output_save_url
- if is_given(priority) and priority is not None:
- body["priority"] = priority
+ if is_given(service_tier) and service_tier is not None:
+ body["service_tier"] = service_tier
body = deepcopy_with_paths(body, [["document"]])
files = extract_files(cast(Mapping[str, object], body), paths=[["document"]])
extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})}
@@ -340,7 +340,7 @@ async def create(
options: Optional[Mapping[str, object]] | Omit = omit,
password: Optional[str] | Omit = omit,
output_save_url: Optional[str] | Omit = omit,
- priority: Optional[Literal["standard", "priority"]] | 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.
# The extra values given here take precedence over values defined on the client or passed to this method.
extra_headers: Headers | None = None,
@@ -352,8 +352,8 @@ async def create(
body = _build_parse_body(document, document_url, model, options, password)
if is_given(output_save_url) and output_save_url is not None:
body["output_save_url"] = output_save_url
- if is_given(priority) and priority is not None:
- body["priority"] = priority
+ if is_given(service_tier) and service_tier is not None:
+ body["service_tier"] = service_tier
body = deepcopy_with_paths(body, [["document"]])
files = extract_files(cast(Mapping[str, object], body), paths=[["document"]])
extra_headers = {"Content-Type": "multipart/form-data", **(extra_headers or {})}
diff --git a/src/landingai_ade/types/v2/__init__.py b/src/landingai_ade/types/v2/__init__.py
index 7a18255..7502512 100644
--- a/src/landingai_ade/types/v2/__init__.py
+++ b/src/landingai_ade/types/v2/__init__.py
@@ -8,6 +8,7 @@
)
from .extract_response import (
V2ExtractResult as V2ExtractResult,
+ V2ExtractBilling as V2ExtractBilling,
V2ExtractMetadata as V2ExtractMetadata,
)
from .file_upload_response import V2FileUploadResponse as V2FileUploadResponse
diff --git a/src/landingai_ade/types/v2/extract_response.py b/src/landingai_ade/types/v2/extract_response.py
index 02869c9..05f75bd 100644
--- a/src/landingai_ade/types/v2/extract_response.py
+++ b/src/landingai_ade/types/v2/extract_response.py
@@ -4,7 +4,14 @@
from ..._models import BaseModel
-__all__ = ["V2ExtractMetadata", "V2ExtractResult"]
+__all__ = ["V2ExtractBilling", "V2ExtractMetadata", "V2ExtractResult"]
+
+
+class V2ExtractBilling(BaseModel):
+ """Billing summary: the service tier the request ran in and the credits charged."""
+
+ service_tier: Optional[str] = None
+ total_credits: Optional[float] = None
class V2ExtractMetadata(BaseModel):
@@ -13,6 +20,7 @@ class V2ExtractMetadata(BaseModel):
duration_ms: int
doc_id: Optional[str] = None
credit_usage: float = 0.0
+ billing: Optional[V2ExtractBilling] = None
class V2ExtractResult(BaseModel):
diff --git a/tests/api_resources/v2/test_extract.py b/tests/api_resources/v2/test_extract.py
index 9636504..d0fad83 100644
--- a/tests/api_resources/v2/test_extract.py
+++ b/tests/api_resources/v2/test_extract.py
@@ -66,7 +66,7 @@ def test_extract_job_create_and_get() -> None:
respx.post("https://api.ade.landing.ai/v2/extract/jobs").mock(
return_value=httpx.Response(202, json={"job_id": "e1", "status": "pending", "created_at": "2026-01-01T00:00:00Z"})
)
- job = client.v2.extract_jobs.create(schema={"type": "object"}, markdown="x", priority="priority")
+ job = client.v2.extract_jobs.create(schema={"type": "object"}, markdown="x", service_tier="priority")
assert job.job_id == "e1" and job.status is JobStatus.PENDING
respx.get("https://api.ade.landing.ai/v2/extract/jobs/e1").mock(
@@ -82,6 +82,37 @@ def test_extract_job_create_and_get() -> None:
assert done.result.metadata.version == "extract-1"
+def test_extract_job_create_sends_service_tier(monkeypatch: pytest.MonkeyPatch) -> None:
+ # The async job create body must carry `service_tier` (renamed from the
+ # old `priority` field per the V2 spec).
+ client = LandingAIADE(apikey=APIKEY)
+ captured: Dict[str, Any] = {}
+
+ def fake_post(path: str, *, cast_to: Any, body: Any = None, options: Any = None, **kwargs: Any) -> Any: # noqa: ARG001
+ captured["body"] = body
+ 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", service_tier="priority")
+ assert captured["body"]["service_tier"] == "priority"
+ assert "priority" not in captured["body"]
+
+
+@respx.mock
+def test_extract_sync_parses_billing_metadata() -> None:
+ # The V2 spec adds a `billing` object to the response metadata.
+ client = LandingAIADE(apikey=APIKEY)
+ body: Dict[str, Any] = dict(EXTRACT_BODY)
+ metadata: Dict[str, Any] = dict(EXTRACT_BODY["metadata"])
+ metadata["billing"] = {"service_tier": "priority", "total_credits": 12.5}
+ body["metadata"] = metadata
+ respx.post("https://api.ade.landing.ai/v2/extract").mock(return_value=httpx.Response(200, json=body))
+ result = client.v2.extract(schema={"type": "object"}, markdown="x")
+ assert result.metadata.billing is not None
+ assert result.metadata.billing.service_tier == "priority"
+ assert result.metadata.billing.total_credits == 12.5
+
+
@respx.mock
def test_extract_job_get_failed_maps_error_object() -> None:
client = LandingAIADE(apikey=APIKEY)
diff --git a/tests/api_resources/v2/test_parse.py b/tests/api_resources/v2/test_parse.py
index e70c7f5..4d81e89 100644
--- a/tests/api_resources/v2/test_parse.py
+++ b/tests/api_resources/v2/test_parse.py
@@ -74,7 +74,7 @@ def test_parse_sync_omits_explicit_none_fields_from_multipart_body() -> None:
def test_parse_job_create_omits_explicit_none_extra_fields(monkeypatch: pytest.MonkeyPatch) -> None:
# Regression test at the body-dict level (before multipart encoding, which
# happens to drop bare `None` values and would otherwise mask this bug):
- # `output_save_url=None`/`priority=None` must not survive into the body
+ # `output_save_url=None`/`service_tier=None` must not survive into the body
# dict handed to the request layer.
client = LandingAIADE(apikey=APIKEY)
captured: Dict[str, Any] = {}
@@ -84,8 +84,24 @@ def fake_post(path: str, *, cast_to: Any, body: Any = None, files: Any = None, o
return {"job_id": "p1", "status": "pending"}
monkeypatch.setattr(client.v2.parse_jobs, "_post", fake_post)
- client.v2.parse_jobs.create(document=b"x", output_save_url=None, priority=None)
+ client.v2.parse_jobs.create(document=b"x", output_save_url=None, service_tier=None)
assert "output_save_url" not in captured["body"]
+ assert "service_tier" not in captured["body"]
+
+
+def test_parse_job_create_sends_service_tier(monkeypatch: pytest.MonkeyPatch) -> None:
+ # The async job create body must carry `service_tier` (renamed from the
+ # old `priority` field per the V2 spec).
+ client = LandingAIADE(apikey=APIKEY)
+ captured: Dict[str, Any] = {}
+
+ def fake_post(path: str, *, cast_to: Any, body: Any = None, files: Any = None, options: Any = None) -> Any: # noqa: ARG001
+ captured["body"] = body
+ return {"job_id": "p1", "status": "pending"}
+
+ monkeypatch.setattr(client.v2.parse_jobs, "_post", fake_post)
+ client.v2.parse_jobs.create(document=b"x", service_tier="priority")
+ assert captured["body"]["service_tier"] == "priority"
assert "priority" not in captured["body"]
@@ -178,7 +194,7 @@ def test_parse_job_create_normalizes_envelope() -> None:
respx.post("https://api.ade.landing.ai/v2/parse/jobs").mock(
return_value=httpx.Response(202, json={"job_id": "p1", "status": "pending", "received_at": 1700000000})
)
- job = client.v2.parse_jobs.create(document=b"pdf", priority="priority")
+ job = client.v2.parse_jobs.create(document=b"pdf", service_tier="priority")
assert isinstance(job, Job)
assert job.job_id == "p1" and job.status is JobStatus.PENDING
@@ -198,6 +214,37 @@ def test_parse_job_get_completed_has_typed_result() -> None:
assert job.result.markdown == "# Hello"
+@respx.mock
+def test_parse_job_get_206_partial_returns_result() -> None:
+ # Per the V2 spec, GET /v2/parse/jobs/{id} can return 206 (partial success).
+ # 206 is a 2xx, so the base client treats it as success and the envelope is
+ # normalized like any completed job.
+ client = LandingAIADE(apikey=APIKEY)
+ body: Dict[str, Any] = dict(PARSE_BODY)
+ metadata: Dict[str, Any] = dict(PARSE_BODY["metadata"])
+ metadata["failed_pages"] = [2]
+ body["metadata"] = metadata
+ respx.get("https://api.ade.landing.ai/v2/parse/jobs/p1").mock(
+ return_value=httpx.Response(206, json={"job_id": "p1", "status": "completed", "data": body})
+ )
+ job = client.v2.parse_jobs.get("p1")
+ assert job.status is JobStatus.COMPLETED
+ assert isinstance(job.result, V2ParseResponse)
+ assert job.result.metadata is not None and job.result.metadata.failed_pages == [2]
+
+
+@respx.mock
+def test_parse_job_get_404_raises_not_found() -> None:
+ from landingai_ade import NotFoundError
+
+ client = LandingAIADE(apikey=APIKEY, max_retries=0)
+ respx.get("https://api.ade.landing.ai/v2/parse/jobs/missing").mock(
+ return_value=httpx.Response(404, json={"detail": "not found"})
+ )
+ with pytest.raises(NotFoundError):
+ client.v2.parse_jobs.get("missing")
+
+
@respx.mock
def test_parse_job_get_empty_job_id_raises() -> None:
client = LandingAIADE(apikey=APIKEY)