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