From a17afd35634cfc8c5d58c21f74d1c351a7d71cfc Mon Sep 17 00:00:00 2001 From: bensynapse <118375461+bensynapse@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:32:06 +0200 Subject: [PATCH] Version 0.6.0: the spec at API 1.20.0, email_pdf added, cost fields removed The saved spec follows the public document at API 1.20.0. Number and NumberUpdate carry email_pdf. numbers.update sends it only when given. Extraction.provider_cost_micro and ExtractionPage.cost_micro are removed. The server stopped sending both at API 1.19.0. 0.5.0 required provider_cost_micro, so an extraction from the current API failed to parse. Tests cover the email_pdf body, an older number and an extraction without the cost. --- CHANGELOG.md | 19 +++++++ README.md | 3 +- pyproject.toml | 2 +- spec/openapi.yaml | 29 +++++----- src/faxforagents/__init__.py | 2 +- src/faxforagents/async_client.py | 4 +- src/faxforagents/client.py | 4 +- src/faxforagents/models.py | 7 ++- tests/fixtures/README.md | 3 ++ tests/fixtures/responses.json | 4 +- tests/test_client.py | 93 ++++++++++++++++++++++++++++++++ 11 files changed, 147 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 854c6fb..9ba4a51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## 0.6.0 (2026-11-13) + +Follows API 1.20.0. The saved spec has 145 operations and the models are regenerated from it. +No client method was added or removed. + +New: `Number.email_pdf`, a boolean. When true, the notice mail of a received fax carries the PDF. +A number read from an older server has `email_pdf` equal to `False`. +`numbers.update(number_id, email_pdf=True)` switches it on, with the scope `number:buy`. An agent key is refused. +`email_pdf` is sent only when given. `False` is sent and switches the attachment off. + +Breaking: `Extraction.provider_cost_micro` is removed. The server stopped sending it at API 1.19.0. +Breaking: `ExtractionPage.cost_micro` is removed, for the same reason. +Code that read either field has to stop. `Extraction.price_micro` is the price the wallet paid, as before. +Fixed: 0.5.0 required `provider_cost_micro`, so every extraction call failed to parse on API 1.19.0 and later. +This covers extractions, classifications and summaries. 0.6.0 parses them. + +The ledger no longer lists `provider_cost` entries. `billing.list_ledger` and its models did not change. +Other model names, fields and optionality are kept. + ## 0.5.0 (2026-11-12) Follows API 1.18.0. The saved spec has 145 operations and the models are regenerated from it. diff --git a/README.md b/README.md index b63b361..9b2b05f 100644 --- a/README.md +++ b/README.md @@ -299,8 +299,9 @@ ruff check src tests && mypy src sh scripts/truthcheck.sh ``` -`spec/openapi.yaml` follows the public document at API version 1.18.0 and keeps the schemas of the earlier live documents. +`spec/openapi.yaml` follows the public document at API version 1.20.0 and keeps the schemas of the earlier live documents. The two validation error schemas are the exception. 0.5.0 removed them because the server never sent that shape. +0.6.0 removed two cost fields of an extraction, because the server stopped sending them. It also retains earlier public model names and route-documented response additions. To refresh it, merge the live schemas while preserving existing declarations, then run `uv run python scripts/gen_models.py`. Keep new response fields optional so older payloads and existing callers remain valid. diff --git a/pyproject.toml b/pyproject.toml index 76f72ec..e4bb447 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "faxforagents" -version = "0.5.0" +version = "0.6.0" description = "Python client for Fax for Agents: a fax API, a hosted MCP server and SDKs built so AI agents can send and receive faxes." readme = "README.md" requires-python = ">=3.10" diff --git a/spec/openapi.yaml b/spec/openapi.yaml index d61f8b9..a9b465a 100644 --- a/spec/openapi.yaml +++ b/spec/openapi.yaml @@ -1,4 +1,4 @@ -# Fax for Agents API, refreshed from the public document of https://github.com/faxforagents/openapi at API 1.18.0. +# Fax for Agents API, refreshed from the public document of https://github.com/faxforagents/openapi at API 1.20.0. # All earlier schemas are kept. Existing SDK routes, model names and serializer additions are retained. openapi: 3.1.0 info: @@ -26,7 +26,7 @@ info: x-logo: url: https://faxforagents.com/brand/logo-512.png altText: Fax for Agents - version: 1.18.0 + version: 1.20.0 servers: - url: https://api.faxforagents.com description: Production (live and test keys) @@ -5379,7 +5379,7 @@ paths: patch: tags: - numbers - summary: Label and inbound route of a number + summary: Label, inbound route and PDF by mail of a number operationId: update_number parameters: - name: number_id @@ -9350,9 +9350,6 @@ components: price_micro: type: integer title: Price Micro - provider_cost_micro: - type: integer - title: Provider Cost Micro currency: type: string title: Currency @@ -9405,7 +9402,6 @@ components: - output - models - price_micro - - provider_cost_micro - currency - error_code - created_by @@ -9504,11 +9500,6 @@ components: description: Returned by GET /v1/extractions/{extraction_id}. Declared here until the public schema includes it. x-sdk-route: GET /v1/extractions/{extraction_id} - cost_micro: - type: integer - description: Returned by GET /v1/extractions/{extraction_id}. Declared here until the public schema includes - it. - x-sdk-route: GET /v1/extractions/{extraction_id} words_count: type: integer description: Returned by GET /v1/extractions/{extraction_id}. Declared here until the public schema includes @@ -10661,6 +10652,12 @@ components: inbound_status: type: string title: Inbound Status + email_pdf: + type: boolean + title: Email Pdf + description: When true, the notice mail of a fax received on this number carries the PDF as an attachment. + Live numbers only. Off by default. + default: false provider: type: string title: Provider @@ -10875,6 +10872,14 @@ components: - type: string - type: 'null' title: Inbound Webhook Id + email_pdf: + anyOf: + - type: boolean + - type: 'null' + title: Email Pdf + description: Attach the PDF of each received fax to the notice mail. The mail goes to the verified address + of an owner of the organization and to no other address. A PDF over 15 MB is not attached. It needs + the scope number:buy. An agent, an OAuth connection and a connected app are refused. additionalProperties: false type: object title: NumberUpdate diff --git a/src/faxforagents/__init__.py b/src/faxforagents/__init__.py index 9e0c911..029aff9 100644 --- a/src/faxforagents/__init__.py +++ b/src/faxforagents/__init__.py @@ -18,7 +18,7 @@ from __future__ import annotations -__version__ = "0.5.0" +__version__ = "0.6.0" from faxforagents._http import ORDER_TOKEN_HEADER from faxforagents.async_client import AsyncClient diff --git a/src/faxforagents/async_client.py b/src/faxforagents/async_client.py index 80fba24..eabf663 100644 --- a/src/faxforagents/async_client.py +++ b/src/faxforagents/async_client.py @@ -932,7 +932,9 @@ async def get(self, number_id: str) -> m.Number: return await self._h.request("GET", f"/v1/numbers/{number_id}", model=m.Number) async def update(self, number_id: str, *, idempotency_key: str | None = None, **changes: Any) -> m.Number: - """Change the label, project or inbound webhook of a number. Scope: number:buy.""" + """Change the label, project, inbound webhook or ``email_pdf`` of a number. Scope: number:buy. + + ``email_pdf=True`` attaches the PDF of each received fax to the notice mail. No agent key.""" body = m.NumberUpdate(**changes) return await self._h.request( "PATCH", f"/v1/numbers/{number_id}", model=m.Number, json=body, idempotency_key=idempotency_key diff --git a/src/faxforagents/client.py b/src/faxforagents/client.py index 381b40c..be7918c 100644 --- a/src/faxforagents/client.py +++ b/src/faxforagents/client.py @@ -957,7 +957,9 @@ def get(self, number_id: str) -> m.Number: return self._h.request("GET", f"/v1/numbers/{number_id}", model=m.Number) def update(self, number_id: str, *, idempotency_key: str | None = None, **changes: Any) -> m.Number: - """Change the label, project or inbound webhook of a number. Scope: number:buy.""" + """Change the label, project, inbound webhook or ``email_pdf`` of a number. Scope: number:buy. + + ``email_pdf=True`` attaches the PDF of each received fax to the notice mail. No agent key.""" body = m.NumberUpdate(**changes) return self._h.request( "PATCH", f"/v1/numbers/{number_id}", model=m.Number, json=body, idempotency_key=idempotency_key diff --git a/src/faxforagents/models.py b/src/faxforagents/models.py index 3ce7325..9d8ec0a 100644 --- a/src/faxforagents/models.py +++ b/src/faxforagents/models.py @@ -1,6 +1,6 @@ """Typed models of the Fax for Agents API. -GENERATED by scripts/gen_models.py from spec/openapi.yaml (API version 1.18.0). Do not edit by hand. +GENERATED by scripts/gen_models.py from spec/openapi.yaml (API version 1.20.0). Do not edit by hand. Money fields ending in ``_micro`` are integer USD micro-units (39000 is $0.039). Timestamps are ISO 8601 strings in UTC. Response models accept fields the API adds later. Request models refuse unknown fields. """ @@ -766,8 +766,6 @@ class ExtractionPage(_Response): # Returned by GET /v1/extractions/{extraction_id}. strong_read: bool | None = Field(None, description="Returned by GET /v1/extractions/{extraction_id}. Declared here until the public schema includes it.") # Returned by GET /v1/extractions/{extraction_id}. - cost_micro: int | None = Field(None, description="Returned by GET /v1/extractions/{extraction_id}. Declared here until the public schema includes it.") - # Returned by GET /v1/extractions/{extraction_id}. words_count: int | None = Field(None, description="Returned by GET /v1/extractions/{extraction_id}. Declared here until the public schema includes it.") @@ -790,7 +788,6 @@ class Extraction(_Response): output: dict[str, Any] | None models: dict[str, Any] price_micro: int - provider_cost_micro: int currency: str error_code: str | None created_by: str @@ -1126,6 +1123,7 @@ class Number(_Response): project_id: str | None inbound_webhook_id: str | None inbound_status: str + email_pdf: bool = Field(False, description="When true, the notice mail of a fax received on this number carries the PDF as an attachment. Live numbers only. Off by default.") provider: str monthly_micro: int currency: str @@ -1174,6 +1172,7 @@ class NumberUpdate(_Request): label: str | None = None project_id: str | None = None inbound_webhook_id: str | None = None + email_pdf: bool | None = Field(None, description="Attach the PDF of each received fax to the notice mail. The mail goes to the verified address of an owner of the organization and to no other address. A PDF over 15 MB is not attached. It needs the scope number:buy. An agent, an OAuth connection and a connected app are refused.") class OAuthConsentClient(_Response): diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 607c22a..1dff615 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -15,6 +15,9 @@ Policy evaluations also return usage when a policy applies. Fax jobs, quotes, extraction pages and inbound summaries needed the route-documented additions. Capabilities gains `one_time` from the updated snapshot. +At API 1.20.0, server revision `3c06d076f0f5cfe477c42b71857cbdda9767884e`, the number gains `email_pdf` from `numbers/service.py`. +The extraction page loses `cost_micro`, which `ocr/service.py` no longer sends. + ## One-time sends (`one-time.json`) Server source revision: `5bee3b046dbcb5bc0fbf98ae59c1a40a1c8c1434`, API 1.16.1. diff --git a/tests/fixtures/responses.json b/tests/fixtures/responses.json index aff442b..702a170 100644 --- a/tests/fixtures/responses.json +++ b/tests/fixtures/responses.json @@ -179,6 +179,7 @@ "project_id": null, "inbound_webhook_id": null, "inbound_status": "enabled", + "email_pdf": false, "provider": "sandbox", "monthly_micro": 0, "currency": "USD", @@ -309,8 +310,7 @@ "escalated": false, "fast_read": true, "strong_read": false, - "warnings": [], - "cost_micro": 0 + "warnings": [] } }, { diff --git a/tests/test_client.py b/tests/test_client.py index 9681005..64e12be 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -425,3 +425,96 @@ def test_list_api_keys_reads_connected_app(client: Client, api: respx.MockRouter plain = {k: v for k, v in api_key_with_secret(id="key_plain").items() if k != "secret"} api.get("/v1/api-keys").respond(200, json={"items": [flagged, plain]}) assert [k.connected_app for k in client.identity.list_api_keys().items] == [True, False] + + +def number(**over: object) -> dict[str, object]: + base: dict[str, object] = { + "id": "num_1", + "environment": "live", + "e164": "+15550001001", + "country": "US", + "number_type": "local", + "locality": None, + "status": "active", + "label": None, + "project_id": None, + "inbound_webhook_id": None, + "inbound_status": "enabled", + "email_pdf": False, + "provider": "carrier", + "monthly_micro": 2000000, + "currency": "USD", + "order_id": "nord_1", + "created_at": "2026-11-13T00:00:00Z", + "activated_at": "2026-11-13T00:00:00Z", + "renews_at": None, + "released_at": None, + } + base.update(over) + return base + + +def test_update_number_sends_email_pdf_only_when_given(client: Client, api: respx.MockRouter) -> None: + route = api.patch("/v1/numbers/num_1") + route.side_effect = [ + httpx.Response(200, json=number(email_pdf=True)), + httpx.Response(200, json=number(email_pdf=False)), + httpx.Response(200, json=number(label="front desk")), + ] + assert client.numbers.update("num_1", email_pdf=True).email_pdf is True + assert json.loads(route.calls.last.request.content) == {"email_pdf": True} + # False is a value, not an omission: it switches the attachment off. + assert client.numbers.update("num_1", email_pdf=False).email_pdf is False + assert json.loads(route.calls.last.request.content) == {"email_pdf": False} + client.numbers.update("num_1", label="front desk") + assert json.loads(route.calls.last.request.content) == {"label": "front desk"} + + +def test_number_from_an_older_server_reads_email_pdf_as_false(client: Client, api: respx.MockRouter) -> None: + older = {k: v for k, v in number().items() if k != "email_pdf"} + api.get("/v1/numbers/num_1").respond(200, json=older) + assert client.numbers.get("num_1").email_pdf is False + + +def test_extraction_parses_without_the_provider_cost(client: Client, api: respx.MockRouter) -> None: + # API 1.19.0 stopped sending provider_cost_micro and the cost_micro of each page. + body = { + "id": "ext_1", + "kind": "extract", + "status": "completed", + "environment": "test", + "document_id": "doc_1", + "inbox_item_id": None, + "schema_id": None, + "schema": None, + "options": {}, + "pages_requested": 1, + "pages_processed": 1, + "pages_escalated": 0, + "document_pages": 1, + "confidence": 0.95, + "pages": [ + { + "page": 1, + "text": "Fixture text", + "confidence": 0.95, + "status": "ok", + "model": "sandbox", + "warnings": [], + } + ], + "output": None, + "models": {}, + "price_micro": 0, + "currency": "USD", + "error_code": None, + "created_by": "key:ffa_test_aaaa", + "created_at": "2026-11-13T00:00:00Z", + "started_at": "2026-11-13T00:00:00Z", + "completed_at": "2026-11-13T00:00:01Z", + } + api.get("/v1/extractions/ext_1").respond(200, json=body) + extraction = client.extractions.get("ext_1") + assert extraction.price_micro == 0 + assert "provider_cost_micro" not in type(extraction).model_fields + assert "cost_micro" not in type(extraction.pages[0]).model_fields