mirror of
https://github.com/Comfy-Org/comfy-python-sdk.git
synced 2026-11-04 23:37:47 +00:00
[PR #192] feat(errors): expose refusal_subject on RouterError / ContentPolicyViolation #192
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#192
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/192
Author: @mattmillerai
Created: 9/25/2026
Status: 🔄 Open
Base:
main← Head:matt/be-17175-refusal-subject📝 Commits (5)
c8ec6f6feat(errors): expose refusal_subject on RouterError / ContentPolicyViolation9db6df3fix(errors): bound refusal_subject to one printable token1d07c83chore: merge main into matt/be-17175-refusal-subject0372265Merge remote-tracking branch 'origin/main' into matt/be-17175-refusal-subjecta7c3588test(router): pin refusal_subject against the synced Router spec📊 Changes
8 files changed (+369 additions, -2 deletions)
View changed files
📝
CHANGELOG.md(+6 -0)📝
src/comfy_low/errors.py(+38 -0)📝
src/comfy_low/transport.py(+3 -0)📝
src/comfy_sdk/exceptions.py(+1 -0)📝
src/comfy_sdk/router_exceptions.py(+60 -1)📝
tests/conftest.py(+9 -1)📝
tests/test_router_exceptions.py(+184 -0)📝
tests/test_router_spec_contract.py(+68 -0)📄 Description
ELI-5
When the Router refuses a model call on content-policy grounds, the SDK raised
ContentPolicyViolationbut could not tell you what was refused — your prompt, your image, or the model's output. The Router can now say which one (input_image,output_text, ...). This PR reads that and puts it on the exception asexc.refusal_subject.What changed
RouterError.__init__gainsrefusal_subject: str | None = None, stored as.refusal_subject. It is never narrowed to the documented list, the same policyerror_typefollows, so a subject added after this version was built still reaches the caller. It is bounded, though: every read goes through a sharedcomfy_low.errors.clean_refusal_subject, which full-matches one token of[A-Za-z0-9_.-]{1,64}. A duplicated header (which httpx joins as"a, b"), control or bidi characters, or an overlong value reads asNoneinstead of reaching a log line verbatim. Same name as the TypeScript SDK'srefusalSubject/refusal_subject.comfy_sdk.router_exceptions:REFUSAL_SUBJECT_HEADER = "X-Comfy-Refusal-Subject"andREFUSAL_SUBJECTS, which lists the ten documented values (input,output,input_{text,image,video,audio},output_{text,image,video,audio}). Both are added to__all__.error_from_responsereads the header first (case-insensitively; a blank or malformed value counts as absent) and falls back tobody["refusal_subject"]. A non-string body value becomesNone.models.runpath does not go througherror_from_response. It goes transport →comfy_low.ApiError→comfy_sdk.exceptions.to_sdk_error. If only the factory had changed,exc.refusal_subjectwould always beNoneon the main call path. SoApiErroranderror_from_envelopenow also acceptrefusal_subject, using the same header-then-body order. The transport passes the header, andto_sdk_errorforwards the value onto theRouterError.error_from_completion(the queued surface) readsrefusal_subjectfrom the completed payload only. The poll and result responses do have headers, but only the body reaches this function, so a subject sent only on the header reads asNoneon the queued path.ServerStateknobs,model_run_error_headersandmodel_run_error_body_extra, for Router-shaped failures.[Unreleased] / Addedentry.Tests cover: header wins over body; body only; absent; blank header falls back to body; non-string body value; unknown value passes through unchanged (header and body); malformed values (duplicated, ANSI, newline, bidi, overlong) read as
Noneon the header, body and completion surfaces, and a malformed header falls back to the body; the 422detail[]validation shape never sets it; the queued completion; and full sync and asyncmodels.runruns against the stub server (header-wins, body-only, absent, hostile-header).tests/test_router_spec_contract.pypinsREFUSAL_SUBJECTS(values and order) against both places the vendored Router spec states the vocabulary —RouterErrorResponse.refusal_subjectandRouterRefusalSubjectHeader— and pinsREFUSAL_SUBJECT_HEADERto the name the spec's responses send that header under.Judgment calls
models.runpath and the queued completion, for the reason given above.RouterError, as the ticket asked. It is not limited toContentPolicyViolation. If the Router ever sends the header with another bucket, the value passes through rather than being dropped.Provenance
cddc298merged in; theCHANGELOG.mdconflict resolved by keeping both main'scredits_usednote andQueueBacklogFullentry and this PR's entry):ruff check .,ruff format --check .andmypy srcare clean.pytest: 1294 passed, 9 skipped (the gateway e2e suite skips because no credentials are set).scripts/check_drift.pyandscripts/check_public_repo_hygiene.py: OK.a7c3588): all checks green, required and optional.Residual
refusal_subjectandX-Comfy-Refusal-Subject. The header spelling, the body field name and all ten values (in order) match what this PR shipped, so the tripwire was replaced with the real pins described above. The spec states the vocabulary in prose rather than as anenum, so the pin parses the backticked list afterclosed vocabulary:; a rewording that moves it fails loudly.refusalSubject/refusal_subject), but the TypeScript SDK'srouterErrors.tswas not opened.Summary by CodeRabbit
None.🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.