Skip to content
Open
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ the fuller account of each version, including verification notes.

## [Unreleased]

### 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.

## [0.5.0] - 2026-10-10

### Added
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
9 changes: 8 additions & 1 deletion src/comfy_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -118,6 +124,7 @@
"RequestHandle",
"AsyncRequestHandle",
"QueueUpdate",
"CostEstimate",
"COMPLETED",
"Output",
"AsyncOutput",
Expand Down
227 changes: 225 additions & 2 deletions src/comfy_sdk/model_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,12 @@
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
from decimal import Decimal
from typing import Any

import httpx
Expand Down Expand Up @@ -139,6 +142,109 @@ 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")

#: 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:
"""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, 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
: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
Comment thread
mattmillerai marked this conversation as resolved.
#: The decoded ``estimate`` object, unmodified — the escape hatch for a
#: 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:
"""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
Comment thread
mattmillerai marked this conversation as resolved.
``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``.

Expand Down Expand Up @@ -249,6 +355,95 @@ 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) 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:
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")
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
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=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"),
credits=number("credits"),
reason=text("reason"),
raw=dict(estimate),
)
if quote.is_exact and quote.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


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.

Expand Down Expand Up @@ -378,6 +573,7 @@ class _RequestHandleBase:
_model: str
_request_id: str
_retry: RetryPolicy
_estimate: CostEstimate | None

@property
def model(self) -> str:
Expand All @@ -394,8 +590,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):
Expand All @@ -412,11 +629,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:
Expand Down Expand Up @@ -616,11 +836,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."""
Expand Down Expand Up @@ -718,4 +941,4 @@ async def _call(
await asyncio.sleep(delay)


__all__ = ["COMPLETED", "AsyncRequestHandle", "QueueUpdate", "RequestHandle"]
__all__ = ["COMPLETED", "AsyncRequestHandle", "CostEstimate", "QueueUpdate", "RequestHandle"]
Loading
Loading