diffusers-workflow 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diffusers_workflow-0.4.0.dist-info/METADATA +318 -0
- diffusers_workflow-0.4.0.dist-info/RECORD +260 -0
- diffusers_workflow-0.4.0.dist-info/WHEEL +5 -0
- diffusers_workflow-0.4.0.dist-info/entry_points.txt +7 -0
- diffusers_workflow-0.4.0.dist-info/licenses/LICENSE +201 -0
- diffusers_workflow-0.4.0.dist-info/top_level.txt +2 -0
- dw/__init__.py +440 -0
- dw/adapter_compatibility.py +226 -0
- dw/arguments.py +1231 -0
- dw/assessment_rules.py +159 -0
- dw/assets.py +130 -0
- dw/cache_blocks.json +16 -0
- dw/cache_blocks.py +146 -0
- dw/community_pipelines/pipeline_flux_rf_inversion.py +1184 -0
- dw/content_types.py +150 -0
- dw/dissolve_frame_errors.py +121 -0
- dw/docs/ACCELERATION.md +352 -0
- dw/docs/AGENT_LOOP.md +95 -0
- dw/docs/DEPENDENCIES.md +91 -0
- dw/docs/IP_ADAPTER.md +109 -0
- dw/docs/LORAS.md +131 -0
- dw/docs/MCP.md +517 -0
- dw/docs/PROMPT_WEIGHTING.md +78 -0
- dw/docs/QUANTIZATION.md +230 -0
- dw/docs/RECIPES_24GB.md +201 -0
- dw/docs/RELEASING.md +195 -0
- dw/docs/REMOTE.md +140 -0
- dw/docs/REPL_COMMANDS.md +121 -0
- dw/docs/REPL_WORKER_GUIDE.md +51 -0
- dw/docs/SECURITY.md +272 -0
- dw/docs/SECURITY_QUICKREF.md +112 -0
- dw/docs/SERVER.md +679 -0
- dw/docs/TASKS.md +1741 -0
- dw/docs/TESTING.md +71 -0
- dw/docs/WORKFLOW_GUIDE.md +2038 -0
- dw/docs/WORKSPACES.md +316 -0
- dw/download_watch.py +335 -0
- dw/elision.py +306 -0
- dw/events.py +275 -0
- dw/for_each.py +409 -0
- dw/host_memory.py +258 -0
- dw/host_memory_projection.py +230 -0
- dw/hub_cache.py +432 -0
- dw/introspection.py +1228 -0
- dw/kernel_availability.py +208 -0
- dw/locations.py +599 -0
- dw/log_setup.py +45 -0
- dw/loudness.py +82 -0
- dw/media_audio.py +217 -0
- dw/media_frames.py +367 -0
- dw/media_info.py +297 -0
- dw/pipeline_processors/chain.py +821 -0
- dw/pipeline_processors/config_objects.py +237 -0
- dw/pipeline_processors/pipeline.py +2297 -0
- dw/pipeline_processors/remote.py +46 -0
- dw/plan.py +920 -0
- dw/previous_results.py +411 -0
- dw/probe_paths.py +59 -0
- dw/prompt_schema.json +48 -0
- dw/prompt_weighting.py +378 -0
- dw/prompts.py +159 -0
- dw/realize.py +250 -0
- dw/reference_limits.py +215 -0
- dw/reference_names.py +125 -0
- dw/repl.py +338 -0
- dw/repl_commands.py +836 -0
- dw/repl_worker.py +159 -0
- dw/result.py +1720 -0
- dw/result_fps.py +82 -0
- dw/run.py +162 -0
- dw/runs.py +768 -0
- dw/scalar_result_validation.py +97 -0
- dw/schema.py +283 -0
- dw/security.py +1038 -0
- dw/select_validation.py +115 -0
- dw/serve.py +277 -0
- dw/server/__init__.py +2 -0
- dw/server/app.py +4586 -0
- dw/server/assess.py +132 -0
- dw/server/catalog_shape.py +487 -0
- dw/server/enhancers.py +129 -0
- dw/server/exports.py +480 -0
- dw/server/guides.py +257 -0
- dw/server/jobs.py +1561 -0
- dw/server/mcp_mount.py +95 -0
- dw/server/netinfo.py +124 -0
- dw/server/observed_cost.py +379 -0
- dw/server/sysinfo.py +71 -0
- dw/server/ui/assets/abap-08VXUWAP.js +1 -0
- dw/server/ui/assets/apex-BWPQTe0t.js +1 -0
- dw/server/ui/assets/azcli-Bc_sGQ0U.js +1 -0
- dw/server/ui/assets/bat-i0X4ZdIN.js +1 -0
- dw/server/ui/assets/bicep-B5-_aFwp.js +2 -0
- dw/server/ui/assets/cameligo-DMUM7wLl.js +1 -0
- dw/server/ui/assets/clojure-Cm7r79vr.js +1 -0
- dw/server/ui/assets/codicon-Brq4_Ui5.ttf +0 -0
- dw/server/ui/assets/coffee-Ba7i2nA0.js +1 -0
- dw/server/ui/assets/cpp-C7h46wYY.js +1 -0
- dw/server/ui/assets/csharp-BKxtCVv1.js +1 -0
- dw/server/ui/assets/csp-bTuwJoIa.js +1 -0
- dw/server/ui/assets/css-DIMkf-bt.js +3 -0
- dw/server/ui/assets/css.worker-B3ciXF_0.js +93 -0
- dw/server/ui/assets/cssMode-CPznxfY8.js +1 -0
- dw/server/ui/assets/cypher-CVaqCwHa.js +1 -0
- dw/server/ui/assets/dart-onAF5SnQ.js +1 -0
- dw/server/ui/assets/dockerfile-DZFCIeNp.js +1 -0
- dw/server/ui/assets/ecl-D05T4iGw.js +1 -0
- dw/server/ui/assets/editor-jjEx9u7D.css +1 -0
- dw/server/ui/assets/editor.api-CpWcotrd.js +847 -0
- dw/server/ui/assets/editor.worker-q-txB4vs.js +30 -0
- dw/server/ui/assets/elixir-6RTg0lbw.js +1 -0
- dw/server/ui/assets/flow9-C5_-GSwl.js +1 -0
- dw/server/ui/assets/freemarker2-CXtRM8N4.js +3 -0
- dw/server/ui/assets/fsharp-C8Ef5oNN.js +1 -0
- dw/server/ui/assets/go-C-y9NEjX.js +1 -0
- dw/server/ui/assets/graphql-fmXr3nnJ.js +1 -0
- dw/server/ui/assets/handlebars-N7x-6NMY.js +1 -0
- dw/server/ui/assets/hcl-CpzslTdj.js +1 -0
- dw/server/ui/assets/html-PhsdjHSr.js +1 -0
- dw/server/ui/assets/html.worker-C93Ht9o9.js +506 -0
- dw/server/ui/assets/htmlMode-Dgj0SEok.js +1 -0
- dw/server/ui/assets/index-3Vw6WAPW.css +1 -0
- dw/server/ui/assets/index-DgrYhQd9.js +43 -0
- dw/server/ui/assets/ini-sBoK_t0W.js +1 -0
- dw/server/ui/assets/java-BEtHBSE6.js +1 -0
- dw/server/ui/assets/javascript-BJqN9Qhv.js +1 -0
- dw/server/ui/assets/json.worker-B2V3pomh.js +62 -0
- dw/server/ui/assets/jsonMode-DbM4SWSv.js +7 -0
- dw/server/ui/assets/julia-Bri6UV-V.js +1 -0
- dw/server/ui/assets/kotlin-BOotOW0E.js +1 -0
- dw/server/ui/assets/less-B9JPFI3C.js +2 -0
- dw/server/ui/assets/lexon-CfSJPG6W.js +1 -0
- dw/server/ui/assets/liquid-BWr8lEc4.js +1 -0
- dw/server/ui/assets/lspLanguageFeatures-C1iGuDyZ.js +4 -0
- dw/server/ui/assets/lua-CsQS60Ue.js +1 -0
- dw/server/ui/assets/m3-D-oSqn_W.js +1 -0
- dw/server/ui/assets/markdown-Cimd5fb3.js +1 -0
- dw/server/ui/assets/mdx-DAdMi_0p.js +1 -0
- dw/server/ui/assets/mips-CIPQ_RoX.js +1 -0
- dw/server/ui/assets/monaco--ixms01u.css +1 -0
- dw/server/ui/assets/monaco-BGCeEqaw.js +56 -0
- dw/server/ui/assets/msdax-DauUninz.js +1 -0
- dw/server/ui/assets/mysql-SOo6toE5.js +1 -0
- dw/server/ui/assets/objective-c-FvmIjYaQ.js +1 -0
- dw/server/ui/assets/pascal-DrH0SRf2.js +1 -0
- dw/server/ui/assets/pascaligo-D-ptJ9y-.js +1 -0
- dw/server/ui/assets/perl-oz_6vUea.js +1 -0
- dw/server/ui/assets/pgsql-DTj74zXo.js +1 -0
- dw/server/ui/assets/php-nr791fC2.js +1 -0
- dw/server/ui/assets/pla-CopQ2nXW.js +1 -0
- dw/server/ui/assets/postiats-43DmfD33.js +1 -0
- dw/server/ui/assets/powerquery-D3hlyOfw.js +1 -0
- dw/server/ui/assets/powershell-DmHpPYUd.js +1 -0
- dw/server/ui/assets/protobuf-C531GsRP.js +2 -0
- dw/server/ui/assets/pug-Z5eAx3Zn.js +1 -0
- dw/server/ui/assets/python-Bcn70HdC.js +1 -0
- dw/server/ui/assets/qsharp-DkqhCAOL.js +1 -0
- dw/server/ui/assets/r-BwWrilGY.js +1 -0
- dw/server/ui/assets/razor-D1HmNnby.js +1 -0
- dw/server/ui/assets/redis-ClamHrr6.js +1 -0
- dw/server/ui/assets/redshift-DT7zqm-g.js +1 -0
- dw/server/ui/assets/restructuredtext-BYgofb2h.js +1 -0
- dw/server/ui/assets/ruby-DezsRK8O.js +1 -0
- dw/server/ui/assets/rust-DdL9SqIa.js +1 -0
- dw/server/ui/assets/sb-CcwsVR0C.js +1 -0
- dw/server/ui/assets/scala-DHpiXF5c.js +1 -0
- dw/server/ui/assets/scheme-BeGwcela.js +1 -0
- dw/server/ui/assets/scss-gp-XZpBa.js +3 -0
- dw/server/ui/assets/shell-CC2rA5mh.js +1 -0
- dw/server/ui/assets/solidity-BEEn4gHE.js +1 -0
- dw/server/ui/assets/sophia-CRfGWb83.js +1 -0
- dw/server/ui/assets/sparql-D_Lu-MrJ.js +1 -0
- dw/server/ui/assets/sql-NEE52Syq.js +1 -0
- dw/server/ui/assets/st-DbInun42.js +1 -0
- dw/server/ui/assets/swift-Bxkupp3x.js +1 -0
- dw/server/ui/assets/systemverilog-Bz4Y3fRF.js +1 -0
- dw/server/ui/assets/tcl-DISqw1ZD.js +1 -0
- dw/server/ui/assets/ts.worker-D7T1-Ig5.js +67738 -0
- dw/server/ui/assets/tsMode-D6u0XmOW.js +11 -0
- dw/server/ui/assets/twig-De2hgUGE.js +1 -0
- dw/server/ui/assets/typescript-BU6v-LMV.js +1 -0
- dw/server/ui/assets/typespec-B8J7ngcE.js +1 -0
- dw/server/ui/assets/vb-DV3o63ZY.js +1 -0
- dw/server/ui/assets/wgsl-DpFanUEy.js +298 -0
- dw/server/ui/assets/workers-Cn7cTUKr.js +1 -0
- dw/server/ui/assets/xml--0LP2Lwk.js +1 -0
- dw/server/ui/assets/yaml-mpBg9jnt.js +1 -0
- dw/server/ui/index.html +17 -0
- dw/server/updater.py +192 -0
- dw/settings.py +98 -0
- dw/shot_span_preflight.py +116 -0
- dw/shots.py +359 -0
- dw/slice_preflight.py +148 -0
- dw/step.py +187 -0
- dw/step_cache.py +442 -0
- dw/subfolders.py +107 -0
- dw/task_domains.py +307 -0
- dw/tasks/assess.py +826 -0
- dw/tasks/audio_transcription.py +88 -0
- dw/tasks/audio_utils.py +1862 -0
- dw/tasks/background_remover.py +43 -0
- dw/tasks/borders.py +113 -0
- dw/tasks/compose_text.py +74 -0
- dw/tasks/concat_videos.py +300 -0
- dw/tasks/depth_estimator.py +54 -0
- dw/tasks/diffusion_upscale.py +109 -0
- dw/tasks/dissolve_videos.py +342 -0
- dw/tasks/format_messages.py +24 -0
- dw/tasks/gather.py +173 -0
- dw/tasks/grade.py +97 -0
- dw/tasks/image_to_text.py +43 -0
- dw/tasks/image_utils.py +764 -0
- dw/tasks/interpolate_frames.py +252 -0
- dw/tasks/judge.py +68 -0
- dw/tasks/model_cache.py +55 -0
- dw/tasks/pair_audio.py +268 -0
- dw/tasks/qr_code.py +19 -0
- dw/tasks/restore_faces.py +175 -0
- dw/tasks/rife_model.py +192 -0
- dw/tasks/segment.py +121 -0
- dw/tasks/select.py +111 -0
- dw/tasks/speech_generation.py +228 -0
- dw/tasks/stabilize.py +129 -0
- dw/tasks/task.py +920 -0
- dw/tasks/tensor_image.py +57 -0
- dw/tasks/text_generation.py +169 -0
- dw/tasks/text_sections.py +80 -0
- dw/tasks/upscale.py +203 -0
- dw/tasks/video_utils.py +624 -0
- dw/tasks/zoe_depth.py +71 -0
- dw/teacache.py +381 -0
- dw/teacache_models.json +99 -0
- dw/test.py +29 -0
- dw/type_helpers.py +231 -0
- dw/validate.py +68 -0
- dw/variable_constraints.py +444 -0
- dw/variables.py +443 -0
- dw/video_extensions.py +141 -0
- dw/vram_estimate.py +116 -0
- dw/worker.py +764 -0
- dw/workflow.py +2007 -0
- dw/workflow_schema.json +1346 -0
- dw/workflow_sources.py +383 -0
- dw/workflows/h3_context_ir.json +57 -0
- dw/workflows/test.json +31 -0
- dw/workspace.py +730 -0
- dw_mcp/__init__.py +6 -0
- dw_mcp/__main__.py +133 -0
- dw_mcp/assets.py +336 -0
- dw_mcp/authoring.py +114 -0
- dw_mcp/catalog.py +360 -0
- dw_mcp/client.py +486 -0
- dw_mcp/diagnose.py +371 -0
- dw_mcp/exports.py +84 -0
- dw_mcp/guides.py +35 -0
- dw_mcp/media.py +638 -0
- dw_mcp/models.py +97 -0
- dw_mcp/prompts.py +104 -0
- dw_mcp/server.py +1343 -0
- dw_mcp/workspaces.py +212 -0
dw_mcp/client.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
"""HTTP access to a running dw.serve, and the single place an API failure
|
|
2
|
+
becomes a message a non-developer can act on."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
DEFAULT_BASE_URL = "http://127.0.0.1:8765"
|
|
13
|
+
|
|
14
|
+
# Twin of dw.server.app.LOOPBACK_HOSTS. Duplicated rather than imported:
|
|
15
|
+
# importing anything under dw/ runs dw/__init__.py and pulls in torch, which
|
|
16
|
+
# this pure HTTP client must not do (tests/test_mcp_server.py guards that).
|
|
17
|
+
LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_loopback_url(url):
|
|
21
|
+
"""True when `url` names this machine's loopback interface - the case
|
|
22
|
+
where an unauthenticated dw.serve is only reachable by this user."""
|
|
23
|
+
from urllib.parse import urlparse
|
|
24
|
+
|
|
25
|
+
return (urlparse(url).hostname or "").lower() in LOOPBACK_HOSTS
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def coerce_json_object(value, param_name):
|
|
29
|
+
"""A tool argument typed as an object can still arrive as a JSON-encoded
|
|
30
|
+
string (a caller that serialized it before handing it over, or a client
|
|
31
|
+
that couldn't parse a malformed document and passed the raw text
|
|
32
|
+
through). Accept that case rather than letting it reach the server as a
|
|
33
|
+
string, where the schema rejection names the wrong problem - not "this
|
|
34
|
+
isn't an object" but a bare pydantic `type=dict_type, input_type=str`,
|
|
35
|
+
which reads as if the field itself were misdeclared."""
|
|
36
|
+
if value is None or isinstance(value, dict):
|
|
37
|
+
return value
|
|
38
|
+
if not isinstance(value, str):
|
|
39
|
+
raise DwApiError(
|
|
40
|
+
f"`{param_name}` must be a JSON object, not {type(value).__name__}."
|
|
41
|
+
)
|
|
42
|
+
try:
|
|
43
|
+
parsed = json.loads(value)
|
|
44
|
+
except json.JSONDecodeError as e:
|
|
45
|
+
raise DwApiError(f"`{param_name}` is not valid JSON: {e}") from e
|
|
46
|
+
if not isinstance(parsed, dict):
|
|
47
|
+
raise DwApiError(
|
|
48
|
+
f"`{param_name}` must be a JSON object, not {type(parsed).__name__}."
|
|
49
|
+
)
|
|
50
|
+
return parsed
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def path_segment(name):
|
|
54
|
+
"""Percent-encode a name for interpolation into a request path,
|
|
55
|
+
including its '/' characters.
|
|
56
|
+
|
|
57
|
+
httpx normalizes dot-segments (`..`) out of a request URL client-side,
|
|
58
|
+
before the request ever reaches the server - so an unquoted name like
|
|
59
|
+
`../escape` is silently rewritten into a different, valid-looking path
|
|
60
|
+
and the server's own path-traversal check never runs on it. Quoting
|
|
61
|
+
keeps the literal bytes intact on the wire, so it is the server's own
|
|
62
|
+
validation - not this client - that decides what a name is allowed to
|
|
63
|
+
contain.
|
|
64
|
+
"""
|
|
65
|
+
return quote(name, safe="")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _declared_length(headers):
|
|
69
|
+
"""The body length a response declares, or None when it declares none
|
|
70
|
+
(a chunked answer) or something that is not a number."""
|
|
71
|
+
value = headers.get("content-length")
|
|
72
|
+
try:
|
|
73
|
+
return int(value) if value not in (None, "") else None
|
|
74
|
+
except ValueError:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def api_path(*segments):
|
|
79
|
+
"""Build a request path from literal and caller-supplied segments,
|
|
80
|
+
percent-encoding each one - including its '/' characters.
|
|
81
|
+
|
|
82
|
+
httpx normalizes dot-segments (`..`) out of a request URL client-side,
|
|
83
|
+
before the request ever reaches the server - so an unquoted segment like
|
|
84
|
+
`../escape` is silently rewritten into a different, valid-looking path
|
|
85
|
+
and the server's own path-traversal check never runs on it. Quoting
|
|
86
|
+
keeps the literal bytes intact on the wire, so it is the server's own
|
|
87
|
+
validation - not this client - that decides what a segment is allowed to
|
|
88
|
+
contain.
|
|
89
|
+
|
|
90
|
+
This is the only sanctioned way to interpolate a value into a request
|
|
91
|
+
path: a handler that builds one with an f-string instead re-opens the
|
|
92
|
+
hole this closes, one call site at a time.
|
|
93
|
+
"""
|
|
94
|
+
return "/" + "/".join(path_segment(str(segment)) for segment in segments)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class DwApiError(Exception):
|
|
98
|
+
"""A request to dw.serve failed. The message is meant to be read by the
|
|
99
|
+
person driving the MCP client, not by a developer with a stack trace.
|
|
100
|
+
|
|
101
|
+
`status_code` is the HTTP status that produced it when it came from a
|
|
102
|
+
response (`None` for a connection failure or timeout, raised before any
|
|
103
|
+
status exists) - a caller that reacts differently to a 404 than to a 409
|
|
104
|
+
needs that without parsing the message text."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, message, status_code=None):
|
|
107
|
+
super().__init__(message)
|
|
108
|
+
self.status_code = status_code
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def resolve_token(explicit=None):
|
|
112
|
+
"""The bearer token dw.serve was started with, if any: the explicit
|
|
113
|
+
value, else DW_API_TOKEN - the same variable dw.serve itself reads, so
|
|
114
|
+
one export configures both ends. Empty means unauthenticated."""
|
|
115
|
+
return explicit or os.environ.get("DW_API_TOKEN") or ""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# The workspace name every request is scoped to when the session has chosen
|
|
119
|
+
# one. Not DW_WORKSPACE: that names a *directory* to the engine, where this
|
|
120
|
+
# names one of a server's workspaces - two different things that would be a
|
|
121
|
+
# confusing single variable
|
|
122
|
+
WORKSPACE_ENV_VAR = "DW_MCP_WORKSPACE"
|
|
123
|
+
|
|
124
|
+
DEFAULT_WORKSPACE = "default"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def resolve_workspace(explicit=None):
|
|
128
|
+
"""Which of the server's workspaces this session works in: the explicit
|
|
129
|
+
value, else DW_MCP_WORKSPACE, else the server's default."""
|
|
130
|
+
return explicit or os.environ.get(WORKSPACE_ENV_VAR) or DEFAULT_WORKSPACE
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def resolve_base_url(explicit=None):
|
|
134
|
+
"""Where dw.serve is: the explicit value, else DW_MCP_URL, else the
|
|
135
|
+
default port."""
|
|
136
|
+
url = explicit or os.environ.get("DW_MCP_URL") or DEFAULT_BASE_URL
|
|
137
|
+
return url.rstrip("/")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class DwClient:
|
|
141
|
+
"""One method per kind of REST call. Knows nothing about MCP - the tool
|
|
142
|
+
handlers are plain functions over this."""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
base_url=None,
|
|
147
|
+
timeout=30.0,
|
|
148
|
+
transport=None,
|
|
149
|
+
token=None,
|
|
150
|
+
workspace=None,
|
|
151
|
+
):
|
|
152
|
+
self.base_url = resolve_base_url(base_url)
|
|
153
|
+
# True only for the client dw.serve --mcp builds for its own mounted
|
|
154
|
+
# tool surface (dw/server/mcp_mount.py). It is what distinguishes
|
|
155
|
+
# "local disk" meaning the caller's own machine (stdio dw-mcp) from
|
|
156
|
+
# it meaning the GPU box a remote agent is talking to - which decides
|
|
157
|
+
# whether download_output may write outside the workspace (#113)
|
|
158
|
+
self.mounted = False
|
|
159
|
+
# Mutable: use_workspace switches it for the rest of the session,
|
|
160
|
+
# which is what makes a switch one visible call rather than a
|
|
161
|
+
# parameter on every tool
|
|
162
|
+
self.workspace = resolve_workspace(workspace)
|
|
163
|
+
self.timeout = timeout
|
|
164
|
+
token = resolve_token(token)
|
|
165
|
+
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
|
166
|
+
self._http = httpx.Client(
|
|
167
|
+
base_url=self.base_url,
|
|
168
|
+
timeout=timeout,
|
|
169
|
+
transport=transport,
|
|
170
|
+
headers=headers,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def close(self):
|
|
174
|
+
self._http.close()
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------- requests
|
|
177
|
+
|
|
178
|
+
def get_json(self, path, params=None, workspace=None):
|
|
179
|
+
return self._json(
|
|
180
|
+
self._request("GET", path, params=params, workspace=workspace), path
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def post_json(self, path, payload=None, params=None, workspace=None):
|
|
184
|
+
"""`params` is for a route whose options are query parameters rather
|
|
185
|
+
than a body - the export route, which takes `overwrite` beside the
|
|
186
|
+
workspace selector `_scoped` adds."""
|
|
187
|
+
return self._json(
|
|
188
|
+
self._request(
|
|
189
|
+
"POST", path, json=payload or {}, params=params, workspace=workspace
|
|
190
|
+
),
|
|
191
|
+
path,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def put_json(self, path, payload, workspace=None):
|
|
195
|
+
return self._json(
|
|
196
|
+
self._request("PUT", path, json=payload, workspace=workspace), path
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def delete_json(self, path, params=None, workspace=None):
|
|
200
|
+
return self._json(
|
|
201
|
+
self._request("DELETE", path, params=params, workspace=workspace), path
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def post_bytes(self, path, data, params=None, workspace=None):
|
|
205
|
+
"""Send a file's bytes as the request body - the shape
|
|
206
|
+
POST /api/uploads takes, so a single file needs no multipart
|
|
207
|
+
parser at either end."""
|
|
208
|
+
return self._json(
|
|
209
|
+
self._request(
|
|
210
|
+
"POST", path, content=data, params=params, workspace=workspace
|
|
211
|
+
),
|
|
212
|
+
path,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def get_bytes(self, path, workspace=None):
|
|
216
|
+
"""Raw body plus content type - for the output media served from the
|
|
217
|
+
/outputs static mount rather than an /api route."""
|
|
218
|
+
response = self._request("GET", path, workspace=workspace)
|
|
219
|
+
self._raise_for_status(response, path)
|
|
220
|
+
return response.content, response.headers.get("content-type", "")
|
|
221
|
+
|
|
222
|
+
def get_media_if(
|
|
223
|
+
self, path, accept_content_type, workspace=None, params=None, max_bytes=None
|
|
224
|
+
):
|
|
225
|
+
"""Like `get_bytes`, but the body is only downloaded when
|
|
226
|
+
`accept_content_type(content_type)` is true, and the response
|
|
227
|
+
headers come back with it - a media route says what it cut in
|
|
228
|
+
them.
|
|
229
|
+
|
|
230
|
+
Headers arrive before the body over HTTP, so a rejection closes the
|
|
231
|
+
connection having read nothing past them - useful for `/outputs`,
|
|
232
|
+
where a rejected file (a video, say) can be arbitrarily large.
|
|
233
|
+
`max_bytes` rejects the same way on size: when the server declares
|
|
234
|
+
a `content-length` whose base64 form would exceed it, the body is
|
|
235
|
+
not read - a refusal that costs the whole download first is no
|
|
236
|
+
saving. Returns `(None, content_type, headers)` on rejection,
|
|
237
|
+
`(body, content_type, headers)` on acceptance; the caller tells the
|
|
238
|
+
two rejections apart by the headers. An error status is still
|
|
239
|
+
raised either way, since the body has to be read to report it.
|
|
240
|
+
"""
|
|
241
|
+
kwargs = {"params": params} if params else {}
|
|
242
|
+
response = self._stream_request("GET", path, workspace=workspace, **kwargs)
|
|
243
|
+
try:
|
|
244
|
+
content_type = response.headers.get("content-type", "")
|
|
245
|
+
if response.status_code < 400 and not accept_content_type(content_type):
|
|
246
|
+
return None, content_type, response.headers
|
|
247
|
+
if response.status_code < 400 and max_bytes is not None:
|
|
248
|
+
declared = _declared_length(response.headers)
|
|
249
|
+
if declared is not None and 4 * math.ceil(declared / 3) > max_bytes:
|
|
250
|
+
return None, content_type, response.headers
|
|
251
|
+
self._call_httpx(response.read, path)
|
|
252
|
+
self._raise_for_status(response, path)
|
|
253
|
+
return response.content, content_type, response.headers
|
|
254
|
+
finally:
|
|
255
|
+
response.close()
|
|
256
|
+
|
|
257
|
+
def get_bytes_if(self, path, accept_content_type, workspace=None):
|
|
258
|
+
"""`get_media_if` without the headers, for the callers that only
|
|
259
|
+
want the body."""
|
|
260
|
+
body, content_type, _headers = self.get_media_if(
|
|
261
|
+
path, accept_content_type, workspace=workspace
|
|
262
|
+
)
|
|
263
|
+
return body, content_type
|
|
264
|
+
|
|
265
|
+
def stream_to_file(self, path, destination, workspace=None):
|
|
266
|
+
"""Stream `path`'s body straight to `destination` on disk, in
|
|
267
|
+
chunks, rather than buffering it whole - for a body too large to
|
|
268
|
+
hold in memory (the videos `get_bytes` can't return). Returns
|
|
269
|
+
`(content_type, bytes_written)`.
|
|
270
|
+
|
|
271
|
+
Written atomically: chunks land in a temp file next to
|
|
272
|
+
`destination` first, moved into place with `os.replace` only after
|
|
273
|
+
the whole body has arrived. A connection drop or server error
|
|
274
|
+
mid-stream removes the temp file and re-raises rather than leaving
|
|
275
|
+
a torn partial file at `destination` - which would otherwise
|
|
276
|
+
"exist" for a later `overwrite=False` caller and mask the failure.
|
|
277
|
+
"""
|
|
278
|
+
response = self._stream_request("GET", path, workspace=workspace)
|
|
279
|
+
try:
|
|
280
|
+
if response.status_code >= 400:
|
|
281
|
+
self._call_httpx(response.read, path)
|
|
282
|
+
self._raise_for_status(response, path)
|
|
283
|
+
content_type = response.headers.get("content-type", "")
|
|
284
|
+
parent = os.path.dirname(destination) or "."
|
|
285
|
+
fd, temp_path = tempfile.mkstemp(dir=parent)
|
|
286
|
+
|
|
287
|
+
def write_chunks():
|
|
288
|
+
written = 0
|
|
289
|
+
with os.fdopen(fd, "wb") as file:
|
|
290
|
+
for chunk in response.iter_bytes():
|
|
291
|
+
file.write(chunk)
|
|
292
|
+
written += len(chunk)
|
|
293
|
+
return written
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
written = self._call_httpx(write_chunks, path)
|
|
297
|
+
os.replace(temp_path, destination)
|
|
298
|
+
except BaseException:
|
|
299
|
+
try:
|
|
300
|
+
os.unlink(temp_path)
|
|
301
|
+
except OSError:
|
|
302
|
+
pass
|
|
303
|
+
raise
|
|
304
|
+
return content_type, written
|
|
305
|
+
finally:
|
|
306
|
+
response.close()
|
|
307
|
+
|
|
308
|
+
# ------------------------------------------------------------ internals
|
|
309
|
+
|
|
310
|
+
def _request(self, method, path, workspace=None, **kwargs):
|
|
311
|
+
return self._call_httpx(
|
|
312
|
+
lambda: self._http.request(method, path, **self._scoped(kwargs, workspace)),
|
|
313
|
+
path,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def _scoped(self, kwargs, workspace=None):
|
|
317
|
+
"""Add the workspace a request is for to its query string.
|
|
318
|
+
|
|
319
|
+
One place rather than a parameter on every handler: routes that are
|
|
320
|
+
not workspace-scoped (prompts, models, system) ignore an unknown
|
|
321
|
+
query parameter, and the server treats a missing selector as its
|
|
322
|
+
default - so the default workspace sends nothing and every request
|
|
323
|
+
looks exactly as it did before workspaces existed.
|
|
324
|
+
|
|
325
|
+
`workspace` is the per-call pin - "for this one call, without
|
|
326
|
+
switching the session" - and wins over the session's own. Naming the
|
|
327
|
+
default explicitly is the way to reach it from a session that is
|
|
328
|
+
somewhere else, so it sends no selector rather than the session's
|
|
329
|
+
(#99).
|
|
330
|
+
"""
|
|
331
|
+
name = workspace or self.workspace
|
|
332
|
+
if name == DEFAULT_WORKSPACE:
|
|
333
|
+
return kwargs
|
|
334
|
+
params = dict(kwargs.get("params") or {})
|
|
335
|
+
params.setdefault("workspace", name)
|
|
336
|
+
return {**kwargs, "params": params}
|
|
337
|
+
|
|
338
|
+
def _stream_request(self, method, path, workspace=None, **kwargs):
|
|
339
|
+
scoped = self._scoped(kwargs, workspace)
|
|
340
|
+
return self._call_httpx(
|
|
341
|
+
lambda: self._http.send(
|
|
342
|
+
self._http.build_request(method, path, **scoped), stream=True
|
|
343
|
+
),
|
|
344
|
+
path,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
def _call_httpx(self, send, path):
|
|
348
|
+
try:
|
|
349
|
+
return send()
|
|
350
|
+
# ConnectTimeout is a subclass of TimeoutException, so it must be
|
|
351
|
+
# caught here, before TimeoutException below.
|
|
352
|
+
except (httpx.ConnectTimeout, httpx.ConnectError):
|
|
353
|
+
raise DwApiError(
|
|
354
|
+
f"Cannot reach diffusers-workflow at {self.base_url}. "
|
|
355
|
+
"Start the server with `dw-serve` (or `python -m dw.serve`) "
|
|
356
|
+
"and try again."
|
|
357
|
+
)
|
|
358
|
+
except httpx.TimeoutException:
|
|
359
|
+
raise DwApiError(
|
|
360
|
+
f"Request to {path} timed out after {self.timeout}s. The "
|
|
361
|
+
"server may be busy loading a model."
|
|
362
|
+
)
|
|
363
|
+
except httpx.HTTPError as e:
|
|
364
|
+
raise DwApiError(f"Request to {path} failed: {e}")
|
|
365
|
+
|
|
366
|
+
def _json(self, response, path):
|
|
367
|
+
self._raise_for_status(response, path)
|
|
368
|
+
try:
|
|
369
|
+
return response.json()
|
|
370
|
+
except ValueError:
|
|
371
|
+
raise DwApiError(f"{path} returned a non-JSON body: {response.text[:200]}")
|
|
372
|
+
|
|
373
|
+
def _raise_for_status(self, response, path):
|
|
374
|
+
if response.status_code < 400:
|
|
375
|
+
return
|
|
376
|
+
# 5xx is always a server-side failure, even when the body happens to
|
|
377
|
+
# carry a `detail` (dw/server/app.py raises 500s with one) - the
|
|
378
|
+
# status has to survive so it reads as distinct from a validation
|
|
379
|
+
# message.
|
|
380
|
+
if response.status_code < 500:
|
|
381
|
+
detail = None
|
|
382
|
+
try:
|
|
383
|
+
body = response.json()
|
|
384
|
+
if isinstance(body, dict):
|
|
385
|
+
detail = body.get("detail")
|
|
386
|
+
except ValueError:
|
|
387
|
+
detail = None
|
|
388
|
+
if detail:
|
|
389
|
+
# The API writes these for humans already - 400s carry
|
|
390
|
+
# validation messages, 404s and 409s carry the reason
|
|
391
|
+
formatted_detail = self._format_detail(detail)
|
|
392
|
+
raise DwApiError(formatted_detail, status_code=response.status_code)
|
|
393
|
+
raise DwApiError(
|
|
394
|
+
f"{path} failed with HTTP {response.status_code}: "
|
|
395
|
+
f"{response.text[:200] or 'no body'}",
|
|
396
|
+
status_code=response.status_code,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def _format_detail(self, detail):
|
|
400
|
+
"""Format a detail from an API error response into a human-readable
|
|
401
|
+
message. FastAPI validation errors (422) have detail as a list of dicts
|
|
402
|
+
with 'loc' and 'msg' keys; a route that has to say what it would do
|
|
403
|
+
sends a dict ('message' plus 'contents' or 'entries'), which str()
|
|
404
|
+
would hand back as a Python repr; string details are returned
|
|
405
|
+
verbatim."""
|
|
406
|
+
if isinstance(detail, dict) and "message" in detail:
|
|
407
|
+
formatted = str(detail["message"])
|
|
408
|
+
contents = detail.get("contents")
|
|
409
|
+
if isinstance(contents, dict):
|
|
410
|
+
held = [
|
|
411
|
+
f"{folder}: {value.get('files', 0)} file(s), "
|
|
412
|
+
f"{value.get('bytes', 0)} bytes"
|
|
413
|
+
for folder, value in contents.items()
|
|
414
|
+
if isinstance(value, dict) and value.get("files")
|
|
415
|
+
]
|
|
416
|
+
formatted += f" Holds {'; '.join(held) if held else 'nothing'}."
|
|
417
|
+
entries = detail.get("entries")
|
|
418
|
+
if isinstance(entries, list) and entries:
|
|
419
|
+
formatted += f" Also holds: {', '.join(str(e) for e in entries)}."
|
|
420
|
+
plan = detail.get("plan")
|
|
421
|
+
if isinstance(plan, dict):
|
|
422
|
+
# A 409 from the cost gate: say what the run costs now, so a
|
|
423
|
+
# client that only sees the message can re-quote from it
|
|
424
|
+
estimate = plan.get("estimate") or {}
|
|
425
|
+
if estimate.get("minutes") is None:
|
|
426
|
+
formatted += (
|
|
427
|
+
f" It now has no measured estimate (basis "
|
|
428
|
+
f"{estimate.get('basis')})"
|
|
429
|
+
)
|
|
430
|
+
else:
|
|
431
|
+
formatted += (
|
|
432
|
+
f" It now estimates {estimate.get('minutes')} minutes "
|
|
433
|
+
f"(basis {estimate.get('basis')})"
|
|
434
|
+
)
|
|
435
|
+
downloads = [
|
|
436
|
+
entry.get("repo") or entry.get("url")
|
|
437
|
+
for entry in plan.get("downloads_required") or []
|
|
438
|
+
]
|
|
439
|
+
if downloads:
|
|
440
|
+
formatted += f", and would download {', '.join(downloads)} first"
|
|
441
|
+
# The shape to resend, not just the new fingerprint: a
|
|
442
|
+
# client reading only the message can re-acknowledge from it
|
|
443
|
+
minutes = estimate.get("minutes")
|
|
444
|
+
repos = [
|
|
445
|
+
entry.get("repo")
|
|
446
|
+
for entry in plan.get("downloads_required") or []
|
|
447
|
+
if entry.get("repo")
|
|
448
|
+
]
|
|
449
|
+
# json.dumps for the whole object, not an f-string per field:
|
|
450
|
+
# an inline workflow has no measured estimate, so `minutes` is
|
|
451
|
+
# None far more often than not, and Python's repr of it is not
|
|
452
|
+
# JSON a client could paste back (#107)
|
|
453
|
+
formatted += (
|
|
454
|
+
". Re-acknowledge with "
|
|
455
|
+
+ json.dumps(
|
|
456
|
+
{
|
|
457
|
+
"fingerprint": plan.get("fingerprint"),
|
|
458
|
+
"minutes": minutes,
|
|
459
|
+
"downloads": repos,
|
|
460
|
+
}
|
|
461
|
+
)
|
|
462
|
+
+ "."
|
|
463
|
+
)
|
|
464
|
+
return formatted
|
|
465
|
+
if isinstance(detail, list):
|
|
466
|
+
messages = []
|
|
467
|
+
for entry in detail:
|
|
468
|
+
if isinstance(entry, dict):
|
|
469
|
+
msg = entry.get("msg")
|
|
470
|
+
loc = entry.get("loc")
|
|
471
|
+
if msg is None:
|
|
472
|
+
messages.append(str(entry))
|
|
473
|
+
elif loc and isinstance(loc, list):
|
|
474
|
+
field_name = loc[-1]
|
|
475
|
+
messages.append(f"{field_name}: {msg}")
|
|
476
|
+
else:
|
|
477
|
+
messages.append(msg)
|
|
478
|
+
else:
|
|
479
|
+
messages.append(str(entry))
|
|
480
|
+
return ". ".join(messages) if messages else str(detail)
|
|
481
|
+
formatted = str(detail)
|
|
482
|
+
# When a workspace was deleted elsewhere, give the agent a path
|
|
483
|
+
# to recovery: list available workspaces and switch to one
|
|
484
|
+
if isinstance(detail, str) and detail.startswith("No such workspace"):
|
|
485
|
+
formatted += " - list_workspaces shows what exists; use_workspace switches."
|
|
486
|
+
return formatted
|