kiwimatecoder 0.1.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 (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/media.py ADDED
@@ -0,0 +1,904 @@
1
+ """Image and video generation through provider media APIs.
2
+
3
+ Generation is opt-in (``media.enabled``) and approval-gated when driven by the
4
+ ``generate_image`` / ``generate_video`` tools, because a call costs money.
5
+ Requests reuse the model provider registry for auth
6
+ (``key_header``/``key_prefix``/``extra_headers``), API versioning
7
+ (``api_version``), and network transport (proxy/CA/offline) so media endpoints
8
+ behave exactly like chat endpoints.
9
+
10
+ Images
11
+ ``POST <base>/images/generations`` (OpenAI-compatible) or ``POST
12
+ <base>/images`` (OpenRouter). Responses carry either ``data[0].b64_json`` or
13
+ a ``data[0].url`` that is downloaded with a bounded size. The decoded image
14
+ is written atomically under the workspace at
15
+ ``<output_dir>/<YYYYmmdd_HHMMSS>-<slug>.png``; callers attach the result to
16
+ ``session.pending_images`` best-effort via :func:`attach_result`.
17
+
18
+ Video
19
+ Video generation is an asynchronous job: ``POST <base>/videos`` returns a
20
+ job id, ``GET <base>/videos/<id>`` is polled until it completes, and the
21
+ finished file is streamed to ``<output_dir>/<stamp>-<slug>.mp4`` with a
22
+ bounded size. A job that is still running when we stop waiting (timeout or
23
+ Ctrl+C) keeps running -- and billing -- on the provider, so its id is
24
+ surfaced and :func:`resume_video` can pick it up again.
25
+
26
+ Two wire dialects are spoken, chosen from the provider's host: OpenRouter's
27
+ (``duration``/``resolution``/``frame_images``, ``unsigned_urls`` on
28
+ completion) and the OpenAI Videos API's (``seconds``/``size``, multipart
29
+ form, ``/videos/<id>/content``).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import base64
35
+ import datetime
36
+ import json
37
+ import os
38
+ import re
39
+ import time
40
+ from collections.abc import Callable
41
+ from dataclasses import dataclass
42
+ from pathlib import Path
43
+ from typing import TYPE_CHECKING, Any
44
+ from urllib.parse import urlparse
45
+
46
+ import httpx
47
+
48
+ from kiwimatecoder import config, images, network
49
+ from kiwimatecoder.redaction import redact
50
+ from kiwimatecoder.web import is_local_address
51
+
52
+ if TYPE_CHECKING:
53
+ from kiwimatecoder.providers import ProviderConfig
54
+ from kiwimatecoder.session import Session
55
+
56
+ REQUEST_TIMEOUT = 120.0
57
+ MAX_IMAGE_BYTES = 20 * 1024 * 1024
58
+ MAX_VIDEO_BYTES = 500 * 1024 * 1024
59
+ VIDEO_POLL_INTERVAL = 10.0
60
+ VIDEO_MAX_WAIT = 15 * 60.0
61
+ # Consecutive failed status polls tolerated before giving up on a job.
62
+ _VIDEO_POLL_RETRIES = 3
63
+ _SLUG_MAX = 40
64
+ _JOB_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
65
+ _VIDEO_DONE = {"completed", "succeeded"}
66
+ _VIDEO_FAILED = {"failed", "cancelled", "canceled", "expired", "error"}
67
+ IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".webp")
68
+ VIDEO_SUFFIXES = (".mp4", ".webm", ".mov")
69
+ _DISABLED_MESSAGE = (
70
+ "Media generation is disabled. Enable it with `/config media enable on` "
71
+ "(or `kiwimatecoder config media enable on`)."
72
+ )
73
+ NO_IMAGE_MODEL_MESSAGE = (
74
+ "No image model chosen. Pick one with `/image model` "
75
+ "(or `kiwimatecoder config media model <id>`)."
76
+ )
77
+ NO_VIDEO_MODEL_MESSAGE = (
78
+ "No video model chosen. Pick one with `/video model` "
79
+ "(or `kiwimatecoder config media video-model <id>`)."
80
+ )
81
+
82
+
83
+ class MediaError(RuntimeError):
84
+ """Raised when media cannot be generated; the message is user-facing."""
85
+
86
+
87
+ class VideoJobError(MediaError):
88
+ """A video job exists on the provider but we have no file for it yet.
89
+
90
+ Raised when we stopped waiting (timeout, lost connection) or could not fetch
91
+ the finished file. Carries ``job_id`` so the caller can offer
92
+ :func:`resume_video`.
93
+ """
94
+
95
+ def __init__(self, message: str, job_id: str) -> None:
96
+ super().__init__(message)
97
+ self.job_id = job_id
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class MediaResult:
102
+ """One generated image written to disk."""
103
+
104
+ path: Path
105
+ revised_prompt: str | None
106
+ model: str
107
+ bytes: int
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class VideoResult:
112
+ """One generated video written to disk."""
113
+
114
+ path: Path
115
+ job_id: str
116
+ model: str
117
+ bytes: int
118
+ cost: float | None = None
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class VideoProgress:
123
+ """A progress tick passed to the ``progress`` callback while polling."""
124
+
125
+ job_id: str
126
+ status: str
127
+ elapsed: float
128
+ percent: int | None = None
129
+
130
+
131
+ ProgressCallback = Callable[[VideoProgress], None]
132
+
133
+
134
+ def format_bytes(count: int) -> str:
135
+ """Human-readable size, e.g. ``1.4 MB``."""
136
+ size = float(count)
137
+ for unit in ("B", "KB", "MB", "GB"):
138
+ if size < 1024 or unit == "GB":
139
+ return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}"
140
+ size /= 1024
141
+ return f"{count} B" # pragma: no cover - loop always returns
142
+
143
+
144
+ def format_models(settings: dict[str, Any]) -> list[str]:
145
+ """One plain-text line per provider that has a chosen image or video model."""
146
+ lines: list[str] = []
147
+ for provider_id, kinds in sorted(settings.get("models", {}).items()):
148
+ image = kinds.get("image") or "none chosen"
149
+ video = kinds.get("video") or "none chosen"
150
+ lines.append(f"{provider_id}: image {image}, video {video}")
151
+ return lines
152
+
153
+
154
+ def _client(timeout: float) -> httpx.Client:
155
+ """Build an httpx client honoring proxy/CA settings (tests patch this)."""
156
+ return httpx.Client(timeout=timeout, **network.current_options())
157
+
158
+
159
+ def _sleep(seconds: float) -> None:
160
+ """Indirection so tests can poll without waiting."""
161
+ time.sleep(seconds)
162
+
163
+
164
+ def _host(url: str) -> str:
165
+ return (urlparse(url).hostname or "").lower()
166
+
167
+
168
+ def _is_openrouter(provider: ProviderConfig) -> bool:
169
+ host = _host(provider.base_url)
170
+ return host == "openrouter.ai" or host.endswith(".openrouter.ai")
171
+
172
+
173
+ def _headers(
174
+ provider: ProviderConfig, key: str | None, *, json_body: bool = True
175
+ ) -> dict[str, str]:
176
+ headers = {"Content-Type": "application/json"} if json_body else {}
177
+ if key:
178
+ if provider.compat == "anthropic":
179
+ headers["anthropic-version"] = "2023-06-01"
180
+ headers["x-api-key"] = key
181
+ else:
182
+ headers[provider.key_header] = f"{provider.key_prefix}{key}"
183
+ headers.update(provider.extra_headers)
184
+ return headers
185
+
186
+
187
+ def _payload(model: str, prompt: str, size: str) -> dict[str, Any]:
188
+ payload: dict[str, Any] = {
189
+ "model": model,
190
+ "prompt": prompt,
191
+ "size": size,
192
+ "n": 1,
193
+ }
194
+ # DALL·E defaults to a URL response; gpt-image models always return b64.
195
+ if "dall-e" in model.lower():
196
+ payload["response_format"] = "b64_json"
197
+ return payload
198
+
199
+
200
+ def _slug(prompt: str) -> str:
201
+ cleaned = re.sub(r"[^a-z0-9]+", "-", prompt.lower()).strip("-")
202
+ cleaned = cleaned[:_SLUG_MAX].strip("-")
203
+ return cleaned or "image"
204
+
205
+
206
+ def _atomic_write_bytes(path: Path, data: bytes) -> None:
207
+ tmp = path.with_name(path.name + ".kiwi.tmp")
208
+ try:
209
+ tmp.write_bytes(data)
210
+ os.replace(tmp, path)
211
+ except OSError:
212
+ try:
213
+ tmp.unlink()
214
+ except OSError:
215
+ pass
216
+ raise
217
+
218
+
219
+ def _output_dir(workspace_root: Path | str, output_dir: str) -> Path:
220
+ """Resolve the media directory, refusing anything outside the workspace."""
221
+ root = Path(workspace_root).resolve()
222
+ target_dir = (root / output_dir).resolve()
223
+ if target_dir != root and not target_dir.is_relative_to(root):
224
+ raise MediaError(
225
+ f"Media output directory '{output_dir}' is outside the workspace root."
226
+ )
227
+ return target_dir
228
+
229
+
230
+ def _reserve_path(
231
+ workspace_root: Path | str, output_dir: str, label: str, suffix: str
232
+ ) -> Path:
233
+ """Create the output directory and return a free ``<stamp>-<slug><suffix>``."""
234
+ target_dir = _output_dir(workspace_root, output_dir)
235
+ try:
236
+ target_dir.mkdir(parents=True, exist_ok=True)
237
+ except OSError as exc:
238
+ raise MediaError(f"Could not create media directory '{target_dir}': {exc}") from exc
239
+ stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
240
+ slug = _slug(label)
241
+ path = target_dir / f"{stamp}-{slug}{suffix}"
242
+ counter = 1
243
+ while path.exists():
244
+ counter += 1
245
+ path = target_dir / f"{stamp}-{slug}-{counter}{suffix}"
246
+ return path
247
+
248
+
249
+ def _write_image(workspace_root: Path | str, output_dir: str, prompt: str, data: bytes) -> Path:
250
+ path = _reserve_path(workspace_root, output_dir, prompt, ".png")
251
+ try:
252
+ _atomic_write_bytes(path, data)
253
+ except OSError as exc:
254
+ raise MediaError(f"Could not save the generated image to '{path}': {exc}") from exc
255
+ return path
256
+
257
+
258
+ def _guard_download(url: str, what: str) -> None:
259
+ """Offline and local-address guards shared by image and video downloads."""
260
+ if config.offline_enabled():
261
+ raise MediaError(network.offline_message(what))
262
+ if is_local_address(url) and not config.get_web()["allow_local"]:
263
+ raise MediaError(
264
+ f"Refusing to download the {what.split()[0]} from a local or private "
265
+ "address. Enable it with `/config web allow-local on`."
266
+ )
267
+
268
+
269
+ def _download_image(url: str) -> bytes:
270
+ """Download an image URL with a bounded size and local/offline guards."""
271
+ _guard_download(url, "image download")
272
+ try:
273
+ with _client(REQUEST_TIMEOUT) as client:
274
+ with client.stream("GET", url) as response:
275
+ if response.status_code != 200:
276
+ raise MediaError(
277
+ f"Image download returned HTTP {response.status_code}."
278
+ )
279
+ data = bytearray()
280
+ for chunk in response.iter_bytes():
281
+ data.extend(chunk)
282
+ if len(data) > MAX_IMAGE_BYTES:
283
+ raise MediaError(
284
+ "Image download exceeded the "
285
+ f"{MAX_IMAGE_BYTES:,}-byte limit."
286
+ )
287
+ except httpx.HTTPError as exc:
288
+ raise MediaError(
289
+ f"Image download failed: {exc.__class__.__name__}."
290
+ ) from exc
291
+ return bytes(data)
292
+
293
+
294
+ def _decode_b64(payload: str) -> bytes:
295
+ try:
296
+ return base64.b64decode(payload, validate=False)
297
+ except (ValueError, TypeError) as exc:
298
+ raise MediaError("The provider returned an invalid base64 image.") from exc
299
+
300
+
301
+ def _first_image(response: httpx.Response, provider_name: str) -> dict[str, Any]:
302
+ try:
303
+ body = response.json()
304
+ except (json.JSONDecodeError, ValueError) as exc:
305
+ raise MediaError(
306
+ f"{provider_name} returned a non-JSON image response."
307
+ ) from exc
308
+ if not isinstance(body, dict):
309
+ raise MediaError(f"{provider_name} returned an unexpected image response.")
310
+ data = body.get("data")
311
+ if not isinstance(data, list) or not data or not isinstance(data[0], dict):
312
+ error = body.get("error")
313
+ if error:
314
+ raise MediaError(f"{provider_name} image error: {redact(str(error))[:500]}")
315
+ raise MediaError(f"{provider_name} returned no image data.")
316
+ return data[0]
317
+
318
+
319
+ def _resolve_provider(
320
+ provider_id: str | None, cfg: dict[str, Any] | None, what: str
321
+ ) -> tuple[dict[str, Any], str, ProviderConfig, str | None]:
322
+ """Common preflight: enabled, known provider, online, and an API key.
323
+
324
+ Returns ``(settings, provider_id, provider, key)``. Every failure is a
325
+ :class:`MediaError` whose message says how to fix it.
326
+ """
327
+ settings = config.get_media(cfg)
328
+ if not settings["enabled"]:
329
+ raise MediaError(_DISABLED_MESSAGE)
330
+
331
+ resolved_provider = str(provider_id or settings["provider"]).strip()
332
+ try:
333
+ provider = config.get_provider_config(resolved_provider, cfg)
334
+ except KeyError as exc:
335
+ raise MediaError(str(exc) or f"Unknown provider '{resolved_provider}'.") from exc
336
+
337
+ if config.offline_enabled(cfg):
338
+ raise MediaError(network.offline_message(what))
339
+
340
+ key = config.get_key(resolved_provider)
341
+ if not key and provider.needs_key:
342
+ raise MediaError(
343
+ f"No API key for {provider.name}. Set one with "
344
+ f"`config key set {provider.id} <KEY>` or the "
345
+ f"{provider.key_env} environment variable."
346
+ )
347
+ return settings, resolved_provider, provider, key
348
+
349
+
350
+ def readiness(*, video: bool = False, cfg: dict[str, Any] | None = None) -> list[str]:
351
+ """Why generation would fail right now; an empty list means ready."""
352
+ problems: list[str] = []
353
+ try:
354
+ settings, *_ = _resolve_provider(
355
+ None, cfg, "video generation" if video else "image generation"
356
+ )
357
+ except MediaError as exc:
358
+ problems.append(str(exc))
359
+ settings = config.get_media(cfg)
360
+ kind = "video" if video else "image"
361
+ if not config.get_media_model(settings["provider"], kind, cfg):
362
+ problems.append(NO_VIDEO_MODEL_MESSAGE if video else NO_IMAGE_MODEL_MESSAGE)
363
+ return problems
364
+
365
+
366
+ def list_outputs(
367
+ session: Session, limit: int = 10, cfg: dict[str, Any] | None = None
368
+ ) -> list[Path]:
369
+ """Most recent generated images and videos, newest first."""
370
+ settings = config.get_media(cfg)
371
+ try:
372
+ directory = _output_dir(session.workspace_root, str(settings["output_dir"]))
373
+ except MediaError:
374
+ return []
375
+ if not directory.is_dir():
376
+ return []
377
+ found: list[tuple[float, Path]] = []
378
+ try:
379
+ for entry in directory.iterdir():
380
+ if entry.suffix.lower() in IMAGE_SUFFIXES + VIDEO_SUFFIXES and entry.is_file():
381
+ try:
382
+ found.append((entry.stat().st_mtime, entry))
383
+ except OSError:
384
+ continue
385
+ except OSError:
386
+ return []
387
+ found.sort(key=lambda item: (item[0], item[1].name), reverse=True)
388
+ return [path for _, path in found[: max(limit, 0)]]
389
+
390
+
391
+ def generate_image(
392
+ prompt: str,
393
+ *,
394
+ session: Session,
395
+ size: str | None = None,
396
+ model: str | None = None,
397
+ provider_id: str | None = None,
398
+ cfg: dict[str, Any] | None = None,
399
+ ) -> MediaResult:
400
+ """Generate one image and return where it was saved.
401
+
402
+ Raises :class:`MediaError` with a user-facing message for every failure;
403
+ raw httpx errors are never leaked. The caller decides whether to attach the
404
+ result to the conversation (see :func:`attach_result`).
405
+ """
406
+ text = str(prompt or "").strip()
407
+ if not text:
408
+ raise MediaError("A prompt is required to generate an image.")
409
+
410
+ settings, resolved_provider, provider, key = _resolve_provider(
411
+ provider_id, cfg, "image generation"
412
+ )
413
+
414
+ chosen_model = str(
415
+ model or config.get_media_model(resolved_provider, "image", cfg)
416
+ ).strip()
417
+ if not chosen_model:
418
+ raise MediaError(NO_IMAGE_MODEL_MESSAGE)
419
+ chosen_size = str(size or settings["size"]).strip()
420
+ if not config.MEDIA_SIZE_RE.match(chosen_size):
421
+ raise MediaError("Image size must look like WxH, e.g. 1024x1024.")
422
+
423
+ route = "images" if _is_openrouter(provider) else "images/generations"
424
+ url = provider.versioned_url(f"{provider.base_url.rstrip('/')}/{route}")
425
+ payload = _payload(chosen_model, text, chosen_size)
426
+ try:
427
+ with _client(REQUEST_TIMEOUT) as client:
428
+ response = client.post(url, json=payload, headers=_headers(provider, key))
429
+ except httpx.HTTPError as exc:
430
+ raise MediaError(
431
+ f"{provider.name} image request failed: {exc.__class__.__name__}."
432
+ ) from exc
433
+
434
+ if response.status_code != 200:
435
+ body = response.text[:500]
436
+ raise MediaError(
437
+ f"{provider.name} image generation returned HTTP "
438
+ f"{response.status_code}: {redact(body)}"
439
+ )
440
+
441
+ item = _first_image(response, provider.name)
442
+ revised = item.get("revised_prompt")
443
+ revised_prompt = str(revised) if revised else None
444
+
445
+ b64_payload = item.get("b64_json")
446
+ if isinstance(b64_payload, str) and b64_payload:
447
+ data = _decode_b64(b64_payload)
448
+ else:
449
+ image_url = item.get("url")
450
+ if not isinstance(image_url, str) or not image_url:
451
+ raise MediaError(
452
+ "The provider response contained neither b64_json nor url."
453
+ )
454
+ data = _download_image(image_url)
455
+ if not data:
456
+ raise MediaError("The provider returned an empty image.")
457
+
458
+ path = _write_image(
459
+ session.workspace_root, str(settings["output_dir"]), text, data
460
+ )
461
+ return MediaResult(
462
+ path=path,
463
+ revised_prompt=revised_prompt,
464
+ model=chosen_model,
465
+ bytes=len(data),
466
+ )
467
+
468
+
469
+ def attach_result(result: MediaResult, session: Session) -> str:
470
+ """Best-effort attach a generated image; returns a note ("" on success).
471
+
472
+ Honors the vision size/count limits. A failure to attach never fails the
473
+ generation itself -- the caller has the path either way.
474
+ """
475
+ try:
476
+ vision = config.get_vision()
477
+ limit = int(vision["max_images_per_turn"])
478
+ if len(session.pending_images) >= limit:
479
+ return (
480
+ f"Not attached: {limit} image(s) already pending. Send them "
481
+ "first, then generate again."
482
+ )
483
+ entry = images.encode_image(result.path, int(vision["max_image_bytes"]))
484
+ except (ValueError, OSError) as exc:
485
+ return f"Not attached: {exc}"
486
+ session.pending_images.append(entry)
487
+ return ""
488
+
489
+
490
+ # ---------------------------------------------------------------------------
491
+ # Video
492
+ # ---------------------------------------------------------------------------
493
+
494
+
495
+ def normalize_video_size(size: str) -> str:
496
+ """Canonical casing for a video size: ``720p``/``1080p`` and ``2K``/``4K``."""
497
+ text = size.strip()
498
+ if "x" in text.lower():
499
+ return text.lower()
500
+ return text.lower() if text.lower().endswith("p") else text.upper()
501
+
502
+
503
+ def _video_payload(
504
+ provider: ProviderConfig,
505
+ model: str,
506
+ prompt: str,
507
+ duration: int,
508
+ size: str,
509
+ first_frame_url: str | None,
510
+ ) -> dict[str, Any]:
511
+ if _is_openrouter(provider):
512
+ payload: dict[str, Any] = {
513
+ "model": model,
514
+ "prompt": prompt,
515
+ "duration": duration,
516
+ }
517
+ if size:
518
+ payload["size" if "x" in size else "resolution"] = size
519
+ if first_frame_url:
520
+ payload["frame_images"] = [
521
+ {
522
+ "type": "image_url",
523
+ "image_url": {"url": first_frame_url},
524
+ "frame_type": "first_frame",
525
+ }
526
+ ]
527
+ return payload
528
+ if first_frame_url:
529
+ raise MediaError(
530
+ f"{provider.name} video generation does not take a first-frame image "
531
+ "here. Use an OpenRouter video model for image-to-video."
532
+ )
533
+ payload = {"model": model, "prompt": prompt, "seconds": str(duration)}
534
+ if size:
535
+ if "x" not in size:
536
+ raise MediaError(
537
+ f"{provider.name} needs an exact video size such as 1280x720, "
538
+ f"not '{size}'."
539
+ )
540
+ payload["size"] = size
541
+ return payload
542
+
543
+
544
+ def _json_object(response: httpx.Response, provider_name: str) -> dict[str, Any]:
545
+ try:
546
+ body = response.json()
547
+ except (json.JSONDecodeError, ValueError) as exc:
548
+ raise MediaError(f"{provider_name} returned a non-JSON video response.") from exc
549
+ if not isinstance(body, dict):
550
+ raise MediaError(f"{provider_name} returned an unexpected video response.")
551
+ return body
552
+
553
+
554
+ def _error_text(body: dict[str, Any]) -> str:
555
+ error = body.get("error")
556
+ if isinstance(error, dict):
557
+ error = error.get("message") or error
558
+ return redact(str(error or "no reason given"))[:500]
559
+
560
+
561
+ def _poll_url(provider: ProviderConfig, job_id: str, advertised: object) -> str:
562
+ """Where to poll: the provider's ``polling_url`` only if it stays on-host."""
563
+ base = provider.base_url.rstrip("/")
564
+ fallback = provider.versioned_url(f"{base}/videos/{job_id}")
565
+ if isinstance(advertised, str) and advertised:
566
+ if _host(advertised) == _host(provider.base_url):
567
+ return advertised
568
+ return fallback
569
+
570
+
571
+ def _download_video(
572
+ url: str,
573
+ dest: Path,
574
+ headers: dict[str, str],
575
+ *,
576
+ send_auth: bool,
577
+ ) -> tuple[int, bytes]:
578
+ """Stream a video to ``dest`` (atomically) with a size bound.
579
+
580
+ Returns ``(bytes written, first 16 bytes)``; the head is used to pick the
581
+ file extension without re-reading a large file.
582
+ """
583
+ _guard_download(url, "video download")
584
+ tmp = dest.with_name(dest.name + ".kiwi.tmp")
585
+ total = 0
586
+ head = b""
587
+ try:
588
+ with _client(REQUEST_TIMEOUT) as client:
589
+ with client.stream("GET", url, headers=headers if send_auth else {}) as response:
590
+ if response.status_code != 200:
591
+ raise MediaError(
592
+ f"Video download returned HTTP {response.status_code}."
593
+ )
594
+ with tmp.open("wb") as handle:
595
+ for chunk in response.iter_bytes():
596
+ if len(head) < 16:
597
+ head = (head + chunk)[:16]
598
+ total += len(chunk)
599
+ if total > MAX_VIDEO_BYTES:
600
+ raise MediaError(
601
+ "Video download exceeded the "
602
+ f"{MAX_VIDEO_BYTES:,}-byte limit."
603
+ )
604
+ handle.write(chunk)
605
+ if total == 0:
606
+ raise MediaError("The provider returned an empty video.")
607
+ if not _looks_like_video(head):
608
+ raise MediaError("The provider returned a file that is not a video.")
609
+ os.replace(tmp, dest)
610
+ except httpx.HTTPError as exc:
611
+ raise MediaError(f"Video download failed: {exc.__class__.__name__}.") from exc
612
+ except OSError as exc:
613
+ raise MediaError(f"Could not save the generated video to '{dest}': {exc}") from exc
614
+ finally:
615
+ try:
616
+ tmp.unlink()
617
+ except OSError:
618
+ pass
619
+ return total, head
620
+
621
+
622
+ def _looks_like_video(head: bytes) -> bool:
623
+ """MP4/MOV (``ftyp`` box) or WebM/Matroska (EBML header)."""
624
+ return head[4:8] == b"ftyp" or head[:4] == b"\x1a\x45\xdf\xa3"
625
+
626
+
627
+ def _video_suffix(head: bytes) -> str:
628
+ return ".webm" if head[:4] == b"\x1a\x45\xdf\xa3" else ".mp4"
629
+
630
+
631
+ def _await_video(
632
+ provider: ProviderConfig,
633
+ key: str | None,
634
+ job_id: str,
635
+ poll_url: str,
636
+ *,
637
+ progress: ProgressCallback | None,
638
+ poll_interval: float,
639
+ max_wait: float,
640
+ started: float,
641
+ ) -> dict[str, Any]:
642
+ """Poll a job until it completes; returns the final status body."""
643
+ headers = _headers(provider, key, json_body=False)
644
+ failures = 0
645
+ while True:
646
+ status = "unknown"
647
+ percent: int | None = None
648
+ body: dict[str, Any] | None = None
649
+ try:
650
+ with _client(REQUEST_TIMEOUT) as client:
651
+ response = client.get(poll_url, headers=headers)
652
+ except httpx.HTTPError:
653
+ response = None
654
+ if response is not None and response.status_code in (401, 403, 404):
655
+ raise MediaError(
656
+ f"{provider.name} video status check for job {job_id} returned "
657
+ f"HTTP {response.status_code}: {redact(response.text[:300])}"
658
+ )
659
+ if response is not None and response.status_code < 400:
660
+ body = _json_object(response, provider.name)
661
+
662
+ if body is None:
663
+ # Network blip or a 5xx/429: the job is fine, keep polling a while.
664
+ failures += 1
665
+ if failures > _VIDEO_POLL_RETRIES:
666
+ raise VideoJobError(
667
+ f"Lost contact with {provider.name} while the video was "
668
+ f"rendering. Job {job_id} may still finish; resume it with "
669
+ f"`/video resume {job_id}`.",
670
+ job_id,
671
+ )
672
+ else:
673
+ failures = 0
674
+ status = str(body.get("status") or "unknown").lower()
675
+ raw_progress = body.get("progress")
676
+ if isinstance(raw_progress, (int, float)) and not isinstance(raw_progress, bool):
677
+ percent = max(0, min(100, int(raw_progress)))
678
+ if status in _VIDEO_DONE:
679
+ return body
680
+ if status in _VIDEO_FAILED:
681
+ raise MediaError(
682
+ f"{provider.name} video generation {status}: {_error_text(body)}"
683
+ )
684
+
685
+ elapsed = time.monotonic() - started
686
+ if progress is not None:
687
+ try:
688
+ progress(VideoProgress(job_id, status, elapsed, percent))
689
+ except Exception: # noqa: BLE001 - a display glitch must not kill the job
690
+ pass
691
+ if elapsed >= max_wait:
692
+ raise VideoJobError(
693
+ f"Still rendering after {int(elapsed // 60)} min. The job keeps "
694
+ f"running (and billing) on {provider.name}; resume it later with "
695
+ f"`/video resume {job_id}`.",
696
+ job_id,
697
+ )
698
+ _sleep(poll_interval)
699
+
700
+
701
+ def _finish_video(
702
+ provider: ProviderConfig,
703
+ key: str | None,
704
+ settings: dict[str, Any],
705
+ session: Session,
706
+ job_id: str,
707
+ advertised_poll_url: object,
708
+ label: str,
709
+ model: str,
710
+ *,
711
+ progress: ProgressCallback | None,
712
+ poll_interval: float | None,
713
+ max_wait: float | None,
714
+ ) -> VideoResult:
715
+ interval = VIDEO_POLL_INTERVAL if poll_interval is None else poll_interval
716
+ limit = VIDEO_MAX_WAIT if max_wait is None else max_wait
717
+ final = _await_video(
718
+ provider,
719
+ key,
720
+ job_id,
721
+ _poll_url(provider, job_id, advertised_poll_url),
722
+ progress=progress,
723
+ poll_interval=interval,
724
+ max_wait=limit,
725
+ started=time.monotonic(),
726
+ )
727
+
728
+ base = provider.base_url.rstrip("/")
729
+ content_url = provider.versioned_url(f"{base}/videos/{job_id}/content")
730
+ urls = final.get("unsigned_urls")
731
+ download_url = content_url
732
+ if isinstance(urls, list) and urls and isinstance(urls[0], str) and urls[0]:
733
+ download_url = urls[0]
734
+ # Only the provider's own host ever sees the API key.
735
+ send_auth = _host(download_url) == _host(provider.base_url)
736
+
737
+ dest = _reserve_path(
738
+ session.workspace_root, str(settings["output_dir"]), label, ".mp4"
739
+ )
740
+ try:
741
+ size, head = _download_video(
742
+ download_url,
743
+ dest,
744
+ _headers(provider, key, json_body=False),
745
+ send_auth=send_auth,
746
+ )
747
+ except MediaError as exc:
748
+ raise VideoJobError(
749
+ f"{exc} Job {job_id} finished on {provider.name}; once the problem "
750
+ f"is fixed, fetch it with `/video resume {job_id}`.",
751
+ job_id,
752
+ ) from exc
753
+ if _video_suffix(head) != dest.suffix:
754
+ renamed = dest.with_suffix(_video_suffix(head))
755
+ if not renamed.exists():
756
+ os.replace(dest, renamed)
757
+ dest = renamed
758
+
759
+ cost: float | None = None
760
+ usage = final.get("usage")
761
+ if isinstance(usage, dict):
762
+ raw_cost = usage.get("cost")
763
+ if isinstance(raw_cost, (int, float)) and not isinstance(raw_cost, bool):
764
+ cost = float(raw_cost)
765
+ return VideoResult(path=dest, job_id=job_id, model=model, bytes=size, cost=cost)
766
+
767
+
768
+ def generate_video(
769
+ prompt: str,
770
+ *,
771
+ session: Session,
772
+ model: str | None = None,
773
+ duration: int | None = None,
774
+ size: str | None = None,
775
+ first_frame: Path | str | None = None,
776
+ provider_id: str | None = None,
777
+ cfg: dict[str, Any] | None = None,
778
+ progress: ProgressCallback | None = None,
779
+ poll_interval: float | None = None,
780
+ max_wait: float | None = None,
781
+ ) -> VideoResult:
782
+ """Submit a video job, wait for it, and save the result.
783
+
784
+ ``first_frame`` is an already-validated workspace image to animate
785
+ (image-to-video). Raises :class:`MediaError` for every failure and
786
+ :class:`VideoJobError` -- carrying the job id -- when the job is still
787
+ running after ``max_wait`` seconds or the file could not be fetched yet.
788
+ """
789
+ text = str(prompt or "").strip()
790
+ if not text:
791
+ raise MediaError("A prompt is required to generate a video.")
792
+
793
+ settings, resolved_provider, provider, key = _resolve_provider(
794
+ provider_id, cfg, "video generation"
795
+ )
796
+
797
+ chosen_model = str(
798
+ model or config.get_media_model(resolved_provider, "video", cfg)
799
+ ).strip()
800
+ if not chosen_model:
801
+ raise MediaError(NO_VIDEO_MODEL_MESSAGE)
802
+ seconds = settings["video_duration"] if duration is None else duration
803
+ if not isinstance(seconds, int) or isinstance(seconds, bool) or not (
804
+ config.VIDEO_DURATION_MIN <= seconds <= config.VIDEO_DURATION_MAX
805
+ ):
806
+ raise MediaError(
807
+ "Video duration must be whole seconds between "
808
+ f"{config.VIDEO_DURATION_MIN} and {config.VIDEO_DURATION_MAX}."
809
+ )
810
+ chosen_size = str(size or settings["video_size"]).strip()
811
+ if chosen_size and not config.VIDEO_SIZE_RE.match(chosen_size):
812
+ raise MediaError("Video size must be WxH (e.g. 1280x720) or a tier like 720p.")
813
+ chosen_size = normalize_video_size(chosen_size) if chosen_size else ""
814
+
815
+ frame_url: str | None = None
816
+ if first_frame is not None:
817
+ try:
818
+ vision = config.get_vision()
819
+ entry = images.encode_image(Path(first_frame), int(vision["max_image_bytes"]))
820
+ except (ValueError, OSError) as exc:
821
+ raise MediaError(f"Cannot use the first-frame image: {exc}") from exc
822
+ frame_url = f"data:{entry['media_type']};base64,{entry['data']}"
823
+
824
+ payload = _video_payload(provider, chosen_model, text, seconds, chosen_size, frame_url)
825
+ url = provider.versioned_url(f"{provider.base_url.rstrip('/')}/videos")
826
+ started = time.monotonic()
827
+ try:
828
+ with _client(REQUEST_TIMEOUT) as client:
829
+ if _is_openrouter(provider):
830
+ response = client.post(url, json=payload, headers=_headers(provider, key))
831
+ else:
832
+ # The OpenAI Videos API takes a multipart form, not JSON.
833
+ response = client.post(
834
+ url,
835
+ files={name: (None, str(value)) for name, value in payload.items()},
836
+ headers=_headers(provider, key, json_body=False),
837
+ )
838
+ except httpx.HTTPError as exc:
839
+ raise MediaError(
840
+ f"{provider.name} video request failed: {exc.__class__.__name__}."
841
+ ) from exc
842
+ if response.status_code not in (200, 201, 202):
843
+ raise MediaError(
844
+ f"{provider.name} video generation returned HTTP "
845
+ f"{response.status_code}: {redact(response.text[:500])}"
846
+ )
847
+ body = _json_object(response, provider.name)
848
+ job_id = str(body.get("id") or "").strip()
849
+ if not _JOB_ID_RE.match(job_id):
850
+ raise MediaError(f"{provider.name} did not return a usable video job id.")
851
+ status = str(body.get("status") or "pending").lower()
852
+ if status in _VIDEO_FAILED:
853
+ raise MediaError(f"{provider.name} video generation {status}: {_error_text(body)}")
854
+
855
+ if progress is not None:
856
+ try:
857
+ progress(VideoProgress(job_id, "submitted", time.monotonic() - started, None))
858
+ except Exception: # noqa: BLE001
859
+ pass
860
+ return _finish_video(
861
+ provider,
862
+ key,
863
+ settings,
864
+ session,
865
+ job_id,
866
+ body.get("polling_url"),
867
+ f"video {text}",
868
+ chosen_model,
869
+ progress=progress,
870
+ poll_interval=poll_interval,
871
+ max_wait=max_wait,
872
+ )
873
+
874
+
875
+ def resume_video(
876
+ job_id: str,
877
+ *,
878
+ session: Session,
879
+ provider_id: str | None = None,
880
+ cfg: dict[str, Any] | None = None,
881
+ progress: ProgressCallback | None = None,
882
+ poll_interval: float | None = None,
883
+ max_wait: float | None = None,
884
+ ) -> VideoResult:
885
+ """Pick up a video job that was still running when we stopped waiting."""
886
+ cleaned = str(job_id or "").strip()
887
+ if not _JOB_ID_RE.match(cleaned):
888
+ raise MediaError("A valid video job id is required to resume a job.")
889
+ settings, resolved_provider, provider, key = _resolve_provider(
890
+ provider_id, cfg, "video generation"
891
+ )
892
+ return _finish_video(
893
+ provider,
894
+ key,
895
+ settings,
896
+ session,
897
+ cleaned,
898
+ None,
899
+ f"video {cleaned}",
900
+ config.get_media_model(resolved_provider, "video", cfg) or "resumed job",
901
+ progress=progress,
902
+ poll_interval=poll_interval,
903
+ max_wait=max_wait,
904
+ )