mirror of
https://github.com/Comfy-Org/comfy-typescript-sdk.git
synced 2026-11-04 23:47:27 +00:00
[PR #164] [MERGED] feat(models): surface Idempotent-Replayed as RunResult.replayed #165
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#165
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/164
Author: @mattmillerai
Created: 9/19/2026
Status: ✅ Merged
Merged: 10/9/2026
Merged by: @mattmillerai
Base:
main← Head:matt/be-15940-idempotent-replayed📝 Commits (3)
79a07dffeat(models): surface Idempotent-Replayed as RunResult.replayed0b22b6cMerge origin/main into matt/be-15940-idempotent-replayedc8a0e84Merge remote-tracking branch 'origin/main' into matt/be-15940-idempotent-replayed📊 Changes
9 files changed (+287 additions, -7 deletions)
View changed files
📝
CHANGELOG.md(+13 -0)📝
scripts/router-route-contract.mjs(+48 -0)📝
src/sdk/modelRequests.test.ts(+26 -0)📝
src/sdk/modelRequests.ts(+12 -1)📝
src/sdk/models.test.ts(+88 -0)📝
src/sdk/models.ts(+61 -3)📝
src/sdk/router-spec-contract.test.ts(+30 -0)📝
src/sdk/surface-parity.test.ts(+4 -1)📝
test/support/router-stub-server.ts(+5 -2)📄 Description
ELI-5
When you retry a model call under the same
Idempotency-Key, Router does not run the model again — it hands you back the answer it already has, and does not charge you a second time. The answer looks identical to a fresh one: same body, same status, same headers. The only thing that says "this is a replay" is a response header,Idempotent-Replayed, and until now this SDK read it for nobody.So a caller summing up what its runs cost had no way to tell a replayed answer from a fresh charge, and would double-count it. Now every
comfy.models.runresult carriesreplayed?: boolean(always set by the SDK; optional only for source compatibility, likecreditsUsed).truemeans "you already paid for this one — skip it".What changed
RunJsonResult.replayed/RunBinaryResult.replayed(src/sdk/models.ts), read infinish()next toservingProvider/droppedParamsand set on all three return sites (binary, no-Content-Typebinary, json). Derived from the header's presence via a newparseReplayed, not from its value — Router omits the header on a fresh run rather than sendingfalse, and the vendored contract says so in as many words ("branch on its presence"). Same derivation the Python SDK'sRouterRunResult.replayeduses (headers.get(...) is not None).RequestHandle.get()(src/sdk/modelRequests.ts) reads the same header into the same field. It isfalseon every real response — see the verification below — and theget()TSDoc now says to deduplicate re-collection byRequestHandle.requestIdrather than byreplayed. Kept as a header read rather than a hard-codedfalsefor the same "one union, both paths" reason the two alt-provider headers are read there.IDEMPOTENT_REPLAYED_HEADER(src/sdk/router-spec-contract.test.ts, backed by a newrunSuccessHeadersreader inscripts/router-route-contract.mjs): the run route's200must declare a header spelled exactly that, resolving toRouterIdempotentReplayedHeader, withrequired: false. All three matter, because this constant's drift is silent in both directions — a name wrong by one character reportsreplayed: falseon every replay forever (indistinguishable from the fresh charge the field exists to tell a replay apart from), and a contract that made the headerrequiredwould mean its arrival no longer marks anything andparseReplayedwould have to read the value instead. The failure messages say which.src/sdk/surface-parity.test.ts): a newpythonMethodsSubsumedByTypescriptfield onAsymmetry, with["run_detailed", "runDetailed"]under the existingresult-envelopeentry (itswhyextended rather than a new entry added). Those names are filtered off the Python side of themodelsname comparison only, and a new rot guard fails the day this SDK grows arunDetailed— the mirror image of the existing lead guards, which fire when Python catches up.CHANGELOG.mdentry under Unreleased / Added.Verification
pnpm lint,pnpm format:check,pnpm typecheck,pnpm build,node scripts/check-spec-drift.mjsandnode scripts/check-public-repo-hygiene.mjsall clean;pnpm testis 649 passed / 5 skipped, 27 files.Break-and-restore, all three restored green:
replayedfrom the binary return site infinish()→tscfailsTS2322naming the missingreplayed. (Run at79a07df, when the field was required on the interface. After the merge withmainthe same guarantee comes fromBuiltRunResult, whichfinish()andRequestHandle.get()return and which requiresreplayed.)idempotentReplayed = trueon the fresh-run test →AssertionError: expected true to be false.runDetailedto the method list the parity guard reads → it fails with its own message,comfy.models.runDetailed` now exists — delete the subsumed entry for `run_detailed.Against the artifacts, not just the diff. Two claims in this change are about things outside it, so both were checked rather than assumed:
spec/router-openapi.yaml:getRouterModelRequestResult's200declaresX-Comfy-Request-IdandX-Content-Type-Optionsand nothing else. SoRequestHandle.get()'sreplayedgenuinely readsfalse, and the TSDoc's "deduplicate byrequestId" is the real advice rather than a hedge.run_detailedadds nothingrundoes not already return." This is the one capability-denial-shaped claim in the diff, so it was falsified empirically rather than unit-tested: every publiccomfy.modelsmethod was enumerated live (handle, list, run, schema, submit, subscribe— norunDetailed), and a real stubbed run's field set was compared againstRouterRunResult's five attributes read off the Python SDK's default branch.output → data,serving_provider → servingProvider,dropped_params → droppedParams,replayed → replayed,request_id → requestId— all five present. The capability is reachable, viarun; nothing is being dead-ended. Note that before this change the claim would have been false:replayedwas the one fieldrundid not carry.The half this does not fix, measured. The vendored contract declares
Idempotent-Replayedon 6 operation/status pairs across three operations. This change surfaces it on 1 of the 6 —runRouterModel200, both content arms. The other five are all on paths deliberately out of scope; they are named individually under Residual.pnpm check:sdk-parityis red at the current head, and that failure is pre-existing and not caused by this branch. The earlierRouterRunResultExtractionErrorwas fixed onmainby #167. What fails now is newer upstream drift: the Python SDK's default branch gainedModels.list/Models.schema, soparity/python-surface.jsonis stale.main's own CI fails the same job (run 37967760042, at8096086). The fix is #201, which refreshes the snapshot and retires thediscovery-methods-land-first-in-typescriptlead. This branch touches neither the snapshot nor that entry, so it should go green once #201 lands and is merged in.Judgment calls
mainafter #157 (creditsUsed) landed. That PR added a field to the same two interfaces and the same result literals. The merge keeps both fields side by side at every return site, in therouter-route-contract.mjsreader and in the spec-contract pins.replayedis now OPTIONAL on the published interfaces. This reverses the original choice to make it required. #157 set the precedent:0.4.0shippedRunJsonResult/RunBinaryResultwithout either field, so a required field would break a consumer's own hand-built result literal.replayed?: booleanfollows that precedent. #157's internalBuiltRunResultnow requires bothreplayedandcreditsUsed, sotscstill names any SDK return site that leaves one out. A hand-built literal that omits it reads asundefined, which is falsy, so it reads as "not a replay".src/sdk/index.ts, on purpose. The plan asked forIDEMPOTENT_REPLAYED_HEADERandparseReplayedto be re-exported from the package root "mirroring howDROPPED_PARAMS_HEADER/parseDroppedParamsare exported". They are not:git grep DROPPED_PARAMS_HEADER src/sdk/index.tsreturns nothing, and neither isFALLBACK_PROVIDER_HEADER, nor (in #157)CREDITS_USED_HEADER. Every Router disclosure header constant and parser is deliberately module-internal — exported frommodels.tsfor the tests and the sibling module, and kept off the published surface. Adding one and not its two siblings would be an odd asymmetry, and a package-root export is a permanent public-API commitment that is far cheaper to add later than to remove. So the precedent was followed over the instruction. Say the word and it is a three-line addition.runSuccessHeadersreader returns{component, required}per header name. #157'srunSuccessHeaderNamesreader, now onmain, sits beside it and returns lowercased names only. That is enough for the credits pin but not for this one. Both readers are kept, and they do not conflict. Collapsing them into one would be a tidy follow-up.Residual
Not fixed by this change, and each one actionable on its own:
replayed.finish()computes it before the!response.okthrow, buterrorFromResponsedoes not receive it, so a replayed400 content_policy_violation— which Router records and replays like any other answer it actually produced, and which some models meter — reaches the caller as an ordinary error with nothing saying it was not charged twice. The contract declares the header onrunRouterModel's400and422. This belongs with the error-path credits work rather than here, because both want the same plumbing throughComfyError.submit()does not surface it.submitRouterModelRequest's201declaresIdempotent-Replayedtoo: a replayed submit returns the ORIGINAL handle rather than queueing a second generation.RequestHandleis not aRunResult, so areplayedon the handle is a separate small piece of design (where does it live — a field, or a property of the submit call?) rather than a line of this diff.422declarations are untouched on all three operations (runRouterModel,submitRouterModelRequest,getRouterModelRequestResult), via the sharedRouterModelValidationErrorresponse. Same category as the error path above. These plus the two items above are the 5 of 6 declared sites this change does not cover.RequestHandle.get()'s TSDoc statesreplayedis alwaysfalsethere, and that was verified against the vendored contract by hand (above) — but no test fails the day a sync addsIdempotent-ReplayedtogetRouterModelRequestResult's200. The code would start reporting it correctly; only the documentation and the "deduplicate byrequestId" advice would go stale. An inverse pin inrouter-spec-contract.test.ts, shaped like the rot guards already in that file, would close it. Left out to keep this diff to what was asked for.Comfy-Org/comfy-python-sdk's default branch,RouterRunResult.replayednow readsheaders.get("Idempotent-Replayed") is not None(itsmodels.py), the same bare spelling Router sends and the vendored contract declares; the open PR referenced here has since merged. So this change mirrors Python's shipped behaviour rather than an anticipated one, and a live comparison of the two SDKs should now match rather than diverge.pnpm check:sdk-parityis still red, on base drift rather than this change. TheRouterRunResultextractor failure this bullet used to describe was fixed by #167. The red job now is the stalelist/schemasnapshot described under Verification, fixed by #201. The parity allowlist entry added here is written so it stays correct both before and afterparity/python-surface.jsonis refreshed. The new guard asserts on the TypeScript side only and deliberately does not assert that the Python snapshot still containsrun_detailed.Provenance
mainmerged in at8096086):pnpm lint,pnpm format:check,pnpm typecheck,pnpm buildandpnpm check:spec-drift(both checks) are clean.pnpm testat the current head: 697 passed, 5 skipped (28 test files passed, 1 skipped).sdk-parityfails on stale-snapshot drift thatmainfails identically. The fix is #201 (see Verification).79a07dfand restored green (see Verification).replayedis optional rather than required, to match #157's source-compatibility precedent (see the judgment call above).🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.