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.
- llm_client/__init__.py +0 -0
- llm_client/capabilities.py +162 -0
- llm_client/interface.py +470 -0
- llm_client/llm_factory.py +981 -0
- llm_client/llm_tooling.py +645 -0
- llm_client/llm_utils.py +205 -0
- llm_client/multimodal.py +237 -0
- llm_client/qwen_image.py +576 -0
- llm_client/web_search.py +149 -0
- power_loop/__init__.py +326 -0
- power_loop/agent/__init__.py +6 -0
- power_loop/agent/sink.py +247 -0
- power_loop/agent/stateful_loop.py +363 -0
- power_loop/agent/system_prompt.py +396 -0
- power_loop/agent/types.py +41 -0
- power_loop/contracts/__init__.py +132 -0
- power_loop/contracts/errors.py +140 -0
- power_loop/contracts/event_payloads.py +278 -0
- power_loop/contracts/events.py +86 -0
- power_loop/contracts/handlers.py +45 -0
- power_loop/contracts/hook_contexts.py +265 -0
- power_loop/contracts/hooks.py +64 -0
- power_loop/contracts/messages.py +90 -0
- power_loop/contracts/protocols.py +48 -0
- power_loop/contracts/tools.py +56 -0
- power_loop/core/agent_context.py +94 -0
- power_loop/core/events.py +124 -0
- power_loop/core/hooks.py +122 -0
- power_loop/core/phase.py +217 -0
- power_loop/core/pipeline.py +880 -0
- power_loop/core/runner.py +60 -0
- power_loop/core/state.py +208 -0
- power_loop/runtime/budget.py +179 -0
- power_loop/runtime/cancellation.py +127 -0
- power_loop/runtime/compact.py +300 -0
- power_loop/runtime/env.py +103 -0
- power_loop/runtime/memory.py +107 -0
- power_loop/runtime/provider.py +176 -0
- power_loop/runtime/retry.py +182 -0
- power_loop/runtime/session_store.py +636 -0
- power_loop/runtime/skills.py +201 -0
- power_loop/runtime/spec.py +233 -0
- power_loop/runtime/structured.py +225 -0
- power_loop/tools/__init__.py +51 -0
- power_loop/tools/default_manifest.py +244 -0
- power_loop/tools/default_tools.py +766 -0
- power_loop/tools/registry.py +162 -0
- power_loop/tools/spawn_agent.py +173 -0
- power_loop-0.2.0.dist-info/METADATA +632 -0
- power_loop-0.2.0.dist-info/RECORD +53 -0
- power_loop-0.2.0.dist-info/WHEEL +5 -0
- power_loop-0.2.0.dist-info/licenses/LICENSE +21 -0
- power_loop-0.2.0.dist-info/top_level.txt +2 -0
llm_client/qwen_image.py
ADDED
|
@@ -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
|
+
}
|