Skip to content
Merged
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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
29 changes: 17 additions & 12 deletions spec/openapi.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -9405,7 +9402,6 @@ components:
- output
- models
- price_micro
- provider_cost_micro
- currency
- error_code
- created_by
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/faxforagents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/faxforagents/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/faxforagents/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions src/faxforagents/models.py
Original file line number Diff line number Diff line change
@@ -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.
"""
Expand Down Expand Up @@ -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.")


Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
3 changes: 3 additions & 0 deletions tests/fixtures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions tests/fixtures/responses.json
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@
"project_id": null,
"inbound_webhook_id": null,
"inbound_status": "enabled",
"email_pdf": false,
"provider": "sandbox",
"monthly_micro": 0,
"currency": "USD",
Expand Down Expand Up @@ -309,8 +310,7 @@
"escalated": false,
"fast_read": true,
"strong_read": false,
"warnings": [],
"cost_micro": 0
"warnings": []
}
},
{
Expand Down
93 changes: 93 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading