mirror of
https://github.com/Comfy-Org/comfy-swift-sdk.git
synced 2026-11-04 23:47:26 +00:00
[PR #68] [MERGED] feat(router): add client.models.run with idempotency, timeout and collect-loop semantics #69
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#69
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/68
Author: @mattmillerai
Created: 9/11/2026
Status: ✅ Merged
Merged: 9/13/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-10180-router-models-run📝 Commits (10+)
a7015f5feat(router): add client.models.run with idempotency, timeout and collect-loop semanticsa67299cfix(router): keep response-controlled text out of logs and tighten two edgesa5d6521fix(router): bound the whole call, and refuse inputs that cannot bound it7311f63fix(router): refuse redirects on the run route, and stop re-sending into a budget too small to answer inc5f858dfix(router): refuse userinfo in the base URL, and bound the request built after applyAuth7a27246fix(router): close the six open review findings on the run surface9faebc0fix(router): make the attempt cap a per-run budget and close the remaining retention gaps213dcfcfix(router): bound retained validation content, and hold the attempt cap on the 401 path2c90aacfix(router): stop the description trapping, and make the retention bounds true on every axis6d7bffdfix(router): close two process-kill paths and finish the log-safety bounds📊 Changes
11 files changed (+3382 additions, -42 deletions)
View changed files
📝
README.md(+38 -9)📝
Scripts/contract/sdk-endpoints.yml(+7 -0)📝
Sources/ComfySwiftSDK/Internal/RouterErrorMapping.swift(+281 -24)➕
Sources/ComfySwiftSDK/Internal/RouterTransport.swift(+838 -0)📝
Sources/ComfySwiftSDK/Internal/SDKLog.swift(+67 -0)📝
Sources/ComfySwiftSDK/Internal/Transport.swift(+30 -3)📝
Sources/ComfySwiftSDK/Public/ComfyCloudClient.swift(+30 -1)📝
Sources/ComfySwiftSDK/Public/RouterError.swift(+91 -5)➕
Sources/ComfySwiftSDK/Public/RouterModels.swift(+240 -0)📝
Tests/ComfySwiftSDKTests/RouterErrorMappingTests.swift(+279 -0)➕
Tests/ComfySwiftSDKTests/RouterRunTests.swift(+1481 -0)📄 Description
ELI-5
The SDK could already run a ComfyUI workflow graph on Comfy Cloud. It could not run a partner model — "give me a cat picture from
bfl/flux-2-pro" — even though Comfy Router exposes exactly that as a single HTTP call. This PR addsclient.models.run("bfl/flux-2-pro", input: [...]). One request goes out, it waits (up to 11 minutes) for the model to answer, and it hands you back the model's own JSON. The tricky part is that image generation costs money, so every call is stamped with an idempotency key: if the connection drops or Comfy stops holding the line, asking again with the same key collects the generation you already paid for instead of starting — and billing — a second one. Most of the code here is about getting that "ask again" rule exactly right, including the three specific server answers that mean "still running, ask me again" and the many that mean "stop".What changed
New public surface —
Sources/ComfySwiftSDK/Public/RouterModels.swiftRouterRunResult:data(the model's native JSON output byte-for-byte),output(a read-onlyRouterJSONview —result.output["images"][0]["url"].stringValue),requestId,idempotencyKey,replayed, anddecode(_:using:).RouterModels:baseURL,run(_:input:idempotencyKey:timeout:), anddefaultTimeout = 660— deliberately a minute above Router's own 10-minute server deadline, so the client never gives up first and turn a504 deadline_exceededthe collect loop handles into a client-side timeout whose outcome nobody knows.ComfyCloudClientgainspublic let modelsplus an optionalrouterBaseURL:on its designated init. The existing signature keeps working (every pre-existing test compiles and passes unchanged).New transport —
Sources/ComfySwiftSDK/Internal/RouterTransport.swift(actor)Posts once and collects. It re-sends the same bytes under the same key for exactly the three status/bucket pairings the vendored contract says a re-send collects —
429,409 concurrency_limit_exceeded,504 deadline_exceeded— and each only when the response carried aRetry-Afterthat still fits inside the caller's deadline. It never re-sends on409 invalid_input(the key is consumed and unreplayable — re-sending just loops on the same refusal), on a4xxwith noRetry-After, on a5xxoutside that pairing, or after a transport failure or client-side timeout, whose outcome is unknown and where re-sending blind is the caller's call to make.Small
Transportrefactor.applyAuth(to:)andwithAuthRetry(perform:)widen fromprivatetointernal(translate(_:)was already internal) soRouterTransportreuses the one implementation of credential injection, proactive refresh and the 401-refresh-retry. No behaviour changed and nothing was duplicated; the widening is additive and the existingRefreshOn401Testsstill pass untouched.One defect found and fixed during self-review
The ticket's flow — persist the key, relaunch, call
run(..., idempotencyKey:)again to collect — was broken as originally written, and not subtly.Swift seeds
Dictionaryhashing per process, so serialising the same[String: Any]emits a different key order in every launch. Measured on this machine across three runs of the same input:Router refuses a re-used key whose request differs with
409 invalid_inputrather than replaying it — so the recovery flow would have presented the same key with reordered bytes and been refused, in precisely the case it exists for. Serialisation is now.sortedKeys, which makes the bytes stable across launches. JSON object key order carries no meaning, so no provider can observe the difference. Locked in byserialisation_is_key_order_stable.Negative-claim falsification — the three-segment model ID
This diff adds a capability denial (
"a/b/c"is refused), so per the falsification discipline I went looking for the capability rather than asserting its absence. Every path reachable from this repo:spec/router-openapi.yaml):/v2/models/{provider}/{model},operationId: runRouterModel— exactly two path parameters.grep -n variant spec/router-openapi.yamlreturns 4 hits, all inside human-readabledescriptionprose; there is novariantpath parameter, novariantschema and novariantfield on any catalog entry.GET /v2/models— cannot emit one.RouterModelIdispattern: ^[a-z0-9]+([._-][a-z0-9]+)*/[a-z0-9]+([._-][a-z0-9]+)*$,maxLength: 193(= the provider segment's 64 + the model segment's 128 + one separator). A three-segment ID is structurally unrepresentable there, so the catalog can never hand a caller one to pass back in.RouterModelSegment,RouterProviderSegment) forbid/inside a segment, so a variant cannot be smuggled into either one.client.submit(_:), is the ComfyUI workflow contract on a different host and does not address models by Router ID at all — it is not an alternative route to the same capability.The
[/{variant}]form in the spec's prose is therefore not addressable on this route today. Critically, the denial redirects rather than dead-ends: it names the variant form and points at the two-segment ID the catalog lists. And it is self-invalidating — if Router ever adds a variant path parameter,check_router_contract.py's route pin fails CI, which is the signal to revisit this branch.Limit on that evidence: I have no Router credential and no network path to
api.comfy.orgfrom this environment, so no live call was made. The falsification above is against the vendored contract, which is the only authority this SDK has and the same one CI pins to — but it is a document, not the running service.Judgment calls (deviations from the plan as written)
ComfySDKInfo.version→0.5.0, not0.4.0. The plan asked for0.4.0; the tree was already at0.4.1, and0.4.0is an existing released tag, so that would have been a downgrade onto a shipped version. This PR adds public API, so a minor bump is the right move. The README install pin moves with it..other("invalid_model_id")and for the variant case to be "refused with a message that names the variant form", which conflicts —ServerRejectionReason.otheris documented as carrying a stable machine identifier, so a prose sentence does not belong in it. The variant case throws.other("invalid_model_id_variant_unsupported"), which names the form in a machine-stable way and is prefixed with the general identifier so a caller branching withhasPrefix("invalid_model_id")still matches both. Every other malformed shape throws.other("invalid_model_id")exactly as specified.routerBaseURL:defaults toRouterModels.defaultBaseURL, notRouterConstants.defaultBaseURL. Swift forbids an internal declaration in a public function's default argument.RouterModels.defaultBaseURLis a public re-export of the internal constant, socheck_router_contract.pystill pins the single source of truth to the spec'sservers[0]; a test asserts the two are equal.Retry-After: 1, not0. The plan'sRetry-After: 0would not exercise a re-send at all: the contract declares a minimum of 1, and the already-shippedRouterErrorMappingdeliberately reads anything below it as "no advice" (retryAfter == nil) so an unusable value can never become an immediate retry.1is the smallest value that exercises the path, which costs those three tests about a second each of real sleeping. That is the sleep under test.Verification
swift build— clean.swift test --no-parallel— 305 tests in 32 suites passed, 0 failed (24 of them new inRouterRunTests).ImportBoundaryTestsandLoggingPrivacyTestsboth still pass; the newSDKLogrouter category never takes the key, the body or a credential as a parameter, so none can reach a log line.python3 Scripts/contract/check_router_contract.py— OK, 15 error types and the bound route in sync with the spec.python3 Scripts/contract/check_contract.py --spec <ComfyUI master openapi.yaml> --manifest Scripts/contract/sdk-endpoints.yml— 6 ok, 0 warnings, 0 errors, so the newexcluded:entry did not disturb the ComfyUI gate.swiftc -typecheckagainst the built module, clean. One note — the snippet as specified needsimport Foundationalongsideimport ComfySwiftSDKforProcessInfo, which the SDK does not re-export. The README copy includes it so a reader can paste it and have it build.Residual
spec/router-openapi.yamland aURLProtocolstub. The contract is the same artifact CI pins to, but it is a document: the response shapes are asserted, the running service's behaviour is not. Anyone with a Router credential should run the acceptance snippet againstapi.comfy.orgonce before this is relied on in the iOS app — in particular to confirm that a real504 deadline_exceededcarries theRetry-Afterthe collect loop requires, since a504without it is deliberately terminal here and would surface to users as a failed run rather than a collected one.parseModelIdwas not read. The plan says the validation "mirrors" it; that repo is not in this sandbox, so the rules here were derived from the vendored spec's ownRouterModelId/RouterModelSegment/RouterProviderSegmentpatterns instead. If the two implementations disagree, the spec is the tiebreaker and this is the one to re-check — most likely around whether the TS side rejects or silently truncates a third segment, and whether it enforces the schemas' lowercase-only^[a-z0-9]+([._-][a-z0-9]+)*$pattern, which this implementation deliberately does not (it percent-encodes and lets the server reject, so a catalog addition that widens the pattern is not blocked client-side by a stale copy of it).-strict-concurrency=completethetransport.withAuthRetry { ... }call inRouterTransport.swiftwarns "sending value of non-Sendable type … risks causing data races". The package isswift-tools-version: 5.9(Swift 5 language mode), so it is not emitted byswift buildor CI. It is safe in fact — the closure captures only value types plus an actor reference, and its body does nothing but call one actor-isolated method — but it is an error in Swift 6 language mode. There is exactly one other such warning in the package, pre-existing, onTransport.swift:109(normalizeToken), and both sit on the same helper. Whoever migrates this package to Swift 6 mode has to restructure the two together; doing it here would mean reshaping a shared helper every existing endpoint calls, which is out of scope for this PR.RouterInputSerializationErroris internal but escapes through a public API viaComfyError.unknown(underlying:). A caller can read itslocalizedDescriptionbut cannotas?-match the type. That is fine for the case it reports — a non-JSONinputis a programming error fixed in the caller, not a condition to branch on — but if it ever needs branching, promoting it to public is the change.Provenance
swift buildclean;swift test --no-parallel305 passed / 0 failed in 32 suites;check_router_contract.pyOK (15 error types, route in sync);check_contract.py6 ok / 0 warnings / 0 errors; acceptance snippetswiftc -typecheckclean against the built module0.5.0rather than the plan's already-released0.4.0; the three-segment model ID throws a prefixed variant-specific identifier rather than the bare one;routerBaseURL:defaults to the publicRouterModels.defaultBaseURLbecause Swift forbids an internal declaration in a public default argument; collect-loop tests useRetry-After: 1because the contract's minimum is 1 and0exercises no re-send at all🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.