mirror of
https://github.com/Comfy-Org/comfy-python-sdk.git
synced 2026-11-04 23:37:47 +00:00
[PR #166] [MERGED] feat(models): surface X-Comfy-Credits-Used on RouterRunResult #164
Labels
No labels
audit:ai-readiness
audit:community
audit:github
audit:health-scoring
audit:policy
audit:release
audit:supply-chain
effort:medium
effort:small
effort:small
effort:trivial
priority:nice-to-have
priority:nice-to-have
priority:recommended
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Comfy-Org/comfy-python-sdk#164
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
📋 Pull Request Information
Original PR: https://github.com/Comfy-Org/comfy-python-sdk/pull/166
Author: @mattmillerai
Created: 9/18/2026
Status: ✅ Merged
Merged: 9/20/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-15587-router-run-credits-used📝 Commits (3)
38e740dfeat(models): surface X-Comfy-Credits-Used on RouterRunResultadb06afMerge origin/main into matt/be-15587-router-run-credits-usedb59d247fix(models): address review on the credits lift, and fix the replay lift beside it📊 Changes
5 files changed (+387 additions, -12 deletions)
View changed files
📝
CHANGELOG.md(+19 -0)📝
src/comfy_sdk/models.py(+93 -8)📝
tests/conftest.py(+22 -2)📝
tests/test_models_run.py(+160 -2)📝
tests/test_router_spec_contract.py(+93 -0)📄 Description
ELI-5
Comfy Router tells you what a model run cost by stamping a header on the response. The Python SDK was throwing that header away, and
RouterRunResult— the shapemodels.run_detailed()hands back — is frozen and slotted, so a caller could not even stick the value on themselves afterwards. This adds acredits_usedfield and fills it in from the header, so the number is reachable.What changed
RouterRunResulthand-lifts a fixed set of response headers; nothing generates that struct from the vendored contract, so a header only becomes reachable when it is lifted by hand.credits_usedis now one of them, populated in_run_resultfromX-Comfy-Credits-Used.Type choice: the wire string, unparsed. The value is a decimal formatted to at most 2dp, and binary
floatis the wrong type for a number a caller reconciles money against. Handing over the digits intact lets the caller pick the numeric type their own reconciliation needs (Decimal) instead of having a lossy one picked for them. Nothing is parsed, so there is no malformed-value branch and no way for this lift to raise.The docstring carries the three caveats that make the value usable, because each is something a caller gets wrong by assuming the obvious: it is a price and not a settled ledger entry; absent means "not reported" and never "free"; and
0is a real reported cost, so the documented test iscredits_used is not Nonerather than the value being non-zero.Worth calling out, because I got it wrong first and my own test caught it: with the string representation a sloppy
if result.credits_used:happens to survive a reported zero, since"0"is a non-empty string. That is luck rather than a contract — it stops holding the moment a caller parses toDecimal("0"), which is exactly what the docstring tells them to do — so the documented rule is presence, and the test asserts presence rather than the accident.Additive only: no existing field changes value, and
RouterRunResultis unreleased (absent fromv0.3.0), so appending a field breaks no positional construction that could exist in the wild._run_resultis the single constructor, reached by both the sync and asyncrun_detailed.Test plan
Two levels, because they fail differently. The unit stubs (
_HeaderLow, the existing pattern in this file) cover present, absent, and a reported0kept distinguishable from an absent one. A wire-level pass over the stub server covers sync and async: the lift reads one exact header name off whateverpost_model_runreturned, and a plain stub dict would answer the same way whether or not that name survives a real HTTP round trip.tests/conftest.pygains amodel_run_response_headersstate field to stamp disclosure headers on a successful run; it defaults empty, so every existing test sends exactly the headers it did before.Provenance
ruff check .clean;ruff format --check .55 files already formatted;mypy srcno issues in 20 source files;pytest897 passed, 9 skipped;scripts/check_public_repo_hygiene.pyOK;scripts/check_drift.pyOK on all three of its checks.Residual
Not fixed here, and each item is actionable on its own.
1. The vendored contract does not declare this header, so the header name is the one unverified premise in the diff.
spec/router-openapi.yamlonmaincarries noRouterCreditsUsedHeaderand noX-Comfy-Credits-Usedanywhere — the most recent spec sync landed with #159 and does not include it. The200response onPOST /v2/models/{provider}/{model}declares seven headers and this is not among them. So"X-Comfy-Credits-Used"in_run_resultis taken verbatim from the work item's description and could not be checked against a vendored declaration, and the docstring is written from the caveats as stated there rather than from the contract's own prose. When the spec sync carrying that header lands, the declared header name must be diffed against this string literal, and the docstring re-read against the real description.This is not a theoretical worry — item 2 is the same mistake already sitting in this struct.
2. Verified defect:
RouterRunResult.replayedis alwaysFalseon a real replayed run. Adjacent to this change and in the same hand-lifted set, so it is in scope to report even though I did not fix it._run_resultreadsX-Comfy-Idempotent-Replayed, but the vendored contract declares the header asIdempotent-Replayed(noX-Comfy-prefix) in all four places it appears — the200on the run route, the200on the queued result read, and two error responses — and the stub server intests/conftest.pysendsIdempotent-Replayed: trueaccordingly. TheX-Comfy-prefixed spelling is not declared anywhere in the contract.Evidence, run against the repo's own stub server on this branch (seed the replay record, then run under the same key so the server answers from it and sends the header):
The existing unit test passes because it feeds the wrong name to a stub dict (
_detailed({"X-Comfy-Idempotent-Replayed": "true"}).replayed is True), so it asserts the lift's own premise rather than the contract. The fix is one character-range in_run_resultplus that test, but it changes existing public behaviour on a different header rather than adding a new one, so it does not belong in a feature PR — it wants its own change with its own note, sincereplayedflipping from always-Falseto correct is observable to anyone already branching on it.3. The
/proxy/*convention the work item asks this to match was not consulted. The instruction was, if the value is parsed, to match whateverX-Comfy-Credits-Usedhandling those consumers already settled on rather than inventing a third convention. That code is not in this repository and I had no access to it. I sidestepped rather than guessed: carrying the unparsed wire string invents no numeric convention, so it cannot conflict with theirs, and a caller can still produce whatever type they use. If those consumers turn out to have settled onDecimal, adding a parsed accessor alongside the raw string is a compatible follow-on; if they settled onfloat, this should stay a string regardless.4. The queued surface drops the same header.
RequestHandle.get()collects its result through_collect, which explicitly discards the response headers (payload, _headers = self._call(...)) and returns the bare body, so any disclosure header on the queued result read — including this one, if the server stamps it there — is unreachable the same way it was onrun_detailedbefore this change. Out of scope here (the work item scopes toRouterRunResult), and it needs a decision this change did not need: whether the queued result read should grow a result envelope at all, or whether the cost belongs onQueueUpdate, which already is built from headers.5. Server-side header coverage is explicitly not in scope and is tracked separately: a substantial share of Router runs carry no header even where the cost is known. This change surfaces whatever the server sends, and
Noneis documented as "not reported", never "free" — so widening the server's disclosure needs no further SDK change.Falsification check (not applicable): this diff denies no capability — it is purely additive, adds no deny/dead-end path, no "not supported"/"unavailable" string, and flips no test to assert a dead-end. It makes a previously unreachable value reachable.
Summary by CodeRabbit
"0"; missing values remain unreported.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.