From 1430fca3f3996bdc07ec348f26cfd18017d2e473 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Fri, 9 Oct 2026 23:29:00 +0000 Subject: [PATCH 1/4] feat(models): expose the queued submit's cost quote as RequestHandle.estimate Router sets an `estimate` object (RouterCostEstimate) on the queued submit's 201 body; submit() kept only request_id and dropped it. Read it into a frozen CostEstimate value and carry it on RequestHandle / AsyncRequestHandle as a read-only `estimate` property. None means no quote is available (estimate off, idempotent replay, rehydrated handle), never no charge. A malformed quote reads as None and never fails the submit. --- CHANGELOG.md | 8 ++ README.md | 19 +++ src/comfy_sdk/__init__.py | 9 +- src/comfy_sdk/model_requests.py | 185 ++++++++++++++++++++++++++++- src/comfy_sdk/models.py | 13 +- tests/conftest.py | 6 + tests/test_models_queue.py | 152 +++++++++++++++++++++++- tests/test_router_spec_contract.py | 24 +++- 8 files changed, 409 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea73f68..5944a40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ the fuller account of each version, including verification notes. ### Added +- The queued submit's cost quote. `RequestHandle.estimate` / `AsyncRequestHandle.estimate` + carry Router's pre-flight quote as a `CostEstimate` (`source`, `currency`, `amount` / + `min_amount` / `max_amount` as decimal strings, their `*_cents`, `credits`, `provider`, + `model`, `pricing_as_of`, `reason`, `raw`, and `is_exact` / `is_estimated` / `is_unknown`) + when the submit's `201` included one. `None` means no quote is available, never no charge: + the estimate is off for the caller, the submit was answered by an idempotent replay, or the + handle was rebuilt with `client.models.handle`. A malformed quote reads as `None` and never + fails the submit. - Job metadata. `submit(..., metadata={"client": "acme"})` on `Comfy` and `AsyncComfy` stores string labels on a job, sent as the `metadata` field of `POST /api/v2/jobs` (omitted when not given, so the request is unchanged). `Job.metadata` / `AsyncJob.metadata` read them back diff --git a/README.md b/README.md index 9c57e90..d6b1716 100644 --- a/README.md +++ b/README.md @@ -766,6 +766,25 @@ failed *and* a cancelled request as `COMPLETED` carrying an `error_type`, so back as a result. `iter_events` deliberately does not raise — it is a view of the queue's progress, and `get()` is the one that collects. +When Router quotes the request's cost up front, the handle carries it as +`handle.estimate`, a `CostEstimate`: + +```python +if handle.estimate is not None: + if handle.estimate.is_exact: + print(handle.estimate.amount) # dollars as a decimal string, e.g. "0.04" + elif handle.estimate.is_estimated: + print(handle.estimate.min_amount, handle.estimate.max_amount) +``` + +`None` means **no quote is available, never "no charge"**: the estimate is not +enabled for every caller, it is omitted when the submit was answered by an +idempotent replay (the original quote is not stored), and a rehydrated handle +never has one. When present, `source` (`exact` / `estimated` / `unknown`, and +any other value read as `unknown`) says how much to trust the figure; an +`unknown` quote carries a `reason` instead. It is not a price lock — the run is +charged at the rates in force when it is rated. + Rehydrate a handle in another process from the two ids that address the request, with no call made: diff --git a/src/comfy_sdk/__init__.py b/src/comfy_sdk/__init__.py index d6c72cb..3ae79d2 100644 --- a/src/comfy_sdk/__init__.py +++ b/src/comfy_sdk/__init__.py @@ -69,7 +69,13 @@ WorkflowFormatUi, ) from .jobs import AsyncJob, Job, JobSummary, JobWorkflow -from .model_requests import COMPLETED, AsyncRequestHandle, QueueUpdate, RequestHandle +from .model_requests import ( + COMPLETED, + AsyncRequestHandle, + CostEstimate, + QueueUpdate, + RequestHandle, +) from .models import BinaryResult from .outputs import AsyncOutput, DownloadUrl, Output from .retry import DEFAULT_RETRY, NO_RETRY, RetryPolicy @@ -118,6 +124,7 @@ "RequestHandle", "AsyncRequestHandle", "QueueUpdate", + "CostEstimate", "COMPLETED", "Output", "AsyncOutput", diff --git a/src/comfy_sdk/model_requests.py b/src/comfy_sdk/model_requests.py index 5b91b73..7352b0b 100644 --- a/src/comfy_sdk/model_requests.py +++ b/src/comfy_sdk/model_requests.py @@ -139,6 +139,100 @@ def __repr__(self) -> str: ) +#: The two ``source`` values that carry a figure. Anything else -- ``unknown`` +#: or a value added server side after this SDK version -- is read as no figure, +#: which is the spec's own rule for the field. +_QUOTED_SOURCES = ("exact", "estimated") + + +@dataclass(frozen=True) +class CostEstimate: + """Router's pre-flight cost quote for one queued request. + + Read from the ``estimate`` object on the queued submit's ``201`` body and + exposed as :attr:`RequestHandle.estimate`. It is priced before dispatch + from the same rate card the charge is billed against, in US dollars. + + ``source`` is an **open string** and says how much to trust the figure: + ``exact`` carries :attr:`amount`; ``estimated`` carries :attr:`min_amount` + and :attr:`max_amount` and no :attr:`amount`; ``unknown`` carries neither, + and a :attr:`reason`. Any other value is read as ``unknown`` + (:attr:`is_unknown`), as the spec directs. An absent :attr:`amount` is + never "free". + + Money is the server's decimal **string**, kept verbatim and never coerced + to a float, so ``"0.04"`` stays exactly ``"0.04"``; the ``*_cents`` and + :attr:`credits` fields are the server's numbers for arithmetic. + + It is **not a price lock**: the run is charged at the rates in force when + it is rated, not at :attr:`pricing_as_of`. + """ + + #: ``exact`` | ``estimated`` | ``unknown``, verbatim — and possibly a value + #: this SDK version has never heard of. Branch on :attr:`is_exact`, + #: :attr:`is_estimated` and :attr:`is_unknown` rather than comparing. + source: str + #: Always ``USD`` today. + currency: str + #: The provider leg the quote is for — on a queued submit, the leg that runs. + provider: str + #: The canonical ``{provider}/{model}`` id the quote is for. + model: str + #: When the rate card the quote was priced from was fetched (or, when no + #: card was read, when the quote was answered), as the server's string. + pricing_as_of: str + #: Dollars as a decimal string. Present only when ``source`` is ``exact``. + amount: str | None = None + #: The lower bound in dollars as a decimal string; ``estimated`` only. + min_amount: str | None = None + #: The upper bound in dollars as a decimal string; ``estimated`` only. + max_amount: str | None = None + #: :attr:`amount` in US cents, unrounded. + amount_cents: float | None = None + #: :attr:`min_amount` in US cents, unrounded. + min_amount_cents: float | None = None + #: :attr:`max_amount` in US cents. + max_amount_cents: float | None = None + #: :attr:`amount` in Comfy credits, rounded to two decimals. + credits: float | None = None + #: Why no figure could be given — present only when ``source`` is + #: ``unknown``: ``not_quotable``, ``unpriced_model``, ``byok`` or + #: ``pricing_unavailable``, and an open string like :attr:`source`. + reason: str | None = None + #: The decoded ``estimate`` object, unmodified — the escape hatch for a + #: field this dataclass does not model yet. + raw: Mapping[str, Any] = field(default_factory=dict) + + @property + def is_exact(self) -> bool: + """Whether the quote is a single figure, in :attr:`amount`.""" + return self.source == "exact" + + @property + def is_estimated(self) -> bool: + """Whether the quote is a range, in :attr:`min_amount` / :attr:`max_amount`.""" + return self.source == "estimated" + + @property + def is_unknown(self) -> bool: + """Whether the quote carries no figure — ``unknown``, or any unrecognised + ``source``.""" + return self.source not in _QUOTED_SOURCES + + def __repr__(self) -> str: + amounts = "".join( + f", {name}={value!r}" + for name, value in ( + ("amount", self.amount), + ("min_amount", self.min_amount), + ("max_amount", self.max_amount), + ("reason", self.reason), + ) + if value is not None + ) + return f"CostEstimate(source={self.source!r}{amounts})" + + def _retry_after_seconds(headers: httpx.Headers) -> int | None: """``Retry-After`` as a positive whole number of seconds, or ``None``. @@ -249,6 +343,65 @@ def _request_id_of(payload: Any) -> str: ) from exc +def _estimate_of(payload: Any) -> CostEstimate | None: + """The cost quote a submit response carries, or ``None``. + + Never raises: by the time this runs the submit has succeeded and the + request is queued, so a quote that is absent or malformed is "no quote", + not a failure of the call. An optional field of the wrong type is left + ``None`` rather than coerced, and a missing or mistyped required field + drops the whole quote — a half-read quote is worse than none. + """ + if not isinstance(payload, Mapping): + return None + estimate = payload.get("estimate") + if not isinstance(estimate, Mapping): + return None + + def text(name: str) -> str | None: + value = estimate.get(name) + return value if isinstance(value, str) else None + + def number(name: str) -> float | None: + value = estimate.get(name) + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + try: + return float(value) + except OverflowError: # an integer too large for a float is no figure + return None + + source = text("source") + currency = text("currency") + provider = text("provider") + model = text("model") + pricing_as_of = text("pricing_as_of") + if ( + source is None + or currency is None + or provider is None + or model is None + or pricing_as_of is None + ): + return None + return CostEstimate( + source=source, + currency=currency, + provider=provider, + model=model, + pricing_as_of=pricing_as_of, + amount=text("amount"), + min_amount=text("min_amount"), + max_amount=text("max_amount"), + amount_cents=number("amount_cents"), + min_amount_cents=number("min_amount_cents"), + max_amount_cents=number("max_amount_cents"), + credits=number("credits"), + reason=text("reason"), + raw=dict(estimate), + ) + + def _raise_for_completion(payload: Any, *, request_id: str, envelope_only: bool = False) -> None: """Raise the typed router exception a completion reports, if it reports one. @@ -378,6 +531,7 @@ class _RequestHandleBase: _model: str _request_id: str _retry: RetryPolicy + _estimate: CostEstimate | None @property def model(self) -> str: @@ -394,8 +548,29 @@ def request_id(self) -> str: """The server-minted id for this request — all a rehydration needs.""" return self._request_id + @property + def estimate(self) -> CostEstimate | None: + """Router's pre-flight cost quote for this request, or ``None``. + + ``None`` means **no quote is available — never "no charge"**. It is + ``None`` when the estimate is not enabled for the caller; when the + submit was answered by an idempotent replay (``Idempotent-Replayed: + true``), because the original quote is not stored — and the SDK's own + transport-level retry inside one ``submit`` can be answered by such a + replay; and always on a handle rebuilt with ``client.models.handle``. + + Branch on ``is not None``; when a quote is present, + :attr:`CostEstimate.source` says how much to trust the figure. It is + not a price lock: the run is charged at the rates in force when rated. + """ + return self._estimate + def __repr__(self) -> str: - return f"{type(self).__name__}(model={self._model!r}, request_id={self._request_id!r})" + estimate = "" if self._estimate is None else f", estimate={self._estimate.source!r}" + return ( + f"{type(self).__name__}(model={self._model!r}, " + f"request_id={self._request_id!r}{estimate})" + ) class RequestHandle(_RequestHandleBase): @@ -412,11 +587,14 @@ def __init__( model: str, request_id: str, retry: RetryPolicy = DEFAULT_RETRY, + *, + estimate: CostEstimate | None = None, ) -> None: self._low = low self._model = model self._request_id = request_id self._retry = retry + self._estimate = estimate # -- polling (authoritative) ------------------------------------------ def status(self) -> QueueUpdate: @@ -616,11 +794,14 @@ def __init__( model: str, request_id: str, retry: RetryPolicy = DEFAULT_RETRY, + *, + estimate: CostEstimate | None = None, ) -> None: self._low = low self._model = model self._request_id = request_id self._retry = retry + self._estimate = estimate async def status(self) -> QueueUpdate: """Awaitable :meth:`RequestHandle.status` — one authoritative poll.""" @@ -718,4 +899,4 @@ async def _call( await asyncio.sleep(delay) -__all__ = ["COMPLETED", "AsyncRequestHandle", "QueueUpdate", "RequestHandle"] +__all__ = ["COMPLETED", "AsyncRequestHandle", "CostEstimate", "QueueUpdate", "RequestHandle"] diff --git a/src/comfy_sdk/models.py b/src/comfy_sdk/models.py index e735106..8ace7df 100644 --- a/src/comfy_sdk/models.py +++ b/src/comfy_sdk/models.py @@ -83,6 +83,7 @@ QueueUpdate, RequestHandle, _completed, + _estimate_of, _remaining, _request_id_of, ) @@ -773,6 +774,12 @@ def submit( ``queue_backlog_full`` (:class:`~comfy_sdk.router_exceptions.QueueBacklogFull`), which clears as its own queued requests finish. + + When Router quotes the request's cost up front it arrives on the + handle as :attr:`~comfy_sdk.model_requests.RequestHandle.estimate`, a + :class:`~comfy_sdk.model_requests.CostEstimate`. ``None`` there means + no quote is available — the estimate is off for the caller, or the + submit was answered by an idempotent replay — and never "no charge". """ low = cast(ComfyLow, self._low) key = ( @@ -797,7 +804,8 @@ def submit( raise time.sleep(delay) request_id = _request_id_of(body) - return RequestHandle(low, model, request_id, self._retry) + estimate = _estimate_of(body) + return RequestHandle(low, model, request_id, self._retry, estimate=estimate) def subscribe( self, @@ -1111,7 +1119,8 @@ async def submit( raise await asyncio.sleep(delay) request_id = _request_id_of(body) - return AsyncRequestHandle(low, model, request_id, self._retry) + estimate = _estimate_of(body) + return AsyncRequestHandle(low, model, request_id, self._retry, estimate=estimate) async def subscribe( self, diff --git a/tests/conftest.py b/tests/conftest.py index b9538e2..33b8bd2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -293,6 +293,10 @@ class ServerState: # Answer the submit with a body carrying no `request_id` at all — accepted # work the caller has no way to reach. queue_submit_omits_request_id: bool = False + # The 201's `estimate` object, sent verbatim (so a test can send a malformed + # one). None omits the field, as Router does on an idempotent replay or + # with the cost estimate off for the caller. + queue_submit_estimate: Any = None # The id the queue hands back, and the one every later route answers for. queue_request_id: str = "req_stub_01" # Status polls that report a non-terminal state before the request reaches @@ -878,6 +882,8 @@ def _post_queue_submit(self, provider: str, model: str) -> None: body: dict[str, Any] = {"status": state.queue_pending_status} if not state.queue_submit_omits_request_id: body["request_id"] = state.queue_request_id + if state.queue_submit_estimate is not None: + body["estimate"] = state.queue_submit_estimate self._json(state.queue_submit_status, body) def _serve_queue_status(self, request_id: str) -> None: diff --git a/tests/test_models_queue.py b/tests/test_models_queue.py index 1bc6f07..fc27867 100644 --- a/tests/test_models_queue.py +++ b/tests/test_models_queue.py @@ -35,7 +35,7 @@ ) from comfy_sdk import AsyncComfy, BinaryResult, Comfy, QueueUpdate from comfy_sdk.exceptions import ComfyError -from comfy_sdk.model_requests import COMPLETED, AsyncRequestHandle, RequestHandle +from comfy_sdk.model_requests import COMPLETED, AsyncRequestHandle, CostEstimate, RequestHandle from comfy_sdk.retry import NO_RETRY from comfy_sdk.router_exceptions import ( ContentPolicyViolation, @@ -46,6 +46,16 @@ MODEL = "acme/fast-sdxl" ARGS = {"prompt": "a red bicycle"} +EXACT_ESTIMATE = { + "source": "exact", + "currency": "USD", + "amount": "0.04", + "amount_cents": 4.0, + "credits": 8.44, + "provider": "acme", + "model": MODEL, + "pricing_as_of": "2026-10-09T00:00:00Z", +} @pytest.fixture @@ -86,6 +96,128 @@ def test_submit_posts_to_the_requests_route_under_the_model_id(server, fast_poll ) +# --- the submit's cost estimate --------------------------------------------- + + +def _submitted_estimate() -> CostEstimate | None: + with _client() as client: + return client.models.submit(MODEL, ARGS).estimate + + +def test_an_exact_estimate_is_exposed_on_the_handle(server, fast_poll) -> None: + server.state.queue_submit_estimate = EXACT_ESTIMATE + with _client() as client: + handle = client.models.submit(MODEL, ARGS) + + estimate = handle.estimate + assert estimate is not None + assert estimate.is_exact and not estimate.is_estimated and not estimate.is_unknown + # The spec's decimal string, verbatim — never coerced to a float. + assert estimate.amount == "0.04" + assert isinstance(estimate.amount, str) + assert estimate.amount_cents == 4.0 + assert estimate.credits == 8.44 + assert (estimate.currency, estimate.provider, estimate.model) == ("USD", "acme", MODEL) + assert estimate.pricing_as_of == "2026-10-09T00:00:00Z" + assert estimate.min_amount is None and estimate.reason is None + assert estimate.raw == EXACT_ESTIMATE + assert "estimate='exact'" in repr(handle) + assert repr(estimate) == "CostEstimate(source='exact', amount='0.04')" + + +def test_an_estimated_range_carries_no_amount(server, fast_poll) -> None: + server.state.queue_submit_estimate = { + **{k: v for k, v in EXACT_ESTIMATE.items() if k not in ("amount", "amount_cents")}, + "source": "estimated", + "min_amount": "0.02", + "max_amount": "0.08", + "min_amount_cents": 2.0, + "max_amount_cents": 8, + } + estimate = _submitted_estimate() + + assert estimate is not None + assert estimate.is_estimated and not estimate.is_exact and not estimate.is_unknown + assert estimate.amount is None + assert (estimate.min_amount, estimate.max_amount) == ("0.02", "0.08") + assert (estimate.min_amount_cents, estimate.max_amount_cents) == (2.0, 8.0) + + +def test_an_unknown_estimate_carries_a_reason_and_no_figure(server, fast_poll) -> None: + server.state.queue_submit_estimate = { + "source": "unknown", + "reason": "not_quotable", + "currency": "USD", + "provider": "acme", + "model": MODEL, + "pricing_as_of": "2026-10-09T00:00:00Z", + } + estimate = _submitted_estimate() + + assert estimate is not None + assert estimate.is_unknown and not estimate.is_exact and not estimate.is_estimated + assert estimate.reason == "not_quotable" + assert estimate.amount is None + + +def test_an_unrecognised_source_reads_as_unknown(server, fast_poll) -> None: + # The spec makes `source` an open string: a value added after this SDK + # version is read as `unknown`, and kept verbatim. + server.state.queue_submit_estimate = {**EXACT_ESTIMATE, "source": "something_new"} + estimate = _submitted_estimate() + + assert estimate is not None + assert estimate.source == "something_new" + assert estimate.is_unknown and not estimate.is_exact + + +def test_no_estimate_on_the_201_is_none(server, fast_poll) -> None: + with _client() as client: + handle = client.models.submit(MODEL, ARGS) + assert handle.estimate is None + assert "estimate" not in repr(handle) + + +@pytest.mark.parametrize( + "malformed", + [ + "nope", + {"source": 5}, + {**EXACT_ESTIMATE, "pricing_as_of": None}, + [EXACT_ESTIMATE], + ], +) +def test_a_malformed_estimate_is_no_quote_and_never_fails_the_submit( + server, fast_poll, malformed: Any +) -> None: + server.state.queue_submit_estimate = malformed + with _client() as client: + handle = client.models.submit(MODEL, ARGS) + assert handle.request_id == server.state.queue_request_id + assert handle.estimate is None + + +def test_a_mistyped_optional_estimate_field_is_left_none(server, fast_poll) -> None: + server.state.queue_submit_estimate = { + **EXACT_ESTIMATE, + "amount": 0.04, + "amount_cents": "4", + "credits": True, + } + estimate = _submitted_estimate() + + assert estimate is not None + assert estimate.is_exact + assert (estimate.amount, estimate.amount_cents, estimate.credits) == (None, None, None) + + +def test_a_rehydrated_handle_has_no_estimate(server, fast_poll) -> None: + server.state.queue_submit_estimate = EXACT_ESTIMATE + with _client() as client: + rid = client.models.submit(MODEL, ARGS).request_id + assert client.models.handle(MODEL, rid).estimate is None + + def test_every_queue_route_is_addressed_by_both_ids(server, fast_poll) -> None: server.state.queue_polls_to_complete = 0 with _client() as client: @@ -664,6 +796,24 @@ async def test_async_submit_and_get(server, fast_poll) -> None: assert await handle.get() == server.state.queue_result +async def test_async_submit_exposes_an_exact_estimate(server, fast_poll) -> None: + server.state.queue_submit_estimate = EXACT_ESTIMATE + async with AsyncComfy(api_key="comfyui-test-key") as client: + handle = await client.models.submit(MODEL, ARGS) + + assert handle.estimate is not None + assert handle.estimate.is_exact + assert handle.estimate.amount == "0.04" + assert handle.estimate.raw == EXACT_ESTIMATE + + +async def test_async_submit_without_an_estimate_is_none(server, fast_poll) -> None: + async with AsyncComfy(api_key="comfyui-test-key") as client: + handle = await client.models.submit(MODEL, ARGS) + assert handle.estimate is None + assert (await client.models.handle(MODEL, handle.request_id)).estimate is None + + async def test_async_get_returns_a_binary_result_too(server, fast_poll) -> None: server.state.queue_result_binary_body = b"\xff\xfb\x90\x64" + b"\xde\xad" * 64 server.state.queue_result_binary_content_type = "audio/mpeg" diff --git a/tests/test_router_spec_contract.py b/tests/test_router_spec_contract.py index 4f950af..10e8515 100644 --- a/tests/test_router_spec_contract.py +++ b/tests/test_router_spec_contract.py @@ -32,7 +32,7 @@ from __future__ import annotations -from dataclasses import fields +from dataclasses import MISSING, fields from pathlib import Path from typing import Any @@ -47,6 +47,7 @@ model_catalog_path, ) from comfy_sdk import COMFY_ROUTER_BASE_URL +from comfy_sdk.model_requests import CostEstimate from comfy_sdk.models import RouterRunResult, _run_result from comfy_sdk.router_exceptions import ( ROUTER_ERROR_TYPES, @@ -319,6 +320,27 @@ def test_the_catalog_query_parameters_are_the_ones_the_spec_declares() -> None: assert model_catalog_path("c", 5) == f"{_MODEL_CATALOG_PATH}?cursor=c&limit=5" +def test_every_cost_estimate_property_the_spec_declares_is_modelled() -> None: + # `CostEstimate` is read field by field out of the submit's `estimate` + # object; a sync that adds a property would otherwise reach callers only + # through `.raw`. The spec's required fields are the dataclass's + # positional ones, which `_estimate_of` holds a quote to. + doc = yaml.safe_load(ROUTER_SPEC.read_text(encoding="utf-8")) + schema = doc["components"]["schemas"]["RouterCostEstimate"] + modelled = {f.name for f in fields(CostEstimate)} - {"raw"} + assert set(schema["properties"]) <= modelled, set(schema["properties"]) - modelled + required = { + f.name + for f in fields(CostEstimate) + if f.default is MISSING and f.default_factory is MISSING + } + assert set(schema["required"]) == required + submit_fields = doc["components"]["schemas"]["RouterQueueSubmitFields"]["properties"] + assert [ref["$ref"] for ref in submit_fields["estimate"]["allOf"]] == [ + "#/components/schemas/RouterCostEstimate" + ] + + def test_the_bound_path_has_exactly_the_two_segments_the_binding_fills() -> None: # `model_run_request` fills `{provider}` and `{model}` by name; a sync that # renamed or added a template variable would silently KeyError at call time From a7c3fb33bc1c8865f5520239cde5ca55c85fe457 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Fri, 9 Oct 2026 23:44:43 +0000 Subject: [PATCH 2/4] fix(models): drop a cost quote whose promised figure cannot be read An exact quote without a readable amount, or an estimated one without both bounds, is now no quote. Money strings must be plain non-negative decimals, numbers must be finite and non-negative, required strings must be non-blank, an unrecognised source carries no figure, and raw is left out of the hash so a CostEstimate is hashable. --- src/comfy_sdk/model_requests.py | 56 +++++++++++++++++++++++++++------ tests/test_models_queue.py | 35 +++++++++++++++++++-- uv.lock | 4 +-- 3 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/comfy_sdk/model_requests.py b/src/comfy_sdk/model_requests.py index 7352b0b..5810ce6 100644 --- a/src/comfy_sdk/model_requests.py +++ b/src/comfy_sdk/model_requests.py @@ -39,6 +39,8 @@ from __future__ import annotations import asyncio +import math +import re import time from collections.abc import AsyncGenerator, Awaitable, Callable, Generator, Iterator, Mapping from dataclasses import dataclass, field, replace @@ -144,6 +146,11 @@ def __repr__(self) -> str: #: which is the spec's own rule for the field. _QUOTED_SOURCES = ("exact", "estimated") +#: A money string as the spec writes one: a plain non-negative decimal. Any +#: other string -- blank, ``NaN``, ``-1``, ``1e9`` -- is no figure, so a caller +#: can always hand :attr:`CostEstimate.amount` to ``Decimal``. +_DECIMAL_AMOUNT = re.compile(r"[0-9]+(?:\.[0-9]+)?") + @dataclass(frozen=True) class CostEstimate: @@ -157,8 +164,11 @@ class CostEstimate: ``exact`` carries :attr:`amount`; ``estimated`` carries :attr:`min_amount` and :attr:`max_amount` and no :attr:`amount`; ``unknown`` carries neither, and a :attr:`reason`. Any other value is read as ``unknown`` - (:attr:`is_unknown`), as the spec directs. An absent :attr:`amount` is - never "free". + (:attr:`is_unknown`), as the spec directs, and its figures are dropped + (they stay in :attr:`raw`). An absent :attr:`amount` is never "free". + The figure the ``source`` promises is always present: an ``exact`` quote + whose :attr:`amount`, or an ``estimated`` one whose bounds, cannot be read + is no quote at all. Money is the server's decimal **string**, kept verbatim and never coerced to a float, so ``"0.04"`` stays exactly ``"0.04"``; the ``*_cents`` and @@ -200,8 +210,9 @@ class CostEstimate: #: ``pricing_unavailable``, and an open string like :attr:`source`. reason: str | None = None #: The decoded ``estimate`` object, unmodified — the escape hatch for a - #: field this dataclass does not model yet. - raw: Mapping[str, Any] = field(default_factory=dict) + #: field this dataclass does not model yet. Left out of the hash, so a + #: quote stays hashable like the frozen value it is. + raw: Mapping[str, Any] = field(default_factory=dict, hash=False) @property def is_exact(self) -> bool: @@ -360,16 +371,25 @@ def _estimate_of(payload: Any) -> CostEstimate | None: def text(name: str) -> str | None: value = estimate.get(name) - return value if isinstance(value, str) else None + return value if isinstance(value, str) and value.strip() else None + + def money(name: str) -> str | None: + value = estimate.get(name) + if isinstance(value, str) and _DECIMAL_AMOUNT.fullmatch(value): + return value + return None def number(name: str) -> float | None: value = estimate.get(name) if isinstance(value, bool) or not isinstance(value, (int, float)): return None try: - return float(value) + result = float(value) except OverflowError: # an integer too large for a float is no figure return None + # NaN slips past every comparison a spend guard makes, and a negative + # charge is no figure either. + return result if math.isfinite(result) and result >= 0 else None source = text("source") currency = text("currency") @@ -384,15 +404,26 @@ def number(name: str) -> float | None: or pricing_as_of is None ): return None - return CostEstimate( + if source not in _QUOTED_SOURCES: + # Read as `unknown`, which carries no figure whatever else arrived. + return CostEstimate( + source=source, + currency=currency, + provider=provider, + model=model, + pricing_as_of=pricing_as_of, + reason=text("reason"), + raw=dict(estimate), + ) + quote = CostEstimate( source=source, currency=currency, provider=provider, model=model, pricing_as_of=pricing_as_of, - amount=text("amount"), - min_amount=text("min_amount"), - max_amount=text("max_amount"), + amount=money("amount"), + min_amount=money("min_amount"), + max_amount=money("max_amount"), amount_cents=number("amount_cents"), min_amount_cents=number("min_amount_cents"), max_amount_cents=number("max_amount_cents"), @@ -400,6 +431,11 @@ def number(name: str) -> float | None: reason=text("reason"), raw=dict(estimate), ) + if quote.is_exact and quote.amount is None: + return None + if quote.is_estimated and (quote.min_amount is None or quote.max_amount is None): + return None + return quote def _raise_for_completion(payload: Any, *, request_id: str, envelope_only: bool = False) -> None: diff --git a/tests/test_models_queue.py b/tests/test_models_queue.py index fc27867..8199743 100644 --- a/tests/test_models_queue.py +++ b/tests/test_models_queue.py @@ -169,6 +169,9 @@ def test_an_unrecognised_source_reads_as_unknown(server, fast_poll) -> None: assert estimate is not None assert estimate.source == "something_new" assert estimate.is_unknown and not estimate.is_exact + # Read as `unknown`, so no figure — the verbatim body keeps it. + assert (estimate.amount, estimate.amount_cents, estimate.credits) == (None, None, None) + assert estimate.raw["amount"] == "0.04" def test_no_estimate_on_the_201_is_none(server, fast_poll) -> None: @@ -184,7 +187,16 @@ def test_no_estimate_on_the_201_is_none(server, fast_poll) -> None: "nope", {"source": 5}, {**EXACT_ESTIMATE, "pricing_as_of": None}, + {**EXACT_ESTIMATE, "model": " "}, [EXACT_ESTIMATE], + # The figure `source` promises cannot be read: a half-read quote. + {**EXACT_ESTIMATE, "amount": 0.04}, + {**EXACT_ESTIMATE, "amount": ""}, + {**EXACT_ESTIMATE, "amount": "NaN"}, + {**EXACT_ESTIMATE, "amount": "-1"}, + {**EXACT_ESTIMATE, "amount": "1e9"}, + {key: value for key, value in EXACT_ESTIMATE.items() if key != "amount"}, + {**EXACT_ESTIMATE, "source": "estimated", "min_amount": "0.02"}, ], ) def test_a_malformed_estimate_is_no_quote_and_never_fails_the_submit( @@ -200,7 +212,6 @@ def test_a_malformed_estimate_is_no_quote_and_never_fails_the_submit( def test_a_mistyped_optional_estimate_field_is_left_none(server, fast_poll) -> None: server.state.queue_submit_estimate = { **EXACT_ESTIMATE, - "amount": 0.04, "amount_cents": "4", "credits": True, } @@ -208,7 +219,27 @@ def test_a_mistyped_optional_estimate_field_is_left_none(server, fast_poll) -> N assert estimate is not None assert estimate.is_exact - assert (estimate.amount, estimate.amount_cents, estimate.credits) == (None, None, None) + assert estimate.amount == "0.04" + assert (estimate.amount_cents, estimate.credits) == (None, None) + + +@pytest.mark.parametrize("bad", [float("nan"), float("inf"), -4.0, 10**400]) +def test_a_non_finite_or_negative_number_is_left_none(server, fast_poll, bad: Any) -> None: + # httpx decodes `NaN` / `Infinity` and `1e400`; NaN would slip past any + # `amount_cents > budget` guard. + server.state.queue_submit_estimate = {**EXACT_ESTIMATE, "amount_cents": bad, "credits": bad} + estimate = _submitted_estimate() + + assert estimate is not None + assert (estimate.amount_cents, estimate.credits) == (None, None) + + +def test_an_estimate_is_hashable(server, fast_poll) -> None: + server.state.queue_submit_estimate = EXACT_ESTIMATE + estimate = _submitted_estimate() + + assert estimate is not None + assert estimate in {estimate} def test_a_rehydrated_handle_has_no_estimate(server, fast_poll) -> None: diff --git a/uv.lock b/uv.lock index 812ca51..449c121 100644 --- a/uv.lock +++ b/uv.lock @@ -287,7 +287,7 @@ requires-dist = [ { name = "datamodel-code-generator", marker = "extra == 'codegen'", specifier = "~=0.68.1" }, { name = "datamodel-code-generator", marker = "extra == 'dev'", specifier = "~=0.68.1" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mypy", marker = "extra == 'dev'", specifier = "~=2.3.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=2.3,<2.5" }, { name = "pillow", marker = "extra == 'dev'", specifier = ">=10" }, { name = "pillow", marker = "extra == 'pil'", specifier = ">=10" }, { name = "pydantic", specifier = ">=2.6" }, @@ -428,7 +428,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ From 76855b9435d368f12d6593aa01774f1e916d1db3 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Fri, 9 Oct 2026 23:55:05 +0000 Subject: [PATCH 3/4] fix(models): drop an estimated quote whose bounds are reversed --- src/comfy_sdk/model_requests.py | 10 ++++++++-- tests/test_models_queue.py | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/comfy_sdk/model_requests.py b/src/comfy_sdk/model_requests.py index 5810ce6..954d264 100644 --- a/src/comfy_sdk/model_requests.py +++ b/src/comfy_sdk/model_requests.py @@ -44,6 +44,7 @@ import time from collections.abc import AsyncGenerator, Awaitable, Callable, Generator, Iterator, Mapping from dataclasses import dataclass, field, replace +from decimal import Decimal from typing import Any import httpx @@ -433,8 +434,13 @@ def number(name: str) -> float | None: ) if quote.is_exact and quote.amount is None: return None - if quote.is_estimated and (quote.min_amount is None or quote.max_amount is None): - return None + if quote.is_estimated: + if quote.min_amount is None or quote.max_amount is None: + return None + # Both already match `_DECIMAL_AMOUNT`, so `Decimal` cannot raise; a + # reversed range is a quote nobody can act on. + if Decimal(quote.min_amount) > Decimal(quote.max_amount): + return None return quote diff --git a/tests/test_models_queue.py b/tests/test_models_queue.py index 8199743..1d8467e 100644 --- a/tests/test_models_queue.py +++ b/tests/test_models_queue.py @@ -197,6 +197,7 @@ def test_no_estimate_on_the_201_is_none(server, fast_poll) -> None: {**EXACT_ESTIMATE, "amount": "1e9"}, {key: value for key, value in EXACT_ESTIMATE.items() if key != "amount"}, {**EXACT_ESTIMATE, "source": "estimated", "min_amount": "0.02"}, + {**EXACT_ESTIMATE, "source": "estimated", "min_amount": "0.08", "max_amount": "0.02"}, ], ) def test_a_malformed_estimate_is_no_quote_and_never_fails_the_submit( @@ -209,6 +210,19 @@ def test_a_malformed_estimate_is_no_quote_and_never_fails_the_submit( assert handle.estimate is None +def test_an_estimated_range_may_be_a_single_point(server, fast_poll) -> None: + server.state.queue_submit_estimate = { + **EXACT_ESTIMATE, + "source": "estimated", + "min_amount": "0.04", + "max_amount": "0.040", + } + estimate = _submitted_estimate() + + assert estimate is not None + assert (estimate.min_amount, estimate.max_amount) == ("0.04", "0.040") + + def test_a_mistyped_optional_estimate_field_is_left_none(server, fast_poll) -> None: server.state.queue_submit_estimate = { **EXACT_ESTIMATE, From 752b7b70a5eaebcc4034162b408051f10b3d39dc Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Thu, 22 Oct 2026 00:04:31 +0000 Subject: [PATCH 4/4] test(models): cover negative and non-finite estimate range bounds An estimated quote whose min_amount or max_amount is negative, NaN, Infinity or a JSON number now has a malformed-quote case each, alongside the existing reversed-range case. --- tests/test_models_queue.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_models_queue.py b/tests/test_models_queue.py index 1d8467e..b363c9c 100644 --- a/tests/test_models_queue.py +++ b/tests/test_models_queue.py @@ -198,6 +198,13 @@ def test_no_estimate_on_the_201_is_none(server, fast_poll) -> None: {key: value for key, value in EXACT_ESTIMATE.items() if key != "amount"}, {**EXACT_ESTIMATE, "source": "estimated", "min_amount": "0.02"}, {**EXACT_ESTIMATE, "source": "estimated", "min_amount": "0.08", "max_amount": "0.02"}, + # A bound that is negative or not a finite figure is as unreadable as + # a missing one, so the range goes with it. + {**EXACT_ESTIMATE, "source": "estimated", "min_amount": "-0.02", "max_amount": "0.08"}, + {**EXACT_ESTIMATE, "source": "estimated", "min_amount": "0.02", "max_amount": "-0.08"}, + {**EXACT_ESTIMATE, "source": "estimated", "min_amount": "NaN", "max_amount": "0.08"}, + {**EXACT_ESTIMATE, "source": "estimated", "min_amount": "0.02", "max_amount": "Infinity"}, + {**EXACT_ESTIMATE, "source": "estimated", "min_amount": 0.02, "max_amount": 0.08}, ], ) def test_a_malformed_estimate_is_no_quote_and_never_fails_the_submit(