elidia-agent-cli 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 (93) hide show
  1. elidia/__init__.py +0 -0
  2. elidia/__main__.py +4 -0
  3. elidia/agent/__init__.py +3 -0
  4. elidia/agent/loop.py +449 -0
  5. elidia/agent/personas.py +130 -0
  6. elidia/agent/portal.py +115 -0
  7. elidia/api/__init__.py +0 -0
  8. elidia/api/client.py +221 -0
  9. elidia/api/streaming.py +55 -0
  10. elidia/auth/__init__.py +0 -0
  11. elidia/auth/keychain.py +86 -0
  12. elidia/cache/__init__.py +1 -0
  13. elidia/cache/lru.py +158 -0
  14. elidia/cli/__init__.py +0 -0
  15. elidia/cli/commands.py +122 -0
  16. elidia/cli/main.py +293 -0
  17. elidia/cli/pager.py +104 -0
  18. elidia/cli/progress.py +173 -0
  19. elidia/cli/renderer.py +32 -0
  20. elidia/cli/repl.py +1234 -0
  21. elidia/cli/themes.py +214 -0
  22. elidia/config/__init__.py +0 -0
  23. elidia/config/defaults.py +5 -0
  24. elidia/config/rules.py +48 -0
  25. elidia/config/settings.py +217 -0
  26. elidia/creative/__init__.py +1 -0
  27. elidia/creative/audio.py +213 -0
  28. elidia/creative/display.py +260 -0
  29. elidia/creative/image.py +142 -0
  30. elidia/creative/local.py +163 -0
  31. elidia/creative/video.py +181 -0
  32. elidia/daemon/__init__.py +0 -0
  33. elidia/daemon/manager.py +349 -0
  34. elidia/db/__init__.py +0 -0
  35. elidia/db/database.py +87 -0
  36. elidia/mcp/__init__.py +0 -0
  37. elidia/mcp/client.py +198 -0
  38. elidia/mcp/config.py +93 -0
  39. elidia/mcp/registry.py +101 -0
  40. elidia/mcp/types.py +43 -0
  41. elidia/memory/__init__.py +3 -0
  42. elidia/memory/auto.py +164 -0
  43. elidia/memory/compaction.py +101 -0
  44. elidia/memory/embeddings.py +47 -0
  45. elidia/memory/outcomes.py +131 -0
  46. elidia/memory/patterns.py +79 -0
  47. elidia/memory/store.py +338 -0
  48. elidia/models/__init__.py +0 -0
  49. elidia/models/adaptive.py +53 -0
  50. elidia/models/router.py +115 -0
  51. elidia/modes/__init__.py +1 -0
  52. elidia/modes/autonomous.py +318 -0
  53. elidia/modes/budget.py +182 -0
  54. elidia/modes/classifier.py +169 -0
  55. elidia/modes/consensus.py +210 -0
  56. elidia/modes/deep_think.py +190 -0
  57. elidia/modes/swarm.py +229 -0
  58. elidia/modes/thinking.py +153 -0
  59. elidia/permissions/__init__.py +0 -0
  60. elidia/permissions/audit.py +138 -0
  61. elidia/permissions/manager.py +199 -0
  62. elidia/permissions/trust.py +63 -0
  63. elidia/rag/__init__.py +4 -0
  64. elidia/rag/chunker.py +216 -0
  65. elidia/rag/engine.py +345 -0
  66. elidia/rag/ingest.py +154 -0
  67. elidia/rag/portal_bridge.py +84 -0
  68. elidia/rag/watcher.py +98 -0
  69. elidia/research/__init__.py +1 -0
  70. elidia/research/export.py +237 -0
  71. elidia/research/orchestrator.py +310 -0
  72. elidia/research/sources.py +223 -0
  73. elidia/session/__init__.py +0 -0
  74. elidia/session/history.py +119 -0
  75. elidia/session/manager.py +82 -0
  76. elidia/tools/__init__.py +28 -0
  77. elidia/tools/base.py +70 -0
  78. elidia/tools/fetch.py +100 -0
  79. elidia/tools/filesystem.py +204 -0
  80. elidia/tools/git.py +156 -0
  81. elidia/tools/search.py +99 -0
  82. elidia/tools/terminal.py +77 -0
  83. elidia/widgets/__init__.py +1 -0
  84. elidia/widgets/protocol.py +248 -0
  85. elidia/widgets/renderer.py +225 -0
  86. elidia/workflow/__init__.py +1 -0
  87. elidia/workflow/engine.py +377 -0
  88. elidia_agent_cli-0.1.0.dist-info/METADATA +344 -0
  89. elidia_agent_cli-0.1.0.dist-info/RECORD +93 -0
  90. elidia_agent_cli-0.1.0.dist-info/WHEEL +5 -0
  91. elidia_agent_cli-0.1.0.dist-info/entry_points.txt +2 -0
  92. elidia_agent_cli-0.1.0.dist-info/licenses/LICENSE +57 -0
  93. elidia_agent_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,213 @@
1
+ """Audio generation via AiUtils Developer API.
2
+
3
+ Text-to-speech (ElevenLabs, OpenAI TTS) and music generation (Suno).
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import base64
8
+ import logging
9
+ import time
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from elidia.api.client import AiUtilsClient
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ TTS_MODELS = {
21
+ "elevenlabs-v2": {"vendor": "elevenlabs", "voices": ["rachel", "adam", "josh", "bella", "elli"]},
22
+ "tts-1": {"vendor": "openai", "voices": ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]},
23
+ "tts-1-hd": {"vendor": "openai", "voices": ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]},
24
+ }
25
+
26
+ MUSIC_MODELS = {
27
+ "suno-v4": {"vendor": "suno", "max_duration": 240},
28
+ }
29
+
30
+ DEFAULT_TTS_MODEL = "tts-1"
31
+ DEFAULT_MUSIC_MODEL = "suno-v4"
32
+ DEFAULT_OUTPUT_DIR = Path.home() / ".elidia" / "generated" / "audio"
33
+
34
+
35
+ @dataclass
36
+ class AudioResult:
37
+ file_path: str
38
+ model: str
39
+ text: str
40
+ voice: str
41
+ format: str = "mp3"
42
+ size_bytes: int = 0
43
+ elapsed_ms: int = 0
44
+
45
+
46
+ @dataclass
47
+ class MusicResult:
48
+ file_path: str
49
+ model: str
50
+ prompt: str
51
+ duration: int
52
+ format: str = "mp3"
53
+ size_bytes: int = 0
54
+ elapsed_ms: int = 0
55
+
56
+
57
+ async def generate_speech(
58
+ client: AiUtilsClient,
59
+ text: str,
60
+ model: str = DEFAULT_TTS_MODEL,
61
+ voice: str = "alloy",
62
+ output_dir: Path | None = None,
63
+ ) -> AudioResult:
64
+ logger.debug(f"Entered into generate_speech: model={model}, voice={voice}, text_len={len(text)}")
65
+ start = time.monotonic()
66
+
67
+ save_dir = output_dir or DEFAULT_OUTPUT_DIR
68
+ save_dir.mkdir(parents=True, exist_ok=True)
69
+
70
+ payload: dict[str, Any] = {
71
+ "model": model,
72
+ "input": text,
73
+ "voice": voice,
74
+ "response_format": "mp3",
75
+ }
76
+
77
+ async with httpx.AsyncClient(
78
+ base_url=client._base_url,
79
+ headers={
80
+ "Authorization": f"Bearer {client._api_key}",
81
+ "Content-Type": "application/json",
82
+ },
83
+ timeout=httpx.Timeout(60.0, connect=10.0),
84
+ ) as http:
85
+ response = await http.post("/audio/speech", json=payload)
86
+
87
+ if response.status_code == 401:
88
+ raise RuntimeError("Invalid API key for audio generation")
89
+ if response.status_code == 402:
90
+ raise RuntimeError("Insufficient balance for audio generation")
91
+ response.raise_for_status()
92
+
93
+ content_type = response.headers.get("content-type", "")
94
+
95
+ if "audio" in content_type or "octet-stream" in content_type:
96
+ audio_bytes = response.content
97
+ else:
98
+ data = response.json()
99
+ b64 = data.get("audio", data.get("data", ""))
100
+ if b64:
101
+ audio_bytes = base64.b64decode(b64)
102
+ else:
103
+ raise RuntimeError("No audio data in response")
104
+
105
+ timestamp = int(time.time())
106
+ safe_text = "".join(c if c.isalnum() or c in " -_" else "" for c in text[:30]).strip().replace(" ", "_")
107
+ filename = f"{timestamp}_{voice}_{safe_text}.mp3"
108
+ file_path = save_dir / filename
109
+ file_path.write_bytes(audio_bytes)
110
+
111
+ elapsed = int((time.monotonic() - start) * 1000)
112
+ logger.info(f"Speech generated: {file_path} ({len(audio_bytes)} bytes, {elapsed}ms)")
113
+
114
+ return AudioResult(
115
+ file_path=str(file_path),
116
+ model=model,
117
+ text=text,
118
+ voice=voice,
119
+ format="mp3",
120
+ size_bytes=len(audio_bytes),
121
+ elapsed_ms=elapsed,
122
+ )
123
+
124
+
125
+ async def generate_music(
126
+ client: AiUtilsClient,
127
+ prompt: str,
128
+ model: str = DEFAULT_MUSIC_MODEL,
129
+ duration: int = 30,
130
+ output_dir: Path | None = None,
131
+ ) -> MusicResult:
132
+ logger.debug(f"Entered into generate_music: model={model}, prompt={prompt[:60]!r}")
133
+ start = time.monotonic()
134
+
135
+ save_dir = output_dir or DEFAULT_OUTPUT_DIR
136
+ save_dir.mkdir(parents=True, exist_ok=True)
137
+
138
+ model_info = MUSIC_MODELS.get(model, MUSIC_MODELS[DEFAULT_MUSIC_MODEL])
139
+ duration = min(duration, model_info["max_duration"])
140
+
141
+ payload: dict[str, Any] = {
142
+ "model": model,
143
+ "prompt": prompt,
144
+ "duration": duration,
145
+ }
146
+
147
+ async with httpx.AsyncClient(
148
+ base_url=client._base_url,
149
+ headers={
150
+ "Authorization": f"Bearer {client._api_key}",
151
+ "Content-Type": "application/json",
152
+ },
153
+ timeout=httpx.Timeout(120.0, connect=10.0),
154
+ ) as http:
155
+ response = await http.post("/audio/music", json=payload)
156
+
157
+ if response.status_code == 401:
158
+ raise RuntimeError("Invalid API key for music generation")
159
+ if response.status_code == 402:
160
+ raise RuntimeError("Insufficient balance for music generation")
161
+ response.raise_for_status()
162
+
163
+ content_type = response.headers.get("content-type", "")
164
+
165
+ if "audio" in content_type or "octet-stream" in content_type:
166
+ audio_bytes = response.content
167
+ else:
168
+ data = response.json()
169
+ b64 = data.get("audio", data.get("data", ""))
170
+ if b64:
171
+ audio_bytes = base64.b64decode(b64)
172
+ elif data.get("url"):
173
+ async with httpx.AsyncClient(timeout=60.0) as dl:
174
+ dl_resp = await dl.get(data["url"])
175
+ dl_resp.raise_for_status()
176
+ audio_bytes = dl_resp.content
177
+ else:
178
+ raise RuntimeError("No audio data or URL in response")
179
+
180
+ timestamp = int(time.time())
181
+ safe_prompt = "".join(c if c.isalnum() or c in " -_" else "" for c in prompt[:30]).strip().replace(" ", "_")
182
+ filename = f"{timestamp}_music_{safe_prompt}.mp3"
183
+ file_path = save_dir / filename
184
+ file_path.write_bytes(audio_bytes)
185
+
186
+ elapsed = int((time.monotonic() - start) * 1000)
187
+ logger.info(f"Music generated: {file_path} ({len(audio_bytes)} bytes, {elapsed}ms)")
188
+
189
+ return MusicResult(
190
+ file_path=str(file_path),
191
+ model=model,
192
+ prompt=prompt,
193
+ duration=duration,
194
+ format="mp3",
195
+ size_bytes=len(audio_bytes),
196
+ elapsed_ms=elapsed,
197
+ )
198
+
199
+
200
+ def list_tts_voices(model: str = DEFAULT_TTS_MODEL) -> list[str]:
201
+ logger.debug(f"Entered into list_tts_voices: model={model}")
202
+ info = TTS_MODELS.get(model, {})
203
+ return info.get("voices", [])
204
+
205
+
206
+ def list_audio_models() -> list[dict[str, Any]]:
207
+ logger.debug("Entered into list_audio_models")
208
+ result: list[dict[str, Any]] = []
209
+ for name, info in TTS_MODELS.items():
210
+ result.append({"model": name, "type": "tts", "vendor": info["vendor"], "voices": info["voices"]})
211
+ for name, info in MUSIC_MODELS.items():
212
+ result.append({"model": name, "type": "music", "vendor": info["vendor"], "max_duration": info["max_duration"]})
213
+ return result
@@ -0,0 +1,260 @@
1
+ """Terminal image display — protocol detection and inline rendering.
2
+
3
+ Detects terminal capabilities and renders images inline using:
4
+ - iTerm2 inline images (OSC 1337)
5
+ - Kitty graphics protocol (ESC _G)
6
+ - Sixel graphics
7
+ - Fallback: save to file and print path
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import logging
13
+ import os
14
+ import struct
15
+ import sys
16
+ from dataclasses import dataclass
17
+ from enum import Enum
18
+ from pathlib import Path
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class TerminalProtocol(str, Enum):
24
+ ITERM2 = "iterm2"
25
+ KITTY = "kitty"
26
+ SIXEL = "sixel"
27
+ NONE = "none"
28
+
29
+
30
+ @dataclass
31
+ class DisplayResult:
32
+ protocol: TerminalProtocol
33
+ displayed: bool
34
+ file_path: str = ""
35
+ message: str = ""
36
+
37
+
38
+ def detect_terminal() -> TerminalProtocol:
39
+ logger.debug("Entered into detect_terminal")
40
+
41
+ term_program = os.environ.get("TERM_PROGRAM", "").lower()
42
+ if term_program in ("iterm.app", "iterm2"):
43
+ return TerminalProtocol.ITERM2
44
+
45
+ if os.environ.get("KITTY_WINDOW_ID"):
46
+ return TerminalProtocol.KITTY
47
+
48
+ term = os.environ.get("TERM", "")
49
+ if "xterm" in term:
50
+ lc_terminal = os.environ.get("LC_TERMINAL", "").lower()
51
+ if lc_terminal == "iterm2":
52
+ return TerminalProtocol.ITERM2
53
+
54
+ if _check_sixel_support():
55
+ return TerminalProtocol.SIXEL
56
+
57
+ return TerminalProtocol.NONE
58
+
59
+
60
+ def display_image(
61
+ image_path: str | Path,
62
+ max_width: int = 80,
63
+ max_height: int = 24,
64
+ protocol: TerminalProtocol | None = None,
65
+ ) -> DisplayResult:
66
+ logger.debug(f"Entered into display_image: path={image_path}")
67
+ path = Path(image_path)
68
+ if not path.exists():
69
+ return DisplayResult(
70
+ protocol=TerminalProtocol.NONE,
71
+ displayed=False,
72
+ file_path=str(path),
73
+ message=f"File not found: {path}",
74
+ )
75
+
76
+ proto = protocol or detect_terminal()
77
+ image_data = path.read_bytes()
78
+
79
+ if proto == TerminalProtocol.ITERM2:
80
+ return _display_iterm2(image_data, path, max_width)
81
+ elif proto == TerminalProtocol.KITTY:
82
+ return _display_kitty(image_data, path, max_width)
83
+ elif proto == TerminalProtocol.SIXEL:
84
+ return _display_sixel(image_data, path, max_width, max_height)
85
+ else:
86
+ return DisplayResult(
87
+ protocol=TerminalProtocol.NONE,
88
+ displayed=False,
89
+ file_path=str(path),
90
+ message=f"Image saved to: {path}",
91
+ )
92
+
93
+
94
+ def _display_iterm2(data: bytes, path: Path, max_width: int) -> DisplayResult:
95
+ logger.debug(f"Entered into _display_iterm2: size={len(data)}")
96
+ b64 = base64.b64encode(data).decode("ascii")
97
+
98
+ filename = base64.b64encode(path.name.encode()).decode("ascii")
99
+ size = len(data)
100
+
101
+ seq = (
102
+ f"\033]1337;File=name={filename};size={size};"
103
+ f"inline=1;width=auto;height=auto;preserveAspectRatio=1:"
104
+ f"{b64}\a"
105
+ )
106
+
107
+ sys.stdout.write(seq)
108
+ sys.stdout.write("\n")
109
+ sys.stdout.flush()
110
+
111
+ return DisplayResult(
112
+ protocol=TerminalProtocol.ITERM2,
113
+ displayed=True,
114
+ file_path=str(path),
115
+ message=f"Displayed via iTerm2 protocol ({len(data)} bytes)",
116
+ )
117
+
118
+
119
+ def _display_kitty(data: bytes, path: Path, max_width: int) -> DisplayResult:
120
+ logger.debug(f"Entered into _display_kitty: size={len(data)}")
121
+ b64 = base64.b64encode(data).decode("ascii")
122
+
123
+ chunk_size = 4096
124
+ chunks = [b64[i:i + chunk_size] for i in range(0, len(b64), chunk_size)]
125
+
126
+ for i, chunk in enumerate(chunks):
127
+ is_last = i == len(chunks) - 1
128
+ more = 0 if is_last else 1
129
+
130
+ if i == 0:
131
+ header = f"a=T,f=100,m={more}"
132
+ else:
133
+ header = f"m={more}"
134
+
135
+ sys.stdout.write(f"\033_G{header};{chunk}\033\\")
136
+
137
+ sys.stdout.write("\n")
138
+ sys.stdout.flush()
139
+
140
+ return DisplayResult(
141
+ protocol=TerminalProtocol.KITTY,
142
+ displayed=True,
143
+ file_path=str(path),
144
+ message=f"Displayed via Kitty protocol ({len(data)} bytes)",
145
+ )
146
+
147
+
148
+ def _display_sixel(data: bytes, path: Path, max_width: int, max_height: int) -> DisplayResult:
149
+ logger.debug(f"Entered into _display_sixel: size={len(data)}")
150
+ try:
151
+ from PIL import Image
152
+ except ImportError:
153
+ return DisplayResult(
154
+ protocol=TerminalProtocol.SIXEL,
155
+ displayed=False,
156
+ file_path=str(path),
157
+ message="Sixel display requires Pillow: pip install Pillow",
158
+ )
159
+
160
+ try:
161
+ img = Image.open(path)
162
+
163
+ cell_w, cell_h = _get_cell_size()
164
+ max_px_w = max_width * cell_w
165
+ max_px_h = max_height * cell_h
166
+
167
+ w, h = img.size
168
+ if w > max_px_w or h > max_px_h:
169
+ ratio = min(max_px_w / w, max_px_h / h)
170
+ new_w = int(w * ratio)
171
+ new_h = int(h * ratio)
172
+ img = img.resize((new_w, new_h), Image.LANCZOS)
173
+
174
+ img = img.convert("P", palette=Image.ADAPTIVE, colors=256)
175
+
176
+ palette = img.getpalette()
177
+ pixels = list(img.getdata())
178
+ w, h = img.size
179
+
180
+ sixel_data = _encode_sixel(pixels, palette, w, h)
181
+
182
+ sys.stdout.write(f"\033P0;0;0q\"1;1;{w};{h}")
183
+ sys.stdout.write(sixel_data)
184
+ sys.stdout.write("\033\\")
185
+ sys.stdout.write("\n")
186
+ sys.stdout.flush()
187
+
188
+ return DisplayResult(
189
+ protocol=TerminalProtocol.SIXEL,
190
+ displayed=True,
191
+ file_path=str(path),
192
+ message=f"Displayed via Sixel ({w}x{h})",
193
+ )
194
+ except Exception as e:
195
+ return DisplayResult(
196
+ protocol=TerminalProtocol.SIXEL,
197
+ displayed=False,
198
+ file_path=str(path),
199
+ message=f"Sixel encoding failed: {e}",
200
+ )
201
+
202
+
203
+ def _encode_sixel(pixels: list[int], palette: list[int] | None, width: int, height: int) -> str:
204
+ logger.debug(f"Entered into _encode_sixel: {width}x{height}")
205
+ lines: list[str] = []
206
+
207
+ if palette:
208
+ for i in range(0, min(len(palette), 768), 3):
209
+ color_idx = i // 3
210
+ r = int(palette[i] / 255 * 100)
211
+ g = int(palette[i + 1] / 255 * 100)
212
+ b = int(palette[i + 2] / 255 * 100)
213
+ lines.append(f"#{color_idx};2;{r};{g};{b}")
214
+
215
+ for band_y in range(0, height, 6):
216
+ band_colors: dict[int, list[int]] = {}
217
+ for dy in range(6):
218
+ y = band_y + dy
219
+ if y >= height:
220
+ break
221
+ for x in range(width):
222
+ px = pixels[y * width + x]
223
+ if px not in band_colors:
224
+ band_colors[px] = [0] * width
225
+ band_colors[px][x] |= 1 << dy
226
+
227
+ for color_idx, scanline in sorted(band_colors.items()):
228
+ lines.append(f"#{color_idx}")
229
+ for val in scanline:
230
+ lines.append(chr(63 + val))
231
+ lines.append("$")
232
+
233
+ lines.append("-")
234
+
235
+ return "".join(lines)
236
+
237
+
238
+ def _check_sixel_support() -> bool:
239
+ logger.debug("Entered into _check_sixel_support")
240
+ term = os.environ.get("TERM", "")
241
+ if "sixel" in term.lower():
242
+ return True
243
+ term_program = os.environ.get("TERM_PROGRAM", "").lower()
244
+ if term_program in ("mlterm", "contour", "foot", "wezterm"):
245
+ return True
246
+ return False
247
+
248
+
249
+ def _get_cell_size() -> tuple[int, int]:
250
+ logger.debug("Entered into _get_cell_size")
251
+ try:
252
+ import fcntl
253
+ import termios
254
+ result = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, b"\x00" * 8)
255
+ rows, cols, xpixel, ypixel = struct.unpack("HHHH", result)
256
+ if xpixel > 0 and ypixel > 0 and cols > 0 and rows > 0:
257
+ return xpixel // cols, ypixel // rows
258
+ except Exception:
259
+ pass
260
+ return 8, 16
@@ -0,0 +1,142 @@
1
+ """Image generation via AiUtils Developer API.
2
+
3
+ Supports multiple models (FLUX, DALL-E, Stable Diffusion) through the
4
+ unified /v1/images/generations endpoint. Saves generated images to disk
5
+ and optionally displays them inline in supported terminals.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import logging
11
+ import time
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from elidia.api.client import AiUtilsClient
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ IMAGE_MODELS = {
23
+ "flux-1.1-pro": {"vendor": "bfl", "supports_size": True, "max_size": 2048},
24
+ "flux-schnell": {"vendor": "bfl", "supports_size": True, "max_size": 1024},
25
+ "dall-e-3": {"vendor": "openai", "supports_size": True, "max_size": 1792},
26
+ "dall-e-2": {"vendor": "openai", "supports_size": True, "max_size": 1024},
27
+ "sd-3.5-large": {"vendor": "stability", "supports_size": True, "max_size": 1024},
28
+ "sdxl-turbo": {"vendor": "stability", "supports_size": True, "max_size": 1024},
29
+ }
30
+
31
+ DEFAULT_MODEL = "flux-schnell"
32
+ DEFAULT_OUTPUT_DIR = Path.home() / ".elidia" / "generated" / "images"
33
+
34
+
35
+ @dataclass
36
+ class ImageResult:
37
+ file_path: str
38
+ model: str
39
+ prompt: str
40
+ width: int
41
+ height: int
42
+ format: str = "png"
43
+ size_bytes: int = 0
44
+ elapsed_ms: int = 0
45
+ base64_data: str = ""
46
+
47
+
48
+ async def generate_image(
49
+ client: AiUtilsClient,
50
+ prompt: str,
51
+ model: str = DEFAULT_MODEL,
52
+ width: int = 1024,
53
+ height: int = 1024,
54
+ output_dir: Path | None = None,
55
+ ) -> ImageResult:
56
+ logger.debug(f"Entered into generate_image: model={model}, prompt={prompt[:60]!r}")
57
+ start = time.monotonic()
58
+
59
+ save_dir = output_dir or DEFAULT_OUTPUT_DIR
60
+ save_dir.mkdir(parents=True, exist_ok=True)
61
+
62
+ model_info = IMAGE_MODELS.get(model, IMAGE_MODELS[DEFAULT_MODEL])
63
+ max_size = model_info["max_size"]
64
+ width = min(width, max_size)
65
+ height = min(height, max_size)
66
+
67
+ payload: dict[str, Any] = {
68
+ "model": model,
69
+ "prompt": prompt,
70
+ "n": 1,
71
+ "response_format": "b64_json",
72
+ }
73
+
74
+ if model_info["supports_size"]:
75
+ payload["size"] = f"{width}x{height}"
76
+
77
+ async with httpx.AsyncClient(
78
+ base_url=client._base_url,
79
+ headers={
80
+ "Authorization": f"Bearer {client._api_key}",
81
+ "Content-Type": "application/json",
82
+ },
83
+ timeout=httpx.Timeout(120.0, connect=10.0),
84
+ ) as http:
85
+ response = await http.post("/images/generations", json=payload)
86
+
87
+ if response.status_code == 401:
88
+ raise RuntimeError("Invalid API key for image generation")
89
+ if response.status_code == 402:
90
+ raise RuntimeError("Insufficient balance for image generation")
91
+ if response.status_code == 429:
92
+ raise RuntimeError("Rate limited — try again in a moment")
93
+ response.raise_for_status()
94
+
95
+ data = response.json()
96
+
97
+ images = data.get("data", [])
98
+ if not images:
99
+ raise RuntimeError("No image returned from API")
100
+
101
+ image_data = images[0]
102
+ b64 = image_data.get("b64_json", "")
103
+ if not b64:
104
+ url = image_data.get("url", "")
105
+ if url:
106
+ async with httpx.AsyncClient(timeout=30.0) as dl:
107
+ img_resp = await dl.get(url)
108
+ img_resp.raise_for_status()
109
+ img_bytes = img_resp.content
110
+ else:
111
+ raise RuntimeError("No image data or URL in response")
112
+ else:
113
+ img_bytes = base64.b64decode(b64)
114
+
115
+ timestamp = int(time.time())
116
+ safe_prompt = "".join(c if c.isalnum() or c in " -_" else "" for c in prompt[:40]).strip().replace(" ", "_")
117
+ filename = f"{timestamp}_{safe_prompt}.png"
118
+ file_path = save_dir / filename
119
+ file_path.write_bytes(img_bytes)
120
+
121
+ elapsed = int((time.monotonic() - start) * 1000)
122
+ logger.info(f"Image generated: {file_path} ({len(img_bytes)} bytes, {elapsed}ms)")
123
+
124
+ return ImageResult(
125
+ file_path=str(file_path),
126
+ model=model,
127
+ prompt=prompt,
128
+ width=width,
129
+ height=height,
130
+ format="png",
131
+ size_bytes=len(img_bytes),
132
+ elapsed_ms=elapsed,
133
+ base64_data=b64 if b64 else base64.b64encode(img_bytes).decode("ascii"),
134
+ )
135
+
136
+
137
+ def list_image_models() -> list[dict[str, Any]]:
138
+ logger.debug("Entered into list_image_models")
139
+ return [
140
+ {"model": name, "vendor": info["vendor"], "max_size": info["max_size"]}
141
+ for name, info in IMAGE_MODELS.items()
142
+ ]