mirror of
https://github.com/Comfy-Org/comfy-typescript-sdk.git
synced 2026-11-04 23:47:27 +00:00
[PR #157] [MERGED] feat(models): surface X-Comfy-Credits-Used on RunJsonResult and RunBinaryResult #156
Labels
No labels
audit:ai-readiness
audit:community
audit:github
audit:health-scoring
audit:policy
audit:release
audit:supply-chain
bug
cursor-review-pin
effort:medium
effort:small
effort:trivial
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-typescript-sdk#156
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-typescript-sdk/pull/157
Author: @mattmillerai
Created: 9/18/2026
Status: ✅ Merged
Merged: 10/8/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-15588-credits-used-header📝 Commits (9)
019281cfeat(models): surface X-Comfy-Credits-Used on RunJsonResult and RunBinaryResultd4793dafix(models): make creditsUsed optional, normalize a blank header, pin the name84963abMerge remote-tracking branch 'origin/main' into matt/be-15588-credits-used-header254f216test(models): pin CREDITS_USED_HEADER to the name the contract now declaresf3dc5a1docs(changelog): note creditsUsed is success-only per the Router contract1023532fix(models): guard the queued credits read, and keep BuiltRunResult internal148d72adocs(models): stop claiming a caller's parse fails loudly on a joined credits value9b4d890docs(models): disclose the unpinned queued cost and the client-refused 2007b95356docs(models): point queued-spend reconciliation at the ledger, not a second run📊 Changes
8 files changed (+528 additions, -8 deletions)
View changed files
📝
CHANGELOG.md(+24 -0)📝
scripts/router-route-contract.mjs(+70 -0)📝
src/sdk/modelRequests.test.ts(+52 -1)📝
src/sdk/modelRequests.ts(+20 -1)📝
src/sdk/models.test.ts(+127 -1)📝
src/sdk/models.ts(+140 -5)📝
src/sdk/router-spec-contract.test.ts(+85 -0)📝
test/support/router-stub-server.ts(+10 -0)📄 Description
Summary
Comfy Router stamps
X-Comfy-Credits-Usedon run responses and this SDK dropped it on the floor.RunJsonResultandRunBinaryResultare closed shapes — three lifted headers plus the body, noheadersfield, no rawResponsehanded back — so a caller had no escape hatch, unlikeX-Comfy-Error-Type, which is reachable through the thrown Router error.Both arms now carry
creditsUsed.CREDITS_USED_HEADER(src/sdk/models.ts), besideFALLBACK_PROVIDER_HEADERandDROPPED_PARAMS_HEADER. Not re-exported from thesdkbarrel — neither of its two siblings is either.finish, on the same line of reasoning asservingProvider/droppedParams: read once before any branch returns, so all three return sites (JSON, binary-by-Content-Type, binary-by-unparseable-body) carry it.RequestHandle.collect(),src/sdk/modelRequests.ts), which builds itsRunResultby hand rather than throughfinish. The contract does not declare the header ongetRouterModelRequestResult's200, so the queued value is unpinned: expectnull, but a header Router stamps anyway passes through as-is.Models.subscribe's TSDoc now says it returns the same shape asrunand names this difference, instead of promising an identical result. A rot guard inrouter-spec-contract.test.tsfails the day the contract declares it (see below).parseCreditsUsed: trimmed, and a blank header read as absent.Why
string | nulland notnumber | nullThe wire value is a decimal string (the contract's example is
"12.5"and it does not fix the scale).Number("")andNumber(null)are both0, so a numeric field cannot tell a reported"0"from an absent header — and a caller reconciling money should not silently receive a float nothing asked it to parse. Same discipline asdroppedParams: keep the server's own shape and parse deliberately. That parse is the caller's to check:NumberanswersNaNrather than throwing on a value that is not one decimal, andparseFloatquietly reads only the leading figure.The TSDoc keeps the three caveats that matter:
nullmeans "not reported", never "free." A sizeable share of real Router runs carry no header today even where the cost is known (server-side coverage is tracked separately and is explicitly not in this change's scope). Summingnullas zero understates spend."0"is a real reported cost — branch on presence (creditsUsed != null), never on the value being non-zero.Why the field is optional, and what keeps that honest
creditsUsed?: string | nullon both interfaces, so a consumer's own result literal — a test double — keeps compiling.The first round of this PR made it required, on the argument that it landed in the same unreleased train as
servingProvider/droppedParams. That was wrong. Those two were added in21454a3, which is tagged v0.4.0 and published to npm:@comfyorg/sdk@0.4.0ships both interfaces with those fields and without this one. The CHANGELOG still files that work under[Unreleased]because the 0.4.0 section was never cut, which is where the mistaken reading came from. A required field is therefore a real source break against a released version, exactly as review said.The
?costs something, though, so it is paired with an internal type. Inside this package the field must stay required, or a return site added later silently answersundefined— which a caller's documented presence check reads as "Router reported no cost", the free-versus-not-reported confusion this whole change exists to end, arriving through the back door of the marker that preserves compatibility for everyone else. So the SDK's own producers are typedBuiltRunResult, which intersects the optional field back to required:finish's return type, and a typed local inRequestHandle.collect. Not exported from the barrel, and it appears in no public signature:run,subscribeandhandle.get()all return the publicRunResult, socreditsUsedhas the same type on every path. It caught a second construction path the moment it was added, and removingcreditsUsedfrom any return site failstsc— verified both ways.The documented presence check is the loose
!= nullfor the same reason: it is the one form that reads a hand-built literal's missing field the same way it reads an unstamped response.A blank header is absent, not zero
parseCreditsUsedtrims and maps blank tonull. Passed through, a present-but-emptyX-Comfy-Credits-Used:arrives as"", which clears the presence check and then reads as a cost of zero throughNumber("")— a caller following the TSDoc to the letter would book a run as free that Router never priced. Trimming matches whatdeclaredLengthdoes toContent-LengthandmediaTypeOfdoes toContent-Type.Anything else is handed back verbatim, including the value
Headers.getjoins out of a header sent twice ("0.42, 0.42"). Picking one of two figures would invent a charge; returningnullwould claim Router reported nothing when it reported this, andnullis the one value documented to mean it was not reported. So the value stays intact, and the shape check is the caller's. The obvious parses do not do it:parseFloat("0.42, 0.42")silently returns0.42andNumber("0.42, 0.42")returnsNaNwithout throwing, so the TSDoc now says so and gives a pattern to validate against (/^\d+(\.\d+)?$/).The header name is pinned to the contract
CREDITS_USED_HEADERwent in pinned to nothing, because the vendored contract declared no credits header — and its failure mode is silent and self-consistent: the stub hard-codes the same literal the SDK expects, so a name wrong by one segment would reportcreditsUsed: nullon every run forever, indistinguishable from "Router reported no cost". So the first round of this PR wrote a rot guard asserting the200declared no credits header, failing with an instruction to replace itself with a real pin the day one arrived.It arrived.
mainlanded #166 (chore: sync vendored Comfy Router spec from cloud@427cc43) while this PR was in review, bringingRouterCreditsUsedHeader—runRouterModel's200now declaresX-Comfy-Credits-Used. Note how that surfaced: no textual conflict, and this PR's own checks stayed green, because the spec file is one this branch never touches. The guard reddens only in the merged result.src/sdk/router-spec-contract.test.tsnow carries the real pin: the credits headers the200declares must equal exactly[CREDITS_USED_HEADER.toLowerCase()]. The declared name matches the constant — the SDK has been reading the right header all along, which was not knowable before the sync. Backed by therunSuccessHeaderNamesreader inscripts/router-route-contract.mjs, and it keeps the two sanity assertions (the declared set does contain the siblingX-Comfy-Router-*headers) so a selector that silently returned nothing cannot satisfy it while seeing nothing.The queued result read gets a rot guard rather than a pin, because nothing declares the header there yet:
getRouterModelRequestResult's200must declare no credits header (read by a newsuccessHeaderNames(doc, operationId)inscripts/router-route-contract.mjs, with a sanity check that the read seesx-comfy-request-id). It fails with an instruction to replace itself with a name pin the day the contract declares one.Verified the run pin in both directions: a constant wrong by one segment reddens, and so does a spec that drops the header (
expected [] to deeply equal [ 'x-comfy-credits-used' ]). The removal case is the one worth having — an "is it declared and does it match" check passes vacuously the day the header disappears, which is when every run silently starts reportingcreditsUsed: nullagain.Tests
Run-level tests for both arms, presence vs. absence, and the
"0"-vs-absent distinction — including an assertion that the naive value-based check (Number(x) !== 0) reads the two identically, which is the bug the type choice exists to prevent. Plus, from the review round: blank (""and" ") and padded values, directparseCreditsUsedunit tests covering the joined duplicate header and a reported"0"surviving every trim, and the credits header pin (which replaced the rot guard when #166 landed the header; verified it reddens both on a constant wrong by one segment and on the header being removed from the spec).The queued test was rewritten because it pinned nothing it claimed: the stub stamps
state.creditsUsedinto the base header map before the scripted-response branch, so every scripted response carried the same value and the assertion passed whether the header was lifted off the result fetch or off the status poll.queueScriptnow takesresultHeadersbesidestatusHeaders, and the result response carries"2.75"against a decoy"9.99"on the poll. Verified it is a real pin: hard-coding the built result'screditsUsedto the poll's value reddens it, where before that same mis-attribution passed.Judgment calls
spec/router-openapi.yamldeclared no credits header, so the TSDoc was sourced from the header's stated semantics rather than lifted from the vendored copy. #166 has since landedRouterCreditsUsedHeader, and it independently confirms all three documented caveats: a price rather than a settled ledger entry, absent meaning "not reported" and never "free" (with an explicit warning that summing absent headers as zero will not reconcile), and0being a real reported cost to be distinguished by branching on presence.RouterCreditsUsedHeaderstates the header "is written only on the path that returns a result, which a refused call never reaches". So a Router refusal carries no cost forfinishto discard. That sentence does not cover a priced200that the SDK itself refuses client-side. Such a call throws aComfyErrorwith no cost on it, and the TSDoc and CHANGELOG now say so and tell callers to reconcile it against the ledger. Carrying the cost onComfyErroris the deferred design call in Residual below.Residual
Not fixed by this PR, and actionable on its own. Two review findings are deferred and recorded as follow-ups; a third was withdrawn:
creditsUseddiscarded on the throwing paths. Deferred last round on the reasoning that Router prices a call as it answers, so adeadline_exceeded504 after the provider ran drops its reported cost. The #166 sync refutes it: the header "is written only on the path that returns a result, which a refused call never reaches". There is no cost on those paths to drop, so no field is needed onComfyErrorOptions/ComfyError, and the follow-up filed for it last round has been retracted rather than left open against a refuted premise. Documented on the TSDoc so it is not re-derived.Idempotent-Replayedis read nowhere. Nothing lets a caller tell a replayed price from a fresh charge, so a spend tracker that logs each result double-counts. #166 makes this sharper rather than softer:RouterCreditsUsedHeadernow instructs the caller directly to "not add the header up across retries of oneIdempotency-Key" — an instruction the SDK gives them no way to honour. It still needs a design decision, and the Python SDK has landed its answer: a frozenRouterRunResultreturned by a newrun_detailed(), carryingreplayedderived from the header's presence. This SDK went the other way and put disclosures directly on the result union, so the choice isreplayed: booleanbeside the existing fields, or mirroringrun_detailedand converging. That decision has since been taken elsewhere and this deferral is no longer open work: #164 (feat(models): surface Idempotent-Replayed as RunResult.replayed) picks the first option, derivingreplayedfrom the header's presence infinish(). It is open againstmainand independent of this PR — but it adds a field to the same two interfaces and the same threefinish()return sites this PR touches, so whichever of the two lands second will have a textual conflict to resolve.sdk-parityis red onmain, unrelated to this PR.ExtractionError: src/comfy_sdk/models.py: unrecognized public class(es) RouterRunResult— the extractor requires every top-level class in that module to be one of the two namespace classes, and the Python SDK added a result dataclass. Verified pre-existing: it fails identically onmain(run 35496875584), and the script reads the Python SDK's live default branch over HTTPS, so it went red with no commit to this repo and touches no file this PR changes. It is not a required check (the required set isTest (node 22),Test (node 24),build-check,public-repo-hygiene), so it does not gate merge — but while it is red the cross-SDK drift check is checking nothing. Fixing it is not blind-shippable: the same Python change addedrun_detailedto both namespace classes and this SDK has norunDetailed, so the refreshed snapshot forces the asymmetry decision above. It is now owned by its own PR and is not this PR's to carry: #167 (fix(parity): track the Python SDK's refactors, and retry the sdk-parity fetch) teaches the extractor thatRouterRunResultis a result record rather than a third namespace class, retries the fetch, and writes down the asymmetries the refreshed snapshot surfaced. Re-confirmed pre-existing on this pass:main's own latest CI run fails with the identicalExtractionError, and this PR changes no file the check reads.RequestHandle.collect()always JSON-parses the result and returnskind: "json", althoughgetRouterModelRequestResult's200declares a binary arm. This has been true onmainsince #133 and is not introduced here. Deferred to a follow-up (agent-ok).200that the SDK then refuses (response_too_large, a body-read timeout, an empty or non-JSON body) throws aComfyErrorwith no cost on it. CarryingcreditsUsedonComfyErroris a public error-surface decision shared with the Python SDK. Deferred to a follow-up that needs a design call (noagent-ok).200has a field on the result union" would fail today: post-#166 the200declares 9 headers and the union surfaces 4. Writing it means deciding, for the other five (Idempotent-Replayed, the threeX-Committed-Spend-*,X-Content-Type-Options), whether each belongs on the result or on a documented exclusion list — a wider design call, and one entangled with theIdempotent-Replayedfollow-up above. When first written this note added that such a test would not even have covered this change, sinceX-Comfy-Credits-Usedwas not in the vendored spec; #166 has now landed it, so it would. The pin above remains the narrow version that could be written without making the wider call.nullmeaning "not reported".RouterCreditsUsedHeadertypes itstringwith the example"12.5"and does not fix its scale, so the TSDoc tells callers not to round it. That is still a spec rather than an observation. This change was verified against a local stub rather than a live Router. If the real header carries something other than a plain decimal string,creditsUsedhands it back verbatim rather than mangling it, which is the failure mode thestringtyping was chosen for.spec/router-openapi.yamland carries sanity assertions so it cannot pass vacuously.Provenance
7b95356, which already containsorigin/mainat4491e4b, so the branch and the merged result are the same tree.pnpm lint,pnpm format:check,pnpm typecheck,pnpm build,pnpm check:spec-drift(both checks OK) and the public-repo hygiene script are clean;pnpm test: 654 passed / 0 failed / 5 skipped.7b95356: all four required checks (Test (node 22),Test (node 24),build-check,public-repo-hygiene) pass.sdk-parityfails with the sameExtractionError(RouterRunResult) thatmain's own scheduled runs show. It is not a required check, and #167 owns the fix.creditsUsedfromcollect()failstscagainstBuiltRunResult. Adding the credits header to the queued200reddens the queued rot guard. The contract readers throw on an unknownoperationIdor a missing200.parseFloat("0.42, 0.42")is0.42,Number("0.42, 0.42")isNaN, and the suggested pattern accepts"12.5"and rejects the joined value.mainwas required. #166 landedRouterCreditsUsedHeader, which reddened the original rot guard only in the merged result, so the guard became a real pin on the run200.subscribe's TSDoc promises the same shape asrun, not an identical result.handle.get()/collect()still return the publicRunResult.BuiltRunResultstays internal. The TSDoc claims no fixed decimal scale.200does have a cost. Queued spend is reconciled against the workspace ledger, not by callingrunagain.ComfyError(needs a design call). The staleparseDroppedParamscomment belongs to #168.Summary by CodeRabbit
New Features
creditsUsedfrom the result response. Queued request results expose the field asnull."0"; missing or blank values are represented asnull.Documentation
🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.