返回 ppt-master
backend_openrouter.py
根目录 / skills / ppt-master / scripts / image_backends / backend_openrouter.py
1 #!/usr/bin/env python3
2 """
3 OpenRouter image generation backend.
4
5 Configuration keys:
6 OPENROUTER_API_KEY (required)
7 OPENROUTER_BASE_URL (optional)
8 OPENROUTER_MODEL (optional)
9 """
10
11 import sys
12 from pathlib import Path
13
14 _SCRIPTS_DIR = Path(__file__).resolve().parents[1]
15 if str(_SCRIPTS_DIR) not in sys.path:
16 sys.path.insert(0, str(_SCRIPTS_DIR))
17
18 from console_encoding import configure_utf8_stdio # noqa: E402
19
20 configure_utf8_stdio()
21
22 if __name__ == "__main__":
23 print(__doc__)
24 print("Use via: python3 skills/ppt-master/scripts/image_gen.py \"prompt\" --backend openrouter")
25 raise SystemExit(0 if any(arg in {"-h", "--help", "help"} for arg in sys.argv[1:]) else 1)
26
27 import os
28 import time
29 import threading
30 import requests
31
32 from image_backends.backend_common import (
33 MAX_RETRIES,
34 decode_data_uri,
35 find_data_uri,
36 http_error,
37 is_permanent_error,
38 is_rate_limit_error,
39 normalize_image_size,
40 resolve_output_path,
41 retry_delay,
42 save_image_bytes,
43 )
44
45
46 # ╔══════════════════════════════════════════════════════════════════╗
47 # ║ Constants ║
48 # ╚══════════════════════════════════════════════════════════════════╝
49
50 VALID_ASPECT_RATIOS = [
51 "1:1", "1:4", "1:8",
52 "2:3", "3:2", "3:4", "4:1", "4:3",
53 "4:5", "5:4", "8:1", "9:16", "16:9", "21:9"
54 ]
55
56 VALID_IMAGE_SIZES = ["512px", "1K", "2K", "4K"]
57
58 DEFAULT_MODEL = "google/gemini-3.1-flash-image"
59 DEFAULT_ENDPOINT = "https://openrouter.ai/api/v1"
60
61 # ╔══════════════════════════════════════════════════════════════════╗
62 # ║ Image Generation ║
63 # ╚══════════════════════════════════════════════════════════════════╝
64
65 def _resolve_url(base_url: str) -> str:
66 """Resolve the OpenRouter generation endpoint."""
67 return base_url.rstrip("/") + "/chat/completions"
68
69 def _message_image_uri(message: dict) -> str | None:
70 """
71 Locate the generated image in a chat completion message.
72
73 OpenRouter returns it in a dedicated `images` array; other OpenAI-compatible endpoints
74 reachable through OPENROUTER_BASE_URL inline it in the message content instead.
75 """
76 images = message.get("images")
77 if images:
78 url = images[0].get("image_url")
79 if isinstance(url, dict):
80 url = url.get("url")
81 if url:
82 return url
83
84 return find_data_uri(message.get("content"))
85
86
87 def _generate_image(api_key: str, prompt: str,
88 aspect_ratio: str = "1:1", image_size: str = "1K",
89 output_dir: str = None, filename: str = None,
90 model: str = DEFAULT_MODEL, base_url: str = DEFAULT_ENDPOINT) -> str:
91 """
92 Image generation via OpenRouter's API.
93 """
94
95 url = _resolve_url(base_url)
96
97 headers = {
98 "Authorization": f"Bearer {api_key}",
99 "Content-Type": "application/json"
100 }
101
102 payload = {
103 "model": model,
104 "messages": [
105 {
106 "role": "user",
107 "content": prompt
108 }
109 ],
110 "modalities": ["image", "text"],
111 "image_config": {
112 "aspect_ratio": aspect_ratio,
113 "image_size": "512" if image_size == "512px" else image_size
114 }
115 }
116
117 print(f"[OpenRouter]")
118 print(f" Model: {model}")
119 print(f" Prompt: {prompt[:120]}{'...' if len(prompt) > 120 else ''}")
120 print(f" Aspect Ratio: {aspect_ratio}")
121 print(f" Image Size: {image_size}")
122 print()
123
124 start_time = time.time()
125 print(f" [..] Generating...", end="", flush=True)
126
127 # Heartbeat thread
128 heartbeat_stop = threading.Event()
129
130 def _heartbeat():
131 while not heartbeat_stop.is_set():
132 heartbeat_stop.wait(5)
133 if not heartbeat_stop.is_set():
134 elapsed = time.time() - start_time
135 print(f" {elapsed:.0f}s...", end="", flush=True)
136
137 hb_thread = threading.Thread(target=_heartbeat, daemon=True)
138 hb_thread.start()
139
140 try:
141 response = requests.post(url, headers=headers, json=payload, timeout=300)
142 if response.status_code != 200:
143 raise http_error(response, "OpenRouter image generation")
144 result = response.json()
145 finally:
146 heartbeat_stop.set()
147 hb_thread.join(timeout=1)
148
149 elapsed = time.time() - start_time
150 print(f"\n [DONE] Image generated ({elapsed:.1f}s)")
151
152 if result.get("choices"):
153 message = result["choices"][0]["message"]
154 image_uri = _message_image_uri(message)
155 if image_uri:
156 image_data, content_type = decode_data_uri(image_uri)
157 path = resolve_output_path(prompt, output_dir, filename, ".png")
158 return save_image_bytes(image_data, path, content_type)
159
160 raise RuntimeError("No image was generated. The server may have refused the request.")
161
162
163 # ╔══════════════════════════════════════════════════════════════════╗
164 # ║ Public Entry Point ║
165 # ╚══════════════════════════════════════════════════════════════════╝
166
167 def generate(prompt: str,
168 aspect_ratio: str = "1:1", image_size: str = "1K",
169 output_dir: str = None, filename: str = None,
170 model: str = None, max_retries: int = MAX_RETRIES) -> str:
171 """
172 OpenRouter image generation with automatic retry.
173
174 Reads credentials from the current process environment or a `.env` file:
175 OPENROUTER_API_KEY
176 OPENROUTER_BASE_URL
177 OPENROUTER_MODEL (optional override)
178 """
179 api_key = os.environ.get("OPENROUTER_API_KEY")
180 base_url = os.environ.get("OPENROUTER_BASE_URL") or DEFAULT_ENDPOINT
181
182 if not api_key:
183 raise ValueError(
184 "No API key found. Set OPENROUTER_API_KEY in the current environment or a .env file."
185 )
186
187 if model is None:
188 model = os.environ.get("OPENROUTER_MODEL") or DEFAULT_MODEL
189
190 image_size = normalize_image_size(image_size)
191
192 if aspect_ratio not in VALID_ASPECT_RATIOS:
193 raise ValueError(f"Invalid aspect ratio '{aspect_ratio}'. Valid: {VALID_ASPECT_RATIOS}")
194
195 if image_size not in VALID_IMAGE_SIZES:
196 raise ValueError(f"Invalid image size '{image_size}'. Valid: {VALID_IMAGE_SIZES}")
197
198 last_error = None
199 for attempt in range(max_retries + 1):
200 try:
201 return _generate_image(api_key, prompt,
202 aspect_ratio, image_size, output_dir,
203 filename, model, base_url)
204 except Exception as e:
205 last_error = e
206 if is_permanent_error(e):
207 raise
208 if attempt < max_retries and is_rate_limit_error(e):
209 delay = retry_delay(attempt, rate_limited=True)
210 print(f"\n [WARN] Rate limit hit (attempt {attempt + 1}/{max_retries + 1}). "
211 f"Waiting {delay}s before retry...")
212 time.sleep(delay)
213 elif attempt < max_retries:
214 delay = retry_delay(attempt, rate_limited=False)
215 print(f"\n [WARN] Error (attempt {attempt + 1}/{max_retries + 1}): {e}. "
216 f"Retrying in {delay}s...")
217 time.sleep(delay)
218 else:
219 break
220
221 raise RuntimeError(f"Failed after {max_retries + 1} attempts. Last error: {last_error}")
222
222 lines PYTHON