diff --git a/docs/design/stainless-exit-and-v2.md b/docs/design/stainless-exit-and-v2.md index 29ce6bc..77a965f 100644 --- a/docs/design/stainless-exit-and-v2.md +++ b/docs/design/stainless-exit-and-v2.md @@ -71,7 +71,7 @@ Auth is unchanged from V1: `Authorization: Bearer `. Hosts differ from V | Route | Notes | |---|---| -| `POST /v2/extract` | Sync. JSON body: `schema` (JSON Schema, required); markdown from exactly one of `markdown` (inline) \| `markdown_ref` (from `POST /v1/files`) \| `markdown_url`; `model`; `options: {strict}`. Returns `{extraction, extraction_metadata (per-field {value, spans}), markdown, metadata {job_id, version, duration_ms, doc_id?, credit_usage}}`. **206** = partial success under strict mode. | +| `POST /v2/extract` | Sync. JSON body: `schema` (JSON Schema, required); markdown from exactly one of `markdown` (inline) \| `markdown_ref` (from `POST /v1/files`) \| `markdown_url`; `model`; `options: {strict}`. Returns `{extraction, extraction_metadata (per-field {value, spans}), markdown, metadata {job_id, model_version, duration_ms, doc_id?, billing}}`. Superseded: this table originally listed `version`/`credit_usage` here; the server sends `model_version` (`version` is only a legacy SDK compatibility field) and never sent `credit_usage`, whose stale copy on the SDK was removed in [aide#2013](https://github.com/landing-ai/aide/issues/2013) — see `docs/v2-testing.md` for the current shape. **206** = partial success under strict mode. | | `POST /v2/extract/jobs` | Async, 202. Plus `priority`, `idempotency_key`. | | `GET /v2/extract/jobs/{job_id}` | Poll. Envelope: `{job_id, status, created_at, completed_at?, result?, error? {code, message}, progress?}`. | | `GET /v2/extract/jobs` | List, paginated. | diff --git a/docs/v2-testing.md b/docs/v2-testing.md index e34965e..581591d 100644 --- a/docs/v2-testing.md +++ b/docs/v2-testing.md @@ -107,10 +107,13 @@ the field deployed ahead of the snapshot. fields the model could not extract — the extraction is partial), `warnings` (non-fatal warnings), and `metadata` (`V2ExtractMetadata`): `job_id`, `model_version`, `duration_ms`, `doc_id`, `input_markdown_chars`, -`output_extraction_chars`, `credit_usage` (deprecated), `range_units`, -`openapi_spec`, and `billing` (`V2ExtractBilling`). The `input_markdown_chars` / -`output_extraction_chars` char counts moved from `billing` onto `metadata` -upstream; both are retained on `V2ExtractBilling` for backward compatibility. +`output_extraction_chars`, `range_units`, `openapi_spec`, and `billing` +(`V2ExtractBilling`: `service_tier`, `total_credits`). The char counts live +only on `metadata`, and credits live only on `metadata.billing.total_credits` +— the server never sends `metadata.credit_usage` or `billing.*_chars`, so +neither field exists on the SDK types anymore (removed in the fix for +[aide#2013](https://github.com/landing-ai/aide/issues/2013); they used to +show a fake `credit_usage: 0.0` and always-null `billing.*_chars`). 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/types/v2/extract_response.py b/src/landingai_ade/types/v2/extract_response.py index 3743917..7fb1d35 100644 --- a/src/landingai_ade/types/v2/extract_response.py +++ b/src/landingai_ade/types/v2/extract_response.py @@ -12,12 +12,6 @@ class V2ExtractBilling(BaseModel): service_tier: Optional[str] = None total_credits: Optional[float] = None - # Characters (code points) in the input markdown as submitted -- the input - # basis of the credit charge. - input_markdown_chars: Optional[int] = None - # Characters in the serialized extraction output -- the output basis of the - # credit charge. - output_extraction_chars: Optional[int] = None class V2ExtractMetadata(BaseModel): @@ -29,9 +23,6 @@ class V2ExtractMetadata(BaseModel): model_version: Optional[str] = None duration_ms: int doc_id: Optional[str] = None - # Deprecated: superseded by `billing`; retained for backward compatibility - # and populated only by older gateway responses. - credit_usage: float = 0.0 billing: Optional[V2ExtractBilling] = None # Characters (code points) in the input markdown as submitted -- the input # basis of the credit charge (moved here from `billing` upstream). diff --git a/tests/api_resources/v2/test_extract.py b/tests/api_resources/v2/test_extract.py index c476cab..1b3af07 100644 --- a/tests/api_resources/v2/test_extract.py +++ b/tests/api_resources/v2/test_extract.py @@ -166,6 +166,72 @@ def test_extract_sync_parses_char_counts_and_warnings() -> None: assert result.warnings is not None and result.warnings[0]["code"] == "partial" +def _real_wire_extract_body() -> Dict[str, Any]: + # aide's real wire shape (services/gateway/job_surface/start.py + # `_public_result` + `V2Billing`): the server never sends + # `metadata.credit_usage` or `billing.input_markdown_chars` / + # `billing.output_extraction_chars`. Regression fixture for aide#2013. + return { + "extraction": {"revenue": "1M"}, + "extraction_metadata": {"revenue": {"value": "1M", "spans": []}}, + "markdown": "# doc", + "metadata": { + "job_id": "e1", + "model_version": "dpt-3", + "duration_ms": 5, + "input_markdown_chars": 42, + "output_extraction_chars": 7, + "billing": {"service_tier": "priority", "total_credits": 12.5}, + }, + } + + +def _assert_no_stale_billing_fields(dumped: Dict[str, Any]) -> None: + assert "credit_usage" not in dumped["metadata"] + billing_dump = dumped["metadata"]["billing"] + assert billing_dump is not None + assert "input_markdown_chars" not in billing_dump + assert "output_extraction_chars" not in billing_dump + assert dumped["metadata"]["input_markdown_chars"] == 42 + assert dumped["metadata"]["output_extraction_chars"] == 7 + assert billing_dump["service_tier"] == "priority" + assert billing_dump["total_credits"] == 12.5 + + +@respx.mock +def test_extract_sync_wire_shape_has_no_stale_billing_fields() -> None: + # aide#2013: the sync `client.v2.extract(...)` response must not surface + # a stale `metadata.credit_usage` or `billing.*_chars`. + client = LandingAIADE(apikey=APIKEY) + respx.post("https://api.ade.landing.ai/v2/extract").mock( + return_value=httpx.Response(200, json=_real_wire_extract_body()) + ) + result = client.v2.extract(schema={"type": "object"}, markdown="x") + _assert_no_stale_billing_fields(result.model_dump()) + + +@respx.mock +def test_extract_job_get_wire_shape_has_no_stale_billing_fields() -> None: + # aide#2013: the same check on the `extract_jobs.get(...)` result path, + # which normalizes via `V2ExtractResult.construct(...)`. + client = LandingAIADE(apikey=APIKEY) + respx.get("https://api.ade.landing.ai/v2/extract/jobs/e1").mock( + return_value=httpx.Response( + 200, + json={ + "job_id": "e1", + "status": "completed", + "created_at": "2026-01-01T00:00:00Z", + "completed_at": "2026-01-01T00:00:09Z", + "result": _real_wire_extract_body(), + }, + ) + ) + done = client.v2.extract_jobs.get("e1") + assert isinstance(done.result, V2ExtractResult) + _assert_no_stale_billing_fields(done.result.model_dump()) + + def test_extract_job_create_sends_output_save_url(monkeypatch: pytest.MonkeyPatch) -> None: # The async job create body carries `output_save_url` (async jobs only). client = LandingAIADE(apikey=APIKEY) diff --git a/tests/test_v2_types.py b/tests/test_v2_types.py index d29b081..9cf6ca0 100644 --- a/tests/test_v2_types.py +++ b/tests/test_v2_types.py @@ -88,7 +88,7 @@ def test_extract_result_parses_nested_metadata() -> None: metadata={"job_id": "j1", "version": "extract-1", "duration_ms": 12}, # type: ignore[arg-type] ) assert r.metadata.job_id == "j1" - assert r.metadata.credit_usage == 0.0 # default + assert not hasattr(r.metadata, "credit_usage") def test_parse_response_builds_from_dicts() -> None: @@ -127,9 +127,11 @@ def test_parse_response_retains_unknown_fields() -> None: def test_extract_result_new_metadata_and_billing_fields() -> None: - # model_version / range_units / openapi_spec on metadata, the two new billing - # counters, and the top-level output_ref all deserialize -- without the - # legacy `version` field, which current gateway responses no longer send. + # model_version / range_units / openapi_spec on metadata, billing + # (service_tier + total_credits only -- no char counts, those live on + # `metadata` itself), and the top-level output_ref all deserialize -- + # without the legacy `version` field, which current gateway responses no + # longer send. r = V2ExtractResult( extraction={}, extraction_metadata={}, @@ -140,7 +142,7 @@ def test_extract_result_new_metadata_and_billing_fields() -> None: "duration_ms": 5, "range_units": "unicode_codepoints", "openapi_spec": "https://api.example/openapi.json", - "billing": {"input_markdown_chars": 100, "output_extraction_chars": 20}, + "billing": {"service_tier": "standard", "total_credits": 1.5}, }, output_ref="ref-123", ) @@ -149,8 +151,8 @@ def test_extract_result_new_metadata_and_billing_fields() -> None: assert r.metadata.range_units == "unicode_codepoints" assert r.metadata.openapi_spec is not None and r.metadata.openapi_spec.endswith("openapi.json") assert r.metadata.billing is not None - assert r.metadata.billing.input_markdown_chars == 100 - assert r.metadata.billing.output_extraction_chars == 20 + assert r.metadata.billing.service_tier == "standard" + assert r.metadata.billing.total_credits == 1.5 assert r.output_ref == "ref-123" @@ -256,6 +258,40 @@ def test_extract_result_metadata_char_counts_warnings_and_schema_violation() -> assert r.warnings is not None and r.warnings[0]["code"] == "partial" +def test_extract_result_wire_shape_has_no_stale_billing_fields() -> None: + # Regression for aide#2013: the server never sends `metadata.credit_usage` + # or `billing.input_markdown_chars` / `billing.output_extraction_chars`. + # A stale default on the SDK type used to materialize them anyway on + # `model_dump()`. This payload matches aide's real wire shape. + r = V2ExtractResult( + extraction={"revenue": "1M"}, + extraction_metadata={"revenue": {"value": "1M", "spans": []}}, + markdown="# doc", + metadata={ # type: ignore[arg-type] + "job_id": "e1", + "model_version": "dpt-3", + "duration_ms": 5, + "doc_id": None, + "input_markdown_chars": 100, + "output_extraction_chars": 20, + "range_units": "unicode_codepoints", + "openapi_spec": "https://api.example/openapi.json", + "billing": {"service_tier": "standard", "total_credits": 1.5}, + }, + ) + dumped = r.model_dump() + + assert "credit_usage" not in dumped["metadata"] + billing_dump = dumped["metadata"]["billing"] + assert billing_dump is not None + assert "input_markdown_chars" not in billing_dump + assert "output_extraction_chars" not in billing_dump + assert dumped["metadata"]["input_markdown_chars"] == 100 + assert dumped["metadata"]["output_extraction_chars"] == 20 + assert billing_dump["service_tier"] == "standard" + assert billing_dump["total_credits"] == 1.5 + + def test_ground_result_builds_from_dicts() -> None: r = V2GroundResult( grounding={