mirror of
https://github.com/Comfy-Org/comfy-swift-sdk.git
synced 2026-11-04 23:47:26 +00:00
[PR #84] feat(router): surface X-Comfy-Credits-Used on the Router run result #85
Labels
No labels
enhancement
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-swift-sdk#85
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-swift-sdk/pull/84
Author: @mattmillerai
Created: 9/18/2026
Status: 🔄 Open
Base:
main← Head:matt/be-15589-router-credits-used📝 Commits (4)
25cda28feat(router): surface X-Comfy-Credits-Used on the run resultdd32a09Merge remote-tracking branch 'origin/main' into matt/be-15589-router-credits-used6aedbaefix(router): harden X-Comfy-Credits-Used read and wire it through the queue result11fc53efix(router): trim edge tabs from X-Comfy-Credits-Used; reject malformed spec headers📊 Changes
9 files changed (+370 additions, -9 deletions)
View changed files
📝
.gitignore(+2 -0)📝
README.md(+2 -0)📝
Scripts/contract/check_router_contract.py(+89 -1)📝
Sources/ComfySwiftSDK/Internal/RouterErrorMapping.swift(+63 -6)📝
Sources/ComfySwiftSDK/Internal/RouterQueueTransport.swift(+2 -1)📝
Sources/ComfySwiftSDK/Internal/RouterTransport.swift(+2 -1)📝
Sources/ComfySwiftSDK/Public/RouterModels.swift(+30 -0)📝
Tests/ComfySwiftSDKTests/RouterErrorMappingTests.swift(+78 -0)📝
Tests/ComfySwiftSDKTests/RouterRunTests.swift(+102 -0)📄 Description
ELI-5
Router tells you what a model run cost, in a response header. The Swift SDK was throwing that away, so an app had to keep its own price list to show a user what they just spent. Now the run result carries it:
result.creditsUsed.What changed
RouterRunResult.creditsUsedsurfacesX-Comfy-Credits-Used, lifted inRouterErrorMapping.successMetadataright alongside the existingX-Comfy-Request-Idread so both go through the same case-insensitive header normalisation rather than a second copy of it in the transport.Type.
String?, not a parsedDouble?. The wire value is a decimal at up to two places and binary floating point is the wrong type for a figure a caller reconciles against money; a caller that needs arithmetic checks the whole value is a plain decimal and then usesDecimal(string:). The check comes first because that initializer parses a prefix rather than returningnil:"1,25"reads as1, and a duplicated header comma-joined as"1.25, 2.50"reads as1.25.Hygiene, and the one judgment call in the diff. The value is handed over as it arrived, trimmed — unparsed and unvalidated, because deciding that an unrecognised figure was never reported is a stronger claim than this SDK is entitled to make (so
"1,25"is reported, not dropped). The two cases it does refuse are the ones where handing the value over faithfully is impossible, and both are refusals rather than repairs, because every repair invents a number: over a 32-scalar bound it is dropped rather than truncated (1000clipped to10is a plausible figure that is wrong by two orders of magnitude), and a value carrying a control character, a line break or an invisible format (Cf) scalar such as a bidi override is dropped rather than scrubbed. That check runs on the value as received, before only HTTP optional whitespace is trimmed, so\n100is refused rather than trimmed into100(tab is the one exception: it is optional whitespace, so it is trimmed at the edges and refused only inside the figure) —requestId's.substitution is right for an opaque id and would turn1\n2into an entirely credible1.2here. That second rule doubles as the log-injection guard for this header, since the value reaches the caller and the result'sdescription, where it is rendered quoted and escaped so a value like0, replayedcannot forge thereplayedmarker.Replay flag on the success path.
successMetadatanow parsesIdempotent-Replayedwith the sameisTruerule as the error path, instead of taking presence astrue. A proxy'sfalseor a blank value no longer reports a charged run as a free replay, which would also explain away a missingcreditsUsed.Queued delivery.
client.modelsqueued delivery (#77) also buildsRouterRunResulton its result fetch, so that path passescreditsUsedthrough as well.Contract check.
Scripts/contract/check_router_contract.pygains a fourth check: once the vendored spec declares a credits header onrunRouterModel's200, its name must matchRouterErrorMapping.creditsUsedHeader. Until then it prints a notice and passes, the same way the queue-route check stands down.Docs. The three caveats that actually bite are on the property and in the README: it is a price and not a settled ledger entry (the workspace's usage and invoices stay authoritative);
nilmeans not reported and never free — the header covers a subset of providers, and a replay is not stamped with it at all, so areplayed == trueresponse reports no figure even though the original call had a cost; and"0"is a real reported cost, so the test is presence (if let creditsUsed), never a non-zero value. The replay caveat is not in the ticket's list — it comes from the public Comfy Router API reference and limitations pages, which both state the header is optional and is not replayed.How this was tested
swift buildclean;swift test --no-parallel378 tests / 32 suites green;Scripts/contract/check_router_contract.pyin sync with the vendored spec. Six new tests: the header reading through verbatim (and into the redacteddescription), read case-insensitively,nilon an unstamped run,"0"staying distinguishable from absent whileDecimal(value ?? "0")collapses both — which is the point of branching on presence — plus the mapping-level trim / blank / over-cap / control-character / U+2028 cases.One hazard found reviewing my own tests and fixed before pushing: the first draft of the
"0"-vs-absent test installed the stub twice with anuninstallbetween the two runs.TestURLProtocol.installtakes a blocking mutex that onlyuninstallreleases, so a firstrunthat threw between them would have left it held and deadlocked every later test in the suite. Both answers now come from one scripted install.No live Router call was made: everything is verified against
TestURLProtocolstubs, which is the surface this SDK's tests have.Residual
The other two Router disclosure headers are deliberately not in this PR.
X-Comfy-Router-Fallback-ProviderandX-Comfy-Router-Dropped-Paramsare also unsurfaced here while comfy-typescript-sdk carries both (FALLBACK_PROVIDER_HEADER/DROPPED_PARAMS_HEADER, exposed asservingProvideranddroppedParams) and comfy-python-sdk carries them too. I chose to file rather than fold them in, for two reasons:Dropped-Paramsis a parsed list in the sibling SDKs rather than a passthrough string, and naming (servingProvidervsfallbackProvider, where the latter is already a request option) is a public-API decision worth its own review; and neither header is declared in this repo's vendoredspec/router-openapi.yaml, so their doc comments would be lifted from a sibling implementation rather than from the contract. Follow-up work: surface both onRouterRunResultwith the list parse and the naming settled, and reconcile the doc comments against the spec once it declares them.The vendored spec does not declare
X-Comfy-Credits-Usedeither, so the doc comment is not lifted from it. The contract check above now pins the name automatically once a sync declares it.spec/router-openapi.yamlat this commit (synced in #76) contains no credits header anywhere — the ask was to doc-comment the property from the vendored spec and that was not possible. The wording comes from the two public docs pages linked above plus the caveats named in the work item. When a spec sync lands the declaration, the property's doc comment and the README paragraph should be re-read against it; the contract script now catches a header name drift on that sync, but not a change in the header's documented semantics.A charged failure still reports no cost.
RouterErrordoes not carry this header, and a partner refusal that really ran — the metered content-policy rejection the contract describes as an answer rather than a Router-level refusal — can be charged. A caller reconciling spend across failures, not just successes, still has that gap. Same five lines, on the error path; out of scope here because the work item scopes the property to the run result.Unexercised artifacts. The upstream server-side change that ships this header, and the work item tracking the header's own coverage, are both outside this repo and could not be read from this environment; the header's name, its decimal-string-to-2dp shape, the
"0"-vs-absent distinction and the not-replayed rule were all corroborated against the public docs pages instead. Coverage of the header in production (a run whose cost is known but which carries no header) is server-side and explicitly not in scope.One claim in the work item did not hold up. It describes this SDK as lifting the least of the three and implies the Python and TypeScript SDKs already surface credits. Code search over both repos' default branches returns 0 hits for
X-Comfy-Credits-Used(against 2 repos carrying the other two headers), so this is the first of the three SDKs to surface it rather than the last to catch up. Code search can lag a very recent merge, so treat that as evidence rather than proof — it changes nothing about this change, but the parity framing is worth correcting before it is repeated.Provenance
swift buildclean;swift test --no-parallel378 tests in 32 suites passed.mainmerge plus two review-fix rounds): no Swift toolchain on the resolving host, soswift build/swift testwere not run locally. CI's macOSBuild & testjob is the verification for this head; the previous head failed it only on the edge-tab credits case, which this head fixes.Scripts/contract/check_router_contract.py: OK on the current head (credits check stands down with a notice). Its armed path was tested against a locally patched spec, and its malformed-input path was exercised directly:headers: []/nullon the200now fail.Decimal(string:)prefix-parsing was fixed by correcting the docs, not by validating the value.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.