power-loop 0.2.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.
Files changed (53) hide show
  1. llm_client/__init__.py +0 -0
  2. llm_client/capabilities.py +162 -0
  3. llm_client/interface.py +470 -0
  4. llm_client/llm_factory.py +981 -0
  5. llm_client/llm_tooling.py +645 -0
  6. llm_client/llm_utils.py +205 -0
  7. llm_client/multimodal.py +237 -0
  8. llm_client/qwen_image.py +576 -0
  9. llm_client/web_search.py +149 -0
  10. power_loop/__init__.py +326 -0
  11. power_loop/agent/__init__.py +6 -0
  12. power_loop/agent/sink.py +247 -0
  13. power_loop/agent/stateful_loop.py +363 -0
  14. power_loop/agent/system_prompt.py +396 -0
  15. power_loop/agent/types.py +41 -0
  16. power_loop/contracts/__init__.py +132 -0
  17. power_loop/contracts/errors.py +140 -0
  18. power_loop/contracts/event_payloads.py +278 -0
  19. power_loop/contracts/events.py +86 -0
  20. power_loop/contracts/handlers.py +45 -0
  21. power_loop/contracts/hook_contexts.py +265 -0
  22. power_loop/contracts/hooks.py +64 -0
  23. power_loop/contracts/messages.py +90 -0
  24. power_loop/contracts/protocols.py +48 -0
  25. power_loop/contracts/tools.py +56 -0
  26. power_loop/core/agent_context.py +94 -0
  27. power_loop/core/events.py +124 -0
  28. power_loop/core/hooks.py +122 -0
  29. power_loop/core/phase.py +217 -0
  30. power_loop/core/pipeline.py +880 -0
  31. power_loop/core/runner.py +60 -0
  32. power_loop/core/state.py +208 -0
  33. power_loop/runtime/budget.py +179 -0
  34. power_loop/runtime/cancellation.py +127 -0
  35. power_loop/runtime/compact.py +300 -0
  36. power_loop/runtime/env.py +103 -0
  37. power_loop/runtime/memory.py +107 -0
  38. power_loop/runtime/provider.py +176 -0
  39. power_loop/runtime/retry.py +182 -0
  40. power_loop/runtime/session_store.py +636 -0
  41. power_loop/runtime/skills.py +201 -0
  42. power_loop/runtime/spec.py +233 -0
  43. power_loop/runtime/structured.py +225 -0
  44. power_loop/tools/__init__.py +51 -0
  45. power_loop/tools/default_manifest.py +244 -0
  46. power_loop/tools/default_tools.py +766 -0
  47. power_loop/tools/registry.py +162 -0
  48. power_loop/tools/spawn_agent.py +173 -0
  49. power_loop-0.2.0.dist-info/METADATA +632 -0
  50. power_loop-0.2.0.dist-info/RECORD +53 -0
  51. power_loop-0.2.0.dist-info/WHEEL +5 -0
  52. power_loop-0.2.0.dist-info/licenses/LICENSE +21 -0
  53. power_loop-0.2.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,576 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import mimetypes
6
+ import ssl
7
+ import time
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+ from urllib.error import HTTPError, URLError
13
+ from urllib.parse import unquote, urlparse
14
+ from urllib.request import HTTPSHandler, ProxyHandler, Request, build_opener
15
+
16
+ import certifi
17
+
18
+ DEFAULT_DASHSCOPE_IMAGE_BASE_URL = "https://dashscope.aliyuncs.com/api/v1"
19
+ DEFAULT_DASHSCOPE_IMAGE_OUTPUT_DIR = "outputs/generated-images"
20
+ DEFAULT_DASHSCOPE_IMAGE_EDIT_OUTPUT_DIR = "outputs/edited-images"
21
+ SYNC_GENERATION_PATH = "/services/aigc/multimodal-generation/generation"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class QwenImageConfig:
26
+ api_key: str
27
+ model: str
28
+ base_url: str = DEFAULT_DASHSCOPE_IMAGE_BASE_URL
29
+ default_size: str | None = None
30
+ prompt_extend: bool = True
31
+ watermark: bool = False
32
+ use_proxy: bool = False
33
+ output_dir: str = DEFAULT_DASHSCOPE_IMAGE_OUTPUT_DIR
34
+ timeout_s: float = 180.0
35
+
36
+ @property
37
+ def enabled(self) -> bool:
38
+ return bool(self.api_key and self.model)
39
+
40
+ @property
41
+ def endpoint(self) -> str:
42
+ return self.base_url.rstrip("/") + SYNC_GENERATION_PATH
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class QwenImageEditConfig:
47
+ api_key: str
48
+ model: str
49
+ base_url: str = DEFAULT_DASHSCOPE_IMAGE_BASE_URL
50
+ default_size: str | None = None
51
+ prompt_extend: bool = True
52
+ watermark: bool = False
53
+ use_proxy: bool = False
54
+ output_dir: str = DEFAULT_DASHSCOPE_IMAGE_EDIT_OUTPUT_DIR
55
+ timeout_s: float = 180.0
56
+
57
+ @property
58
+ def enabled(self) -> bool:
59
+ return bool(self.api_key and self.model)
60
+
61
+ @property
62
+ def endpoint(self) -> str:
63
+ return self.base_url.rstrip("/") + SYNC_GENERATION_PATH
64
+
65
+
66
+ class QwenImageError(RuntimeError):
67
+ def __init__(
68
+ self,
69
+ message: str,
70
+ *,
71
+ category: str,
72
+ retryable: bool = False,
73
+ status_code: int | None = None,
74
+ ):
75
+ super().__init__(message)
76
+ self.category = category
77
+ self.retryable = retryable
78
+ self.status_code = status_code
79
+
80
+
81
+ def _parse_optional_bool(value: Any, default: bool) -> bool:
82
+ if value is None:
83
+ return default
84
+ if isinstance(value, bool):
85
+ return value
86
+ normalized = str(value).strip().lower()
87
+ if not normalized:
88
+ return default
89
+ if normalized in {"1", "true", "yes", "on"}:
90
+ return True
91
+ if normalized in {"0", "false", "no", "off"}:
92
+ return False
93
+ return default
94
+
95
+
96
+ def qwen_image_config_from_env(env: Mapping[str, Any]) -> QwenImageConfig | None:
97
+ model = str(env.get("DASHSCOPE_IMAGE_MODEL") or "").strip()
98
+ api_key = str(env.get("DASHSCOPE_IMAGE_API_KEY") or env.get("DASHSCOPE_API_KEY") or "").strip()
99
+ if not (model and api_key):
100
+ return None
101
+
102
+ base_url = str(env.get("DASHSCOPE_IMAGE_BASE_URL") or DEFAULT_DASHSCOPE_IMAGE_BASE_URL).strip()
103
+ default_size = str(env.get("DASHSCOPE_IMAGE_DEFAULT_SIZE") or "").strip() or None
104
+ output_dir = str(env.get("DASHSCOPE_IMAGE_OUTPUT_DIR") or DEFAULT_DASHSCOPE_IMAGE_OUTPUT_DIR).strip()
105
+
106
+ try:
107
+ timeout_s = float(env.get("DASHSCOPE_IMAGE_TIMEOUT_S") or 180.0)
108
+ except Exception:
109
+ timeout_s = 180.0
110
+
111
+ return QwenImageConfig(
112
+ api_key=api_key,
113
+ model=model,
114
+ base_url=base_url,
115
+ default_size=default_size,
116
+ prompt_extend=_parse_optional_bool(env.get("DASHSCOPE_IMAGE_PROMPT_EXTEND"), True),
117
+ watermark=_parse_optional_bool(env.get("DASHSCOPE_IMAGE_WATERMARK"), False),
118
+ use_proxy=_parse_optional_bool(env.get("DASHSCOPE_IMAGE_USE_PROXY"), False),
119
+ output_dir=output_dir,
120
+ timeout_s=max(1.0, timeout_s),
121
+ )
122
+
123
+
124
+ def qwen_image_edit_config_from_env(env: Mapping[str, Any]) -> QwenImageEditConfig | None:
125
+ model = str(env.get("DASHSCOPE_IMAGE_EDIT_MODEL") or "").strip()
126
+ api_key = str(env.get("DASHSCOPE_IMAGE_EDIT_API_KEY") or env.get("DASHSCOPE_API_KEY") or "").strip()
127
+ if not (model and api_key):
128
+ return None
129
+
130
+ base_url = str(
131
+ env.get("DASHSCOPE_IMAGE_EDIT_BASE_URL") or env.get("DASHSCOPE_IMAGE_BASE_URL") or DEFAULT_DASHSCOPE_IMAGE_BASE_URL
132
+ ).strip()
133
+ default_size = str(env.get("DASHSCOPE_IMAGE_EDIT_DEFAULT_SIZE") or "").strip() or None
134
+ output_dir = str(env.get("DASHSCOPE_IMAGE_EDIT_OUTPUT_DIR") or DEFAULT_DASHSCOPE_IMAGE_EDIT_OUTPUT_DIR).strip()
135
+
136
+ try:
137
+ timeout_s = float(env.get("DASHSCOPE_IMAGE_EDIT_TIMEOUT_S") or 180.0)
138
+ except Exception:
139
+ timeout_s = 180.0
140
+
141
+ return QwenImageEditConfig(
142
+ api_key=api_key,
143
+ model=model,
144
+ base_url=base_url,
145
+ default_size=default_size,
146
+ prompt_extend=_parse_optional_bool(env.get("DASHSCOPE_IMAGE_EDIT_PROMPT_EXTEND"), True),
147
+ watermark=_parse_optional_bool(env.get("DASHSCOPE_IMAGE_EDIT_WATERMARK"), False),
148
+ use_proxy=_parse_optional_bool(env.get("DASHSCOPE_IMAGE_EDIT_USE_PROXY"), False),
149
+ output_dir=output_dir,
150
+ timeout_s=max(1.0, timeout_s),
151
+ )
152
+
153
+
154
+ def _create_ssl_context() -> ssl.SSLContext:
155
+ return ssl.create_default_context(cafile=certifi.where())
156
+
157
+
158
+ def _build_url_opener(use_proxy: bool):
159
+ handlers = [HTTPSHandler(context=_create_ssl_context())]
160
+ if not use_proxy:
161
+ handlers.insert(0, ProxyHandler({}))
162
+ return build_opener(*handlers)
163
+
164
+
165
+ def build_qwen_image_payload(
166
+ *,
167
+ model: str,
168
+ prompt: str,
169
+ negative_prompt: str | None = None,
170
+ size: str | None = None,
171
+ prompt_extend: bool = True,
172
+ watermark: bool = False,
173
+ ) -> dict[str, Any]:
174
+ payload: dict[str, Any] = {
175
+ "model": model,
176
+ "input": {
177
+ "messages": [
178
+ {
179
+ "role": "user",
180
+ "content": [{"text": prompt}],
181
+ }
182
+ ]
183
+ },
184
+ "parameters": {
185
+ "prompt_extend": bool(prompt_extend),
186
+ "watermark": bool(watermark),
187
+ },
188
+ }
189
+
190
+ parameters = payload["parameters"]
191
+ if negative_prompt:
192
+ parameters["negative_prompt"] = negative_prompt
193
+ if size:
194
+ parameters["size"] = size
195
+ return payload
196
+
197
+
198
+ def _guess_mime_type(path: Path) -> str:
199
+ mime_type, _ = mimetypes.guess_type(str(path))
200
+ return mime_type or "application/octet-stream"
201
+
202
+
203
+ def _file_to_data_url(path: Path) -> str:
204
+ mime_type = _guess_mime_type(path)
205
+ payload = base64.b64encode(path.read_bytes()).decode("ascii")
206
+ return f"data:{mime_type};base64,{payload}"
207
+
208
+
209
+ def build_qwen_image_edit_payload(
210
+ *,
211
+ model: str,
212
+ prompt: str,
213
+ image_sources: list[str],
214
+ negative_prompt: str | None = None,
215
+ size: str | None = None,
216
+ n: int | None = None,
217
+ prompt_extend: bool = True,
218
+ watermark: bool = False,
219
+ ) -> dict[str, Any]:
220
+ if not image_sources:
221
+ raise ValueError("At least one image source is required")
222
+
223
+ content: list[dict[str, Any]] = [{"image": source} for source in image_sources]
224
+ content.append({"text": prompt})
225
+
226
+ payload: dict[str, Any] = {
227
+ "model": model,
228
+ "input": {
229
+ "messages": [
230
+ {
231
+ "role": "user",
232
+ "content": content,
233
+ }
234
+ ]
235
+ },
236
+ "parameters": {
237
+ "prompt_extend": bool(prompt_extend),
238
+ "watermark": bool(watermark),
239
+ },
240
+ }
241
+ parameters = payload["parameters"]
242
+ if negative_prompt:
243
+ parameters["negative_prompt"] = negative_prompt
244
+ if size:
245
+ parameters["size"] = size
246
+ if n is not None:
247
+ parameters["n"] = int(n)
248
+ return payload
249
+
250
+
251
+ def _post_json(url: str, api_key: str, payload: dict[str, Any], timeout_s: float, *, use_proxy: bool = False) -> dict[str, Any]:
252
+ body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
253
+ request = Request(
254
+ url,
255
+ data=body,
256
+ headers={
257
+ "Content-Type": "application/json",
258
+ "Authorization": f"Bearer {api_key}",
259
+ },
260
+ method="POST",
261
+ )
262
+ try:
263
+ opener = _build_url_opener(use_proxy)
264
+ with opener.open(request, timeout=timeout_s) as response:
265
+ return json.loads(response.read().decode("utf-8"))
266
+ except HTTPError as exc:
267
+ try:
268
+ details = exc.read().decode("utf-8")
269
+ except Exception:
270
+ details = str(exc)
271
+ raise QwenImageError(
272
+ f"DashScope image request failed with HTTP {exc.code}: {details}",
273
+ category="provider_http_error" if exc.code < 500 else "provider_server_error",
274
+ retryable=exc.code >= 500 or exc.code == 429,
275
+ status_code=exc.code,
276
+ ) from exc
277
+ except URLError as exc:
278
+ raise QwenImageError(
279
+ f"DashScope image request failed: {exc}",
280
+ category="network_error",
281
+ retryable=True,
282
+ ) from exc
283
+
284
+
285
+ def _download_binary(url: str, timeout_s: float, *, use_proxy: bool = False) -> bytes:
286
+ request = Request(url, method="GET")
287
+ try:
288
+ opener = _build_url_opener(use_proxy)
289
+ with opener.open(request, timeout=timeout_s) as response:
290
+ return response.read()
291
+ except HTTPError as exc:
292
+ raise QwenImageError(
293
+ f"Image download failed with HTTP {exc.code}: {url}",
294
+ category="download_http_error" if exc.code < 500 else "download_server_error",
295
+ retryable=exc.code >= 500 or exc.code == 429,
296
+ status_code=exc.code,
297
+ ) from exc
298
+ except URLError as exc:
299
+ raise QwenImageError(
300
+ f"Image download failed: {exc}",
301
+ category="download_network_error",
302
+ retryable=True,
303
+ ) from exc
304
+
305
+
306
+ def _extract_image_urls(response_payload: dict[str, Any]) -> list[str]:
307
+ urls: list[str] = []
308
+
309
+ output = response_payload.get("output") or {}
310
+ choices = output.get("choices") or []
311
+ for choice in choices:
312
+ message = (choice or {}).get("message") or {}
313
+ for item in message.get("content") or []:
314
+ image_url = (item or {}).get("image")
315
+ if image_url:
316
+ urls.append(str(image_url))
317
+
318
+ results = output.get("results") or []
319
+ for item in results:
320
+ image_url = (item or {}).get("url")
321
+ if image_url:
322
+ urls.append(str(image_url))
323
+
324
+ deduped: list[str] = []
325
+ seen: set[str] = set()
326
+ for url in urls:
327
+ if url in seen:
328
+ continue
329
+ seen.add(url)
330
+ deduped.append(url)
331
+ return deduped
332
+
333
+
334
+ def _resolve_image_input_sources(image_paths: list[str | Path]) -> list[str]:
335
+ sources: list[str] = []
336
+ for raw_path in image_paths:
337
+ path = Path(raw_path).expanduser().resolve()
338
+ if not path.is_file():
339
+ raise QwenImageError(f"Image input not found: {raw_path}", category="input_not_found")
340
+ mime_type = _guess_mime_type(path)
341
+ if not mime_type.startswith("image/"):
342
+ raise QwenImageError(
343
+ f"Unsupported image input type: {raw_path} ({mime_type})",
344
+ category="input_type_error",
345
+ )
346
+ sources.append(_file_to_data_url(path))
347
+ if not (1 <= len(sources) <= 3):
348
+ raise QwenImageError("Qwen image edit requires 1 to 3 input images", category="input_count_error")
349
+ return sources
350
+
351
+
352
+ def _suggest_filename(image_url: str, request_id: str, index: int, prefix: str | None) -> str:
353
+ parsed = urlparse(image_url)
354
+ basename = Path(unquote(parsed.path)).name or f"image-{index}.png"
355
+ stem = Path(basename).stem or f"image-{index}"
356
+ suffix = Path(basename).suffix or ".png"
357
+ safe_prefix = (prefix or request_id or "generated-image").strip().replace(" ", "-")
358
+ safe_prefix = "".join(ch for ch in safe_prefix if ch.isalnum() or ch in {"-", "_"}).strip("-_") or "generated-image"
359
+ return f"{safe_prefix}-{index}{suffix}" if stem == safe_prefix else f"{safe_prefix}-{stem}{suffix}"
360
+
361
+
362
+ def summarize_image_operation_result(
363
+ result: Mapping[str, Any], *, operation: str, input_paths: list[str] | None = None
364
+ ) -> dict[str, Any]:
365
+ images = result.get("images") or []
366
+ image_items: list[dict[str, Any]] = []
367
+ output_paths: list[str] = []
368
+ for item in images:
369
+ if not isinstance(item, Mapping):
370
+ continue
371
+ path_value = item.get("path")
372
+ source_url = item.get("source_url")
373
+ image_info: dict[str, Any] = {}
374
+ if path_value:
375
+ image_info["path"] = str(path_value)
376
+ output_paths.append(str(path_value))
377
+ if source_url:
378
+ image_info["source_url"] = str(source_url)
379
+ if image_info:
380
+ image_items.append(image_info)
381
+
382
+ summary: dict[str, Any] = {
383
+ "ok": True,
384
+ "operation": operation,
385
+ "provider": result.get("provider"),
386
+ "model": result.get("model"),
387
+ "request_id": result.get("request_id"),
388
+ "image_count": len(image_items),
389
+ "paths": output_paths,
390
+ "images": image_items,
391
+ }
392
+ if output_paths:
393
+ summary["primary_path"] = output_paths[0]
394
+ if input_paths is not None:
395
+ summary["input_paths"] = [str(path) for path in input_paths]
396
+ if result.get("width") is not None:
397
+ summary["width"] = result.get("width")
398
+ if result.get("height") is not None:
399
+ summary["height"] = result.get("height")
400
+
401
+ usage = result.get("usage")
402
+ if isinstance(usage, Mapping) and usage:
403
+ summary["usage"] = dict(usage)
404
+
405
+ return summary
406
+
407
+
408
+ def summarize_image_operation_error(
409
+ exc: Exception,
410
+ *,
411
+ operation: str,
412
+ input_paths: list[str] | None = None,
413
+ ) -> dict[str, Any]:
414
+ category = "unknown_error"
415
+ retryable = False
416
+ status_code: int | None = None
417
+
418
+ if isinstance(exc, QwenImageError):
419
+ category = exc.category
420
+ retryable = exc.retryable
421
+ status_code = exc.status_code
422
+ elif isinstance(exc, FileNotFoundError):
423
+ category = "path_not_found"
424
+ elif isinstance(exc, ValueError):
425
+ category = "invalid_input"
426
+
427
+ summary: dict[str, Any] = {
428
+ "ok": False,
429
+ "operation": operation,
430
+ "error": {
431
+ "category": category,
432
+ "message": str(exc),
433
+ "retryable": retryable,
434
+ },
435
+ }
436
+ if input_paths is not None:
437
+ summary["input_paths"] = [str(path) for path in input_paths]
438
+ if status_code is not None:
439
+ summary["error"]["status_code"] = status_code
440
+ return summary
441
+
442
+
443
+ def generate_image_with_qwen(
444
+ config: QwenImageConfig,
445
+ *,
446
+ prompt: str,
447
+ output_dir: Path,
448
+ negative_prompt: str | None = None,
449
+ size: str | None = None,
450
+ prompt_extend: bool | None = None,
451
+ watermark: bool | None = None,
452
+ filename_prefix: str | None = None,
453
+ workspace_root: Path | None = None,
454
+ ) -> dict[str, Any]:
455
+ if not config.enabled:
456
+ raise QwenImageError("Qwen image generation is not configured", category="configuration_error")
457
+ if not prompt or not prompt.strip():
458
+ raise QwenImageError("prompt is required", category="invalid_input")
459
+
460
+ payload = build_qwen_image_payload(
461
+ model=config.model,
462
+ prompt=prompt.strip(),
463
+ negative_prompt=(negative_prompt or "").strip() or None,
464
+ size=(size or config.default_size or "").strip() or None,
465
+ prompt_extend=config.prompt_extend if prompt_extend is None else bool(prompt_extend),
466
+ watermark=config.watermark if watermark is None else bool(watermark),
467
+ )
468
+ response_payload = _post_json(config.endpoint, config.api_key, payload, config.timeout_s, use_proxy=config.use_proxy)
469
+ image_urls = _extract_image_urls(response_payload)
470
+ if not image_urls:
471
+ raise QwenImageError(
472
+ f"DashScope returned no image URL: {json.dumps(response_payload, ensure_ascii=False)}",
473
+ category="empty_result",
474
+ )
475
+
476
+ output_dir.mkdir(parents=True, exist_ok=True)
477
+ request_id = str(response_payload.get("request_id") or int(time.time()))
478
+ saved_images: list[dict[str, Any]] = []
479
+ for index, image_url in enumerate(image_urls, start=1):
480
+ file_name = _suggest_filename(image_url, request_id, index, filename_prefix)
481
+ destination = output_dir / file_name
482
+ destination.write_bytes(_download_binary(image_url, config.timeout_s, use_proxy=config.use_proxy))
483
+
484
+ path_value = str(destination)
485
+ if workspace_root is not None:
486
+ try:
487
+ path_value = destination.resolve().relative_to(workspace_root.resolve()).as_posix()
488
+ except Exception:
489
+ path_value = str(destination)
490
+
491
+ saved_images.append(
492
+ {
493
+ "path": path_value,
494
+ "source_url": image_url,
495
+ }
496
+ )
497
+
498
+ usage = response_payload.get("usage") or {}
499
+ return {
500
+ "provider": "dashscope",
501
+ "model": config.model,
502
+ "request_id": response_payload.get("request_id"),
503
+ "images": saved_images,
504
+ "usage": usage,
505
+ "width": usage.get("width"),
506
+ "height": usage.get("height"),
507
+ "raw_response": response_payload,
508
+ }
509
+
510
+
511
+ def edit_image_with_qwen(
512
+ config: QwenImageEditConfig,
513
+ *,
514
+ prompt: str,
515
+ image_paths: list[str | Path],
516
+ output_dir: Path,
517
+ negative_prompt: str | None = None,
518
+ size: str | None = None,
519
+ n: int | None = None,
520
+ prompt_extend: bool | None = None,
521
+ watermark: bool | None = None,
522
+ filename_prefix: str | None = None,
523
+ workspace_root: Path | None = None,
524
+ ) -> dict[str, Any]:
525
+ if not config.enabled:
526
+ raise QwenImageError("Qwen image edit is not configured", category="configuration_error")
527
+ if not prompt or not prompt.strip():
528
+ raise QwenImageError("prompt is required", category="invalid_input")
529
+
530
+ image_sources = _resolve_image_input_sources(image_paths)
531
+ payload = build_qwen_image_edit_payload(
532
+ model=config.model,
533
+ prompt=prompt.strip(),
534
+ image_sources=image_sources,
535
+ negative_prompt=(negative_prompt or "").strip() or None,
536
+ size=(size or config.default_size or "").strip() or None,
537
+ n=n,
538
+ prompt_extend=config.prompt_extend if prompt_extend is None else bool(prompt_extend),
539
+ watermark=config.watermark if watermark is None else bool(watermark),
540
+ )
541
+ response_payload = _post_json(config.endpoint, config.api_key, payload, config.timeout_s, use_proxy=config.use_proxy)
542
+ image_urls = _extract_image_urls(response_payload)
543
+ if not image_urls:
544
+ raise QwenImageError(
545
+ f"DashScope returned no edited image URL: {json.dumps(response_payload, ensure_ascii=False)}",
546
+ category="empty_result",
547
+ )
548
+
549
+ output_dir.mkdir(parents=True, exist_ok=True)
550
+ request_id = str(response_payload.get("request_id") or int(time.time()))
551
+ saved_images: list[dict[str, Any]] = []
552
+ for index, image_url in enumerate(image_urls, start=1):
553
+ file_name = _suggest_filename(image_url, request_id, index, filename_prefix)
554
+ destination = output_dir / file_name
555
+ destination.write_bytes(_download_binary(image_url, config.timeout_s, use_proxy=config.use_proxy))
556
+
557
+ path_value = str(destination)
558
+ if workspace_root is not None:
559
+ try:
560
+ path_value = destination.resolve().relative_to(workspace_root.resolve()).as_posix()
561
+ except Exception:
562
+ path_value = str(destination)
563
+
564
+ saved_images.append({"path": path_value, "source_url": image_url})
565
+
566
+ usage = response_payload.get("usage") or {}
567
+ return {
568
+ "provider": "dashscope",
569
+ "model": config.model,
570
+ "request_id": response_payload.get("request_id"),
571
+ "images": saved_images,
572
+ "usage": usage,
573
+ "width": usage.get("width"),
574
+ "height": usage.get("height"),
575
+ "raw_response": response_payload,
576
+ }