diff --git a/api.md b/api.md index 67c1625..adc2194 100644 --- a/api.md +++ b/api.md @@ -99,7 +99,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, a `V2BuildSchemaResponse` for build-schema jobs, or `None` until completion), `error` (JobError), `raw` (the full original envelope as a `dict`), and the `.is_terminal` property. +- 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, a `V2BuildSchemaResponse` for build-schema jobs, or `None` until completion), `metadata` (the result's metadata receipt -- a `V2ParseMetadata` for parse jobs, a `V2ExtractMetadata` for extract jobs -- surfaced when an `output_save_url` job completes: the result is delivered to `output_url` (see `raw`) but the receipt stays here; inline jobs carry it inside `result` instead), `error` (JobError), `raw` (the full original envelope as a `dict`), and the `.is_terminal` property. - V2ParseResponse -- `markdown`, `structure`, `grounding`, `metadata` (V2ParseMetadata, which nests V2ParseBilling and carries `output_markdown_chars`, `range_units`, and `openapi_spec`). `structure` is a typed V2ParseStructure tree (`document` → V2ParsePageV2ParseElement); each node below the root carries its spatial data inline in a V2ParseNodeGrounding (`page`, V2ParseRange, V2ParseBox, normalized page coordinates), and leaf elements additionally carry an `atomic_grounding` list. With `options.inline_markdown`, each node also carries its `markdown` slice. The legacy top-level `grounding` tree (V2ParseGrounding → `V2ParseGroundingPage` → `V2ParseGroundingElement` → `V2ParseGroundingEntry`) is retained for older gateway responses. Element `type`/page `status` are permissive strings and unknown keys are retained. - V2ExtractResult -- `extraction`, `extraction_metadata`, `markdown`, `output_ref`, `schema_violation_error` (set when `strict=False` and the schema had unextractable fields), `warnings`, and `metadata` (V2ExtractMetadata, which carries `model_version`, `input_markdown_chars`, `output_extraction_chars`, `range_units`, `openapi_spec`, and nests V2ExtractBilling). - V2BuildSchemaResponse -- `extraction_schema` (the generated JSON Schema serialized as a string) and `metadata` (V2BuildSchemaMetadata: `job_id`, `duration_ms`, `openapi_spec`, `filename`/`org_id`/`version` (retained for compatibility), a `warnings` list of V2BuildSchemaWarning (`code`, `msg`), and nested V2BuildSchemaBilling). diff --git a/docs/v2-testing.md b/docs/v2-testing.md index f669a5b..38b0e73 100644 --- a/docs/v2-testing.md +++ b/docs/v2-testing.md @@ -9,7 +9,7 @@ what to check when the upstream spec (`specs/v2-aide.json`) changes. | Layer | Location | What it covers | | --- | --- | --- | | Response models | `tests/test_v2_types.py` | Deserialization of `V2ParseResponse` / `V2ExtractResult` / `V2BuildSchemaResponse` / `V2GroundResult` and their nested models from plain dicts, including unknown-key tolerance. | -| Job normalization | `tests/test_v2_normalize.py` | `normalize_parse_job` / `normalize_extract_job` / `normalize_build_schema_job`: envelope → unified `Job` (status, timestamps, `result`, `error`). | +| Job normalization | `tests/test_v2_normalize.py` | `normalize_parse_job` / `normalize_extract_job` / `normalize_build_schema_job`: envelope → unified `Job` (status, timestamps, `result`, `metadata`, `error`). | | Resource wiring | `tests/api_resources/v2/` | `respx`-mocked HTTP: host routing, multipart/JSON bodies, options serialization, job polling. No network. | | Live smoke | `tests/contract/test_v2_smoke.py` | End-to-end calls against staging (marked `contract`; skipped unless `LANDINGAI_ADE_STAGING_APIKEY` is set). | @@ -66,7 +66,11 @@ upstream; both are retained on `V2ExtractBilling` for backward compatibility. 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 -reports `output_url` (on `Job.raw`) instead of an inline `result`. +reports `output_url` (on `Job.raw`) instead of an inline `result`. The delivery +moves the content, not the receipt — the metadata block is still returned on the +job status, and the normalizer surfaces it on `Job.metadata` (a `V2ExtractMetadata` +for extract jobs, a `V2ParseMetadata` for parse jobs). Inline jobs leave +`Job.metadata` as `None` and carry the receipt inside `result.metadata` instead. ## Current build-schema-response shape @@ -109,6 +113,9 @@ field-name drift: - Failures arrive as a structured `error` object (`{code, message}`); older parse envelopes used a flat `failure_reason` string. Both map to `Job.error`. - `created_at` / `completed_at` accept ISO-8601 strings or epoch seconds. +- A top-level `metadata` block (present once an `output_save_url` job completes) + maps to `Job.metadata`; it stays `None` for inline jobs, whose metadata rides on + `result.metadata` instead. - Unknown / renamed `status` values fall back to `pending` rather than raising; the raw envelope is always preserved on `Job.raw`. diff --git a/specs/_generated/v2_models.py b/specs/_generated/v2_models.py index 2d60b3d..e74ed21 100644 --- a/specs/_generated/v2_models.py +++ b/specs/_generated/v2_models.py @@ -546,82 +546,7 @@ class V2ExtractPostResponse(BaseModel): ) -class V2ExtractBuildSchemaPostRequest(BaseModel): - """ - Input to V2BuildSchemaOperationWorkflow — the ``/v2/extract/build-schema`` - request body. - - Mirrors VTRA's ``BuildSchemaRequest``: generate a JSON Schema from one or - more source markdown documents and/or a natural-language ``prompt``, and/or - iterate on an existing ``schema``. At least one of ``markdowns`` / - ``markdown_urls`` / ``prompt`` / ``schema`` must be provided. - """ - - markdown_urls: Optional[list[str]] = Field( - None, - description='URLs to Markdown files to analyze for schema generation.', - title='Markdown Urls', - ) - markdowns: Optional[list[str]] = Field( - None, - description='Markdown files or inline content strings to analyze for schema generation. Multiple documents can be provided for better schema coverage.', - title='Markdowns', - ) - prompt: Optional[str] = Field( - None, - description='Instructions for how to generate or modify the schema.', - title='Prompt', - ) - schema_: Optional[str] = Field( - None, - alias='schema', - description='Existing JSON schema to iterate on or refine.', - title='Schema', - ) - - -class V2ExtractBuildSchemaPostRequest1(BaseModel): - markdown_urls: Optional[list[str]] = Field( - None, - description='URLs to Markdown files to analyze for schema generation. JSON-serialized string in form data.', - title='Markdown Urls', - ) - markdowns: Optional[list[Union[str, bytes]]] = Field( - None, description='Repeat the field for each file upload.' - ) - prompt: Optional[str] = Field( - None, - description='Instructions for how to generate or modify the schema. JSON-serialized string in form data.', - title='Prompt', - ) - schema_: Optional[str] = Field( - None, - alias='schema', - description='Existing JSON schema to iterate on or refine. JSON-serialized string in form data.', - title='Schema', - ) - - -class V2ExtractBuildSchemaPostResponse(BaseModel): - """ - Result returned by V2BuildSchemaOperationWorkflow — the - ``/v2/extract/build-schema`` response body (VTRA ``BuildSchemaResponse``). - - ``extraction_schema`` is the generated JSON Schema serialized as a STRING - (VTRA parity — the v1 field is a string, not an object). - """ - - extraction_schema: str = Field( - ..., - description='The generated JSON schema as a string.', - title='Extraction Schema', - ) - metadata: V2BuildSchemaMetadata = Field( - ..., description='The metadata for the schema generation process.' - ) - - -class V2ExtractBuildSchemaJobsGetParametersQuery(BaseModel): +class V2ExtractJobsGetParametersQuery(BaseModel): page: Optional[int] = Field( 0, description='Page number (0-indexed).', ge=0, title='Page' ) @@ -646,13 +571,13 @@ class Job(BaseModel): failure_reason: Optional[str] = None job_id: Optional[str] = Field( None, - description='The unique identifier for this v2-build-schema job. Format: ``extract-<26-character Crockford base32 ULID>`` (``[0-9a-hjkmnp-tv-z]{26}`` tail). Opaque, server-minted, and stable for the life of the job — the same id is returned on the sync response, the async 202, and every poll. Treat it as opaque; older id formats remain accepted indefinitely and are never re-issued.', + description='The unique identifier for this v2-extract job. Format: ``extract-<26-character Crockford base32 ULID>`` (``[0-9a-hjkmnp-tv-z]{26}`` tail). Opaque, server-minted, and stable for the life of the job — the same id is returned on the sync response, the async 202, and every poll. Treat it as opaque; older id formats remain accepted indefinitely and are never re-issued.', ) model_version: Optional[str] = None status: Optional[Status1] = None -class V2ExtractBuildSchemaJobsGetResponse(BaseModel): +class V2ExtractJobsGetResponse(BaseModel): has_more: Optional[bool] = None jobs: Optional[list[Job]] = None page: Optional[int] = None @@ -668,164 +593,6 @@ class ServiceTier2(Enum): priority = 'priority' -class V2ExtractBuildSchemaJobsPostRequest(BaseModel): - """ - Input to V2BuildSchemaOperationWorkflow — the ``/v2/extract/build-schema`` - request body. - - Mirrors VTRA's ``BuildSchemaRequest``: generate a JSON Schema from one or - more source markdown documents and/or a natural-language ``prompt``, and/or - iterate on an existing ``schema``. At least one of ``markdowns`` / - ``markdown_urls`` / ``prompt`` / ``schema`` must be provided. - """ - - markdown_urls: Optional[list[str]] = Field( - None, - description='URLs to Markdown files to analyze for schema generation.', - title='Markdown Urls', - ) - markdowns: Optional[list[str]] = Field( - None, - description='Markdown files or inline content strings to analyze for schema generation. Multiple documents can be provided for better schema coverage.', - title='Markdowns', - ) - prompt: Optional[str] = Field( - None, - description='Instructions for how to generate or modify the schema.', - title='Prompt', - ) - schema_: Optional[str] = Field( - None, - alias='schema', - description='Existing JSON schema to iterate on or refine.', - title='Schema', - ) - service_tier: Optional[ServiceTier2] = Field( - None, - description='Async service tier. ``priority`` runs in the fast lane at the sync billing rate; absent → ``standard``.', - ) - - -class V2ExtractBuildSchemaJobsPostRequest1(BaseModel): - markdown_urls: Optional[list[str]] = Field( - None, - description='URLs to Markdown files to analyze for schema generation. JSON-serialized string in form data.', - title='Markdown Urls', - ) - markdowns: Optional[list[Union[str, bytes]]] = Field( - None, description='Repeat the field for each file upload.' - ) - prompt: Optional[str] = Field( - None, - description='Instructions for how to generate or modify the schema. JSON-serialized string in form data.', - title='Prompt', - ) - schema_: Optional[str] = Field( - None, - alias='schema', - description='Existing JSON schema to iterate on or refine. JSON-serialized string in form data.', - title='Schema', - ) - service_tier: Optional[ServiceTier2] = Field( - None, - description='Async service tier. ``priority`` runs in the fast lane at the sync billing rate; absent → ``standard``.', - ) - - -class V2ExtractBuildSchemaJobsPostResponse(BaseModel): - created_at: Optional[str] = None - job_id: Optional[str] = Field( - None, - description='The unique identifier for this v2-build-schema job. Format: ``extract-<26-character Crockford base32 ULID>`` (``[0-9a-hjkmnp-tv-z]{26}`` tail). Opaque, server-minted, and stable for the life of the job — the same id is returned on the sync response, the async 202, and every poll. Treat it as opaque; older id formats remain accepted indefinitely and are never re-issued.', - ) - status: Optional[Status1] = None - - -class Error(BaseModel): - """ - Present once status is ``failed``. - """ - - code: Optional[str] = Field( - None, description='Stable error code (``internal_error`` when unmapped).' - ) - message: Optional[str] = None - - -class Result(BaseModel): - """ - Result returned by V2BuildSchemaOperationWorkflow — the - ``/v2/extract/build-schema`` response body (VTRA ``BuildSchemaResponse``). - - ``extraction_schema`` is the generated JSON Schema serialized as a STRING - (VTRA parity — the v1 field is a string, not an object). - """ - - extraction_schema: str = Field( - ..., - description='The generated JSON schema as a string.', - title='Extraction Schema', - ) - metadata: V2BuildSchemaMetadata = Field( - ..., description='The metadata for the schema generation process.' - ) - - -class V2ExtractBuildSchemaJobsJobIdGetResponse(BaseModel): - completed_at: Optional[str] = Field( - None, description='Present once the job is terminal.' - ) - created_at: Optional[str] = None - error: Optional[Error] = Field( - None, description='Present once status is ``failed``.' - ) - job_id: Optional[str] = Field( - None, - description='The unique identifier for this v2-build-schema job. Format: ``extract-<26-character Crockford base32 ULID>`` (``[0-9a-hjkmnp-tv-z]{26}`` tail). Opaque, server-minted, and stable for the life of the job — the same id is returned on the sync response, the async 202, and every poll. Treat it as opaque; older id formats remain accepted indefinitely and are never re-issued.', - ) - progress: Optional[float] = Field( - None, - description='Job completion as a decimal from 0 (not started) to 1 (complete). Present while ``processing``.', - ge=0.0, - le=1.0, - ) - result: Optional[Result] = Field( - None, description='Present once status is ``completed``.' - ) - status: Optional[Status1] = None - - -class V2ExtractJobsGetParametersQuery(BaseModel): - page: Optional[int] = Field( - 0, description='Page number (0-indexed).', ge=0, title='Page' - ) - page_size: Optional[int] = Field( - 10, description='Number of items per page.', ge=1, le=100, title='Page Size' - ) - status: Optional[str] = Field( - None, description='Filter by job status.', title='Status' - ) - - -class Job1(BaseModel): - completed_at: Optional[str] = None - created_at: Optional[str] = None - failure_reason: Optional[str] = None - job_id: Optional[str] = Field( - None, - description='The unique identifier for this v2-extract job. Format: ``extract-<26-character Crockford base32 ULID>`` (``[0-9a-hjkmnp-tv-z]{26}`` tail). Opaque, server-minted, and stable for the life of the job — the same id is returned on the sync response, the async 202, and every poll. Treat it as opaque; older id formats remain accepted indefinitely and are never re-issued.', - ) - model_version: Optional[str] = None - status: Optional[Status1] = None - - -class V2ExtractJobsGetResponse(BaseModel): - has_more: Optional[bool] = None - jobs: Optional[list[Job1]] = None - page: Optional[int] = None - page_size: Optional[int] = None - - class V2ExtractJobsPostRequest(BaseModel): """ Input to V2ExtractOperationWorkflow. @@ -931,7 +698,18 @@ class V2ExtractJobsPostResponse(BaseModel): status: Optional[Status1] = None -class Result1(BaseModel): +class Error(BaseModel): + """ + Present once status is ``failed``. + """ + + code: Optional[str] = Field( + None, description='Stable error code (``internal_error`` when unmapped).' + ) + message: Optional[str] = None + + +class Result(BaseModel): """ Result returned by V2ExtractOperationWorkflow — the ``/v2/extract`` response body (``docs/extract-v2-proposal.md`` → Response). @@ -980,17 +758,21 @@ class V2ExtractJobsJobIdGetResponse(BaseModel): None, description='The unique identifier for this v2-extract job. Format: ``extract-<26-character Crockford base32 ULID>`` (``[0-9a-hjkmnp-tv-z]{26}`` tail). Opaque, server-minted, and stable for the life of the job — the same id is returned on the sync response, the async 202, and every poll. Treat it as opaque; older id formats remain accepted indefinitely and are never re-issued.', ) + metadata: Optional[dict[str, Any]] = Field( + None, + description="The result's metadata block (billing included), present alongside ``output_url`` once a job with ``output_save_url`` has ``completed`` — the delivery moves the content, not the receipt. Same shape as the inline ``result``'s ``metadata``; inline jobs carry it there instead.", + ) output_url: Optional[str] = Field( None, description='The URL the result was delivered to. Present once the job has ``completed`` and ``output_save_url`` was set, instead of inline ``result``.', ) progress: Optional[float] = Field( None, - description='Job completion as a decimal from 0 (not started) to 1 (complete). Present while ``processing``.', + description='Estimated completion as a decimal from 0 to 1 — an estimate, not a measurement: it typically advances between polls while the job is ``processing``, may jump forward when the service reports a real milestone (e.g. parsed pages), and approaches but never reaches 1 (long-running jobs plateau near 0.98 — completion is signaled by ``status``, and a job may complete from any progress value). Present while ``processing``.', ge=0.0, le=1.0, ) - result: Optional[Result1] = Field( + result: Optional[Result] = Field( None, description='Present once status is ``completed`` and ``output_save_url`` was not set. When ``output_save_url`` was set, the result is delivered there and ``output_url`` is returned instead.', ) @@ -1106,7 +888,7 @@ class V2ParseJobsGetParametersQuery(BaseModel): ) -class Status7(Enum): +class Status4(Enum): """ The job's current status: ``pending``, ``processing``, ``completed``, or ``failed``. """ @@ -1117,7 +899,7 @@ class Status7(Enum): failed = 'failed' -class Job2(BaseModel): +class Job1(BaseModel): completed_at: Optional[str] = Field( None, description='ISO-8601 timestamp for when the job finished, if terminal.' ) @@ -1135,7 +917,7 @@ class Job2(BaseModel): model_version: Optional[str] = Field( None, description='The model snapshot used to parse the document.' ) - status: Optional[Status7] = Field( + status: Optional[Status4] = Field( None, description="The job's current status: ``pending``, ``processing``, ``completed``, or ``failed``.", ) @@ -1146,14 +928,14 @@ class V2ParseJobsGetResponse(BaseModel): None, description='Whether more jobs exist beyond this page; request the next ``page`` to fetch them.', ) - jobs: Optional[list[Job2]] = Field( + jobs: Optional[list[Job1]] = Field( None, description="The caller's parse jobs for this page, newest first." ) page: Optional[int] = Field(None, description='The 0-indexed page number.') page_size: Optional[int] = Field(None, description='Items per page.') -class ServiceTier6(Enum): +class ServiceTier4(Enum): """ Async service tier (``POST /jobs`` only). ``priority`` runs in the fast lane at the sync billing rate; absent → ``standard``. """ @@ -1162,7 +944,7 @@ class ServiceTier6(Enum): priority = 'priority' -class Status8(Enum): +class Status5(Enum): """ The job's status at creation — normally ``pending`` (a just-created job that is still running is reported as ``pending``), but may already be a terminal ``completed`` / ``failed`` if the job finished before the create response was rendered. """ @@ -1181,13 +963,13 @@ class V2ParseJobsPostResponse(BaseModel): ..., description='The unique identifier for the created parse job. Poll ``GET /v2/parse/jobs/{job_id}`` for its status and result. Format: ``-<26-character Crockford base32 ULID>`` matching ``^(parse|extract)-[0-9a-hjkmnp-tv-z]{26}$``. Opaque, server-minted, and stable for the life of the job — the same id is returned on the sync response, the async 202, and every poll. Treat it as opaque; older id formats remain accepted indefinitely and are never re-issued.', ) - status: Status8 = Field( + status: Status5 = Field( ..., description="The job's status at creation — normally ``pending`` (a just-created job that is still running is reported as ``pending``), but may already be a terminal ``completed`` / ``failed`` if the job finished before the create response was rendered.", ) -class Error2(BaseModel): +class Error1(BaseModel): """ Present once the job has ``failed`` — the failure code + message. """ @@ -1196,7 +978,7 @@ class Error2(BaseModel): message: Optional[str] = None -class Status9(Enum): +class Status6(Enum): """ The job's current status: ``pending``, ``processing``, ``completed``, or ``failed``. """ @@ -1255,14 +1037,14 @@ class V2WorkflowJobsGetParametersQuery(BaseModel): ) -class Status10(Enum): +class Status7(Enum): pending = 'pending' processing = 'processing' completed = 'completed' failed = 'failed' -class Job3(BaseModel): +class Job2(BaseModel): completed_at: Optional[str] = None created_at: Optional[str] = None failure_reason: Optional[str] = None @@ -1271,17 +1053,17 @@ class Job3(BaseModel): description='The unique identifier for this v2-workflow job. Format: ``v2-workflow-<26-character Crockford base32 ULID>`` (``[0-9a-hjkmnp-tv-z]{26}`` tail). Opaque, server-minted, and stable for the life of the job — the same id is returned on the sync response, the async 202, and every poll. Treat it as opaque; older id formats remain accepted indefinitely and are never re-issued.', ) model_version: Optional[str] = None - status: Optional[Status10] = None + status: Optional[Status7] = None class V2WorkflowJobsGetResponse(BaseModel): has_more: Optional[bool] = None - jobs: Optional[list[Job3]] = None + jobs: Optional[list[Job2]] = None page: Optional[int] = None page_size: Optional[int] = None -class ServiceTier7(Enum): +class ServiceTier5(Enum): """ Async service tier. ``priority`` runs in the fast lane at the sync billing rate; absent → ``standard``. """ @@ -1296,10 +1078,10 @@ class V2WorkflowJobsPostResponse(BaseModel): None, description='The unique identifier for this v2-workflow job. Format: ``v2-workflow-<26-character Crockford base32 ULID>`` (``[0-9a-hjkmnp-tv-z]{26}`` tail). Opaque, server-minted, and stable for the life of the job — the same id is returned on the sync response, the async 202, and every poll. Treat it as opaque; older id formats remain accepted indefinitely and are never re-issued.', ) - status: Optional[Status10] = None + status: Optional[Status7] = None -class Error3(BaseModel): +class Error2(BaseModel): """ Present once status is ``failed``. """ @@ -1310,7 +1092,7 @@ class Error3(BaseModel): message: Optional[str] = None -class Result2(BaseModel): +class Result1(BaseModel): """ Result returned by V2WorkflowOperationWorkflow. @@ -1351,7 +1133,7 @@ class V2WorkflowJobsJobIdGetResponse(BaseModel): None, description='Present once the job is terminal.' ) created_at: Optional[str] = None - error: Optional[Error3] = Field( + error: Optional[Error2] = Field( None, description='Present once status is ``failed``.' ) job_id: Optional[str] = Field( @@ -1360,14 +1142,14 @@ class V2WorkflowJobsJobIdGetResponse(BaseModel): ) progress: Optional[float] = Field( None, - description='Job completion as a decimal from 0 (not started) to 1 (complete). Present while ``processing``.', + description='Estimated completion as a decimal from 0 to 1 — an estimate, not a measurement: it typically advances between polls while the job is ``processing``, may jump forward when the service reports a real milestone (e.g. parsed pages), and approaches but never reaches 1 (long-running jobs plateau near 0.98 — completion is signaled by ``status``, and a job may complete from any progress value). Present while ``processing``.', ge=0.0, le=1.0, ) - result: Optional[Result2] = Field( + result: Optional[Result1] = Field( None, description='Present once status is ``completed``.' ) - status: Optional[Status10] = None + status: Optional[Status7] = None class BlocksOptions(BaseModel): @@ -1509,7 +1291,7 @@ class V2ParseJobsPostRequest(BaseModel): None, description='Public URL the full response is delivered to; the API response then carries ``output_url`` instead of inline data.', ) - service_tier: Optional[ServiceTier6] = Field( + service_tier: Optional[ServiceTier4] = Field( None, description='Async service tier (``POST /jobs`` only). ``priority`` runs in the fast lane at the sync billing rate; absent → ``standard``.', ) @@ -1659,7 +1441,7 @@ class V2WorkflowJobsPostRequest(BaseModel): ], title='Output', ) - service_tier: Optional[ServiceTier7] = Field( + service_tier: Optional[ServiceTier5] = Field( None, description='Async service tier. ``priority`` runs in the fast lane at the sync billing rate; absent → ``standard``.', ) @@ -1700,7 +1482,7 @@ class V2WorkflowJobsPostRequest1(BaseModel): ], title='Output', ) - service_tier: Optional[ServiceTier7] = Field( + service_tier: Optional[ServiceTier5] = Field( None, description='Async service tier. ``priority`` runs in the fast lane at the sync billing rate; absent → ``standard``.', ) @@ -1861,7 +1643,7 @@ class V2ParseJobsJobIdGetResponse(BaseModel): created_at: Optional[str] = Field( None, description='ISO-8601 timestamp for when the job was created.' ) - error: Optional[Error2] = Field( + error: Optional[Error1] = Field( None, description='Present once the job has ``failed`` — the failure code + message.', ) @@ -1869,6 +1651,10 @@ class V2ParseJobsJobIdGetResponse(BaseModel): None, description='The unique identifier for this parse job. Format: ``-<26-character Crockford base32 ULID>`` matching ``^(parse|extract)-[0-9a-hjkmnp-tv-z]{26}$``. Opaque, server-minted, and stable for the life of the job — the same id is returned on the sync response, the async 202, and every poll. Treat it as opaque; older id formats remain accepted indefinitely and are never re-issued.', ) + metadata: Optional[ParseMetadata] = Field( + None, + description='The parse metadata (billing included), present alongside ``output_url`` once a job with ``output_save_url`` has ``completed`` — the delivery moves the content, not the receipt. Inline jobs carry it inside ``result`` instead.', + ) output_url: Optional[str] = Field( None, description='The URL the result was delivered to. Present once the job has ``completed`` and ``output_save_url`` was set, instead of inline ``result``.', @@ -1881,7 +1667,7 @@ class V2ParseJobsJobIdGetResponse(BaseModel): None, description='The parse response, present once the job has ``completed`` and ``output_save_url`` was not set. When ``output_save_url`` was set, the result is delivered there and ``output_url`` is returned instead.', ) - status: Optional[Status9] = Field( + status: Optional[Status6] = Field( None, description="The job's current status: ``pending``, ``processing``, ``completed``, or ``failed``.", ) diff --git a/specs/v2-aide.json b/specs/v2-aide.json index da25f28..ac712a7 100644 --- a/specs/v2-aide.json +++ b/specs/v2-aide.json @@ -1724,6 +1724,13 @@ "description": "The unique identifier for this v2-extract job. Format: ``extract-<26-character Crockford base32 ULID>`` (``[0-9a-hjkmnp-tv-z]{26}`` tail). Opaque, server-minted, and stable for the life of the job — the same id is returned on the sync response, the async 202, and every poll. Treat it as opaque; older id formats remain accepted indefinitely and are never re-issued.", "type": "string" }, + "metadata": { + "description": "The result's metadata block (billing included), present alongside ``output_url`` once a job with ``output_save_url`` has ``completed`` — the delivery moves the content, not the receipt. Same shape as the inline ``result``'s ``metadata``; inline jobs carry it there instead.", + "type": [ + "object", + "null" + ] + }, "output_url": { "description": "The URL the result was delivered to. Present once the job has ``completed`` and ``output_save_url`` was set, instead of inline ``result``.", "type": [ @@ -1732,7 +1739,7 @@ ] }, "progress": { - "description": "Job completion as a decimal from 0 (not started) to 1 (complete). Present while ``processing``.", + "description": "Estimated completion as a decimal from 0 to 1 — an estimate, not a measurement: it typically advances between polls while the job is ``processing``, may jump forward when the service reports a real milestone (e.g. parsed pages), and approaches but never reaches 1 (long-running jobs plateau near 0.98 — completion is signaled by ``status``, and a job may complete from any progress value). Present while ``processing``.", "maximum": 1, "minimum": 0, "type": "number" @@ -2449,6 +2456,17 @@ "description": "The unique identifier for this parse job. Format: ``-<26-character Crockford base32 ULID>`` matching ``^(parse|extract)-[0-9a-hjkmnp-tv-z]{26}$``. Opaque, server-minted, and stable for the life of the job — the same id is returned on the sync response, the async 202, and every poll. Treat it as opaque; older id formats remain accepted indefinitely and are never re-issued.", "type": "string" }, + "metadata": { + "anyOf": [ + { + "$ref": "#/components/schemas/ParseMetadata" + }, + { + "type": "null" + } + ], + "description": "The parse metadata (billing included), present alongside ``output_url`` once a job with ``output_save_url`` has ``completed`` — the delivery moves the content, not the receipt. Inline jobs carry it inside ``result`` instead." + }, "output_url": { "description": "The URL the result was delivered to. Present once the job has ``completed`` and ``output_save_url`` was set, instead of inline ``result``.", "type": [ @@ -3108,7 +3126,7 @@ "type": "string" }, "progress": { - "description": "Job completion as a decimal from 0 (not started) to 1 (complete). Present while ``processing``.", + "description": "Estimated completion as a decimal from 0 to 1 — an estimate, not a measurement: it typically advances between polls while the job is ``processing``, may jump forward when the service reports a real milestone (e.g. parsed pages), and approaches but never reaches 1 (long-running jobs plateau near 0.98 — completion is signaled by ``status``, and a job may complete from any progress value). Present while ``processing``.", "maximum": 1, "minimum": 0, "type": "number" diff --git a/src/landingai_ade/resources/v2/_normalize.py b/src/landingai_ade/resources/v2/_normalize.py index 4f9bd0e..0df5960 100644 --- a/src/landingai_ade/resources/v2/_normalize.py +++ b/src/landingai_ade/resources/v2/_normalize.py @@ -11,7 +11,9 @@ JobError, JobStatus, V2ExtractResult, + V2ParseMetadata, V2ParseResponse, + V2ExtractMetadata, V2BuildSchemaResponse, ) @@ -71,6 +73,11 @@ def normalize_parse_job(raw: Mapping[str, Any]) -> Job: created = raw.get("created_at") created = created if created is not None else raw.get("received_at") + # Present alongside `output_url` once an `output_save_url` job completes; the + # inline result carries its own metadata instead. + meta = raw.get("metadata") + metadata = V2ParseMetadata.construct(**cast(Dict[str, Any], meta)) if isinstance(meta, Mapping) else None + return Job( job_id=str(raw["job_id"]), status=status, @@ -78,6 +85,7 @@ def normalize_parse_job(raw: Mapping[str, Any]) -> Job: completed_at=_ts(raw.get("completed_at")), progress=_progress(raw.get("progress")), result=result, + metadata=metadata, error=error, raw=dict(raw), ) @@ -98,6 +106,11 @@ def normalize_extract_job(raw: Mapping[str, Any]) -> Job: elif raw.get("failure_reason"): # extract *list* uses failure_reason error = JobError(message=str(raw["failure_reason"])) + # Present alongside `output_url` once an `output_save_url` job completes; the + # inline result carries its own metadata instead. + meta = raw.get("metadata") + metadata = V2ExtractMetadata.construct(**cast(Dict[str, Any], meta)) if isinstance(meta, Mapping) else None + return Job( job_id=str(raw["job_id"]), status=status, @@ -105,6 +118,7 @@ def normalize_extract_job(raw: Mapping[str, Any]) -> Job: completed_at=_ts(raw.get("completed_at")), progress=_progress(raw.get("progress")), result=result, + metadata=metadata, error=error, raw=dict(raw), ) diff --git a/src/landingai_ade/types/v2/job.py b/src/landingai_ade/types/v2/job.py index 1f98631..4167abd 100644 --- a/src/landingai_ade/types/v2/job.py +++ b/src/landingai_ade/types/v2/job.py @@ -34,6 +34,11 @@ class Job(BaseModel): progress: Optional[float] = None # Populated on completion: V2ParseResponse for parse jobs, V2ExtractResult for extract jobs. result: Optional[object] = None + # The result's metadata block (billing included), surfaced when a job created with + # `output_save_url` completes: the delivery moves the content to `output_url` (see + # `raw`), but the receipt stays here. `V2ParseMetadata` for parse jobs, + # `V2ExtractMetadata` for extract jobs. Inline jobs carry it inside `result` instead. + metadata: Optional[object] = None error: Optional[JobError] = None # Full original envelope for fields not surfaced above (org_id, output_url, version, ...). raw: Dict[str, object] = Field(default_factory=dict) diff --git a/tests/api_resources/v2/test_extract.py b/tests/api_resources/v2/test_extract.py index c476cab..ab86aed 100644 --- a/tests/api_resources/v2/test_extract.py +++ b/tests/api_resources/v2/test_extract.py @@ -9,7 +9,7 @@ from pydantic import Field, BaseModel from landingai_ade import LandingAIADE -from landingai_ade.types.v2 import JobStatus, V2ExtractResult +from landingai_ade.types.v2 import JobStatus, V2ExtractResult, V2ExtractMetadata from landingai_ade.lib.v2_errors import V2SyncTimeoutError APIKEY = "My Apikey" @@ -192,6 +192,31 @@ def test_extract_job_get_failed_maps_error_object() -> None: assert job.status is JobStatus.FAILED and job.error is not None and job.error.code == "internal_error" +@respx.mock +def test_extract_job_get_output_save_url_surfaces_metadata() -> None: + # An `output_save_url` extract job delivers its result to `output_url` and + # returns the metadata receipt as a top-level `metadata` block instead of an + # inline `result`. + client = LandingAIADE(apikey=APIKEY) + respx.get("https://api.ade.landing.ai/v2/extract/jobs/e4").mock( + return_value=httpx.Response( + 200, + json={ + "job_id": "e4", + "status": "completed", + "output_url": "https://example.com/out.json", + "metadata": {"job_id": "e4", "model_version": "extract-1", "duration_ms": 12}, + }, + ) + ) + job = client.v2.extract_jobs.get("e4") + assert job.status is JobStatus.COMPLETED + assert job.result is None + assert isinstance(job.metadata, V2ExtractMetadata) + assert job.metadata.model_version == "extract-1" + assert job.raw["output_url"] == "https://example.com/out.json" + + @respx.mock def test_extract_job_wait_raise_on_failure() -> None: from landingai_ade.lib.v2_errors import JobFailedError diff --git a/tests/api_resources/v2/test_parse.py b/tests/api_resources/v2/test_parse.py index a05098b..a33e767 100644 --- a/tests/api_resources/v2/test_parse.py +++ b/tests/api_resources/v2/test_parse.py @@ -9,7 +9,7 @@ import pytest from landingai_ade import LandingAIADE -from landingai_ade.types.v2 import Job, JobStatus, V2ParseResponse +from landingai_ade.types.v2 import Job, JobStatus, V2ParseMetadata, V2ParseResponse from landingai_ade.lib.v2_errors import V2SyncTimeoutError APIKEY = "My Apikey" @@ -536,6 +536,31 @@ def test_parse_job_get_completed_has_typed_result() -> None: assert job.result.markdown == "# Hello" +@respx.mock +def test_parse_job_get_output_save_url_surfaces_metadata() -> None: + # When the job was created with `output_save_url`, the completed status + # delivers the result to `output_url` and returns the metadata receipt as a + # top-level `metadata` block instead of an inline `result`. + client = LandingAIADE(apikey=APIKEY) + respx.get("https://api.ade.landing.ai/v2/parse/jobs/p1").mock( + return_value=httpx.Response( + 200, + json={ + "job_id": "p1", + "status": "completed", + "output_url": "https://example.com/out.json", + "metadata": {"req_id": "r1", "job_id": "p1", "page_count": 3, "range_units": "unicode_codepoints"}, + }, + ) + ) + job = client.v2.parse_jobs.get("p1") + assert job.status is JobStatus.COMPLETED + assert job.result is None + assert isinstance(job.metadata, V2ParseMetadata) + assert job.metadata.page_count == 3 + assert job.raw["output_url"] == "https://example.com/out.json" + + @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). diff --git a/tests/contract/test_v2_smoke.py b/tests/contract/test_v2_smoke.py index 595e37b..3c4da19 100644 --- a/tests/contract/test_v2_smoke.py +++ b/tests/contract/test_v2_smoke.py @@ -54,6 +54,10 @@ def test_extract_jobs(staging_client: LandingAIADE) -> None: done = staging_client.v2.extract_jobs.wait(job.job_id, timeout=300) assert done.status is JobStatus.COMPLETED assert isinstance(done.result, V2ExtractResult) + # Without `output_save_url`, the metadata receipt rides inline on the result; + # the top-level `Job.metadata` field is only populated for delivered jobs. + assert done.metadata is None + assert done.result.metadata.model_version def test_parse_sync(staging_client: LandingAIADE) -> None: @@ -105,3 +109,7 @@ def test_parse_jobs(staging_client: LandingAIADE) -> None: assert isinstance(done.result, V2ParseResponse) assert isinstance(done.result.markdown, str) assert done.result.markdown + # Inline jobs carry the metadata receipt on the result, not the top-level + # `Job.metadata` field (populated only when `output_save_url` delivered the result). + assert done.metadata is None + assert done.result.metadata is not None diff --git a/tests/test_v2_normalize.py b/tests/test_v2_normalize.py index aed1ba6..81ad006 100644 --- a/tests/test_v2_normalize.py +++ b/tests/test_v2_normalize.py @@ -7,7 +7,9 @@ from landingai_ade.types.v2 import ( JobStatus, V2ExtractResult, + V2ParseMetadata, V2ParseResponse, + V2ExtractMetadata, V2BuildSchemaResponse, ) from landingai_ade.resources.v2._normalize import ( @@ -108,6 +110,45 @@ def test_normalize_extract_job_iso_and_result() -> None: assert job.result.metadata.version == "extract-1" +def test_normalize_parse_job_output_save_url_surfaces_metadata() -> None: + # When `output_save_url` was set, the completed job delivers its result to + # `output_url` (kept in `raw`) and returns the receipt as a top-level + # `metadata` block instead of inline `result`. + raw: Dict[str, Any] = { + "job_id": "parse-abc", + "status": "completed", + "output_url": "https://example.com/out.json", + "metadata": {"job_id": "parse-abc", "page_count": 2, "range_units": "unicode_codepoints"}, + } + job = normalize_parse_job(raw) + assert job.status is JobStatus.COMPLETED + assert job.result is None + assert isinstance(job.metadata, V2ParseMetadata) + assert job.metadata.page_count == 2 + assert job.raw["output_url"] == "https://example.com/out.json" + + +def test_normalize_parse_job_without_metadata_leaves_it_none() -> None: + raw = {"job_id": "parse-x", "status": "pending"} + job = normalize_parse_job(raw) + assert job.metadata is None + + +def test_normalize_extract_job_output_save_url_surfaces_metadata() -> None: + raw: Dict[str, Any] = { + "job_id": "e1", + "status": "completed", + "output_url": "https://example.com/out.json", + "metadata": {"job_id": "e1", "model_version": "extract-1", "duration_ms": 10}, + } + job = normalize_extract_job(raw) + assert job.status is JobStatus.COMPLETED + assert job.result is None + assert isinstance(job.metadata, V2ExtractMetadata) + assert job.metadata.model_version == "extract-1" + assert job.raw["output_url"] == "https://example.com/out.json" + + def test_normalize_extract_job_error_object() -> None: raw = {"job_id": "e2", "status": "failed", "error": {"code": "internal_error", "message": "boom"}} job = normalize_extract_job(raw)