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)