Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design/stainless-exit-and-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Auth is unchanged from V1: `Authorization: Bearer <apikey>`. 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. |
Expand Down
11 changes: 7 additions & 4 deletions docs/v2-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Comment on lines +113 to +116

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — marked that table row superseded and pointed to docs/v2-testing.md for the corrected shape (commit 98e68ef).


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
Expand Down
9 changes: 0 additions & 9 deletions src/landingai_ade/types/v2/extract_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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).
Expand Down
66 changes: 66 additions & 0 deletions tests/api_resources/v2/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
50 changes: 43 additions & 7 deletions tests/test_v2_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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={},
Expand All @@ -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",
)
Expand All @@ -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"


Expand Down Expand Up @@ -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={
Expand Down